Initial template

This commit is contained in:
Michael Franz 2025-12-04 13:30:07 +01:00
parent dcd4925371
commit 88030ce384
14 changed files with 3764 additions and 140 deletions

View File

@ -2,7 +2,6 @@ package main
import (
"log"
"os"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
@ -40,6 +39,7 @@ func main() {
// Initialize handlers
userHandler := handlers.NewUserHandler(db)
menuHandler := handlers.NewMenuHandler(db)
// Set Gin mode
if cfg.Server.Env == "production" {
@ -79,6 +79,12 @@ func main() {
// Current user
api.GET("/auth/me", authHandler.GetCurrentUser)
// Menu and UI configuration
api.GET("/menu", menuHandler.GetMenuItems)
api.GET("/toolbar", menuHandler.GetToolbarItems)
api.GET("/settings", menuHandler.GetUserSettings)
api.PUT("/settings", menuHandler.UpdateUserSettings)
// User management (admin only)
users := api.Group("/users")
users.Use(middleware.RequireAdmin())

View File

@ -4,9 +4,37 @@ go 1.21
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/go-sql-driver/mysql v1.7.1
golang.org/x/crypto v0.20.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22
golang.org/x/crypto v0.23.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1 +1,97 @@
# This file will be generated by go mod download
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -58,11 +58,11 @@ func Connect(cfg *config.DatabaseConfig) (*DB, error) {
}
func (db *DB) Migrate() error {
var createUsersTable string
var queries []string
if db.Type == "sqlite" {
createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
queries = []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
@ -71,11 +71,46 @@ func (db *DB) Migrate() error {
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
);`,
`CREATE TABLE IF NOT EXISTS menu_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
title TEXT NOT NULL,
icon TEXT DEFAULT '',
route TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
role_required TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES menu_items(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
sidebar_position TEXT DEFAULT 'left',
sidebar_collapsed INTEGER DEFAULT 0,
theme TEXT DEFAULT 'light',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS toolbar_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
icon TEXT DEFAULT '',
action TEXT NOT NULL,
shortcut TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
separator INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`,
}
} else {
createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
queries = []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
@ -84,14 +119,208 @@ func (db *DB) Migrate() error {
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
`
);`,
`CREATE TABLE IF NOT EXISTS menu_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
parent_id BIGINT,
title VARCHAR(255) NOT NULL,
icon VARCHAR(100) DEFAULT '',
route VARCHAR(255) DEFAULT '',
sort_order INT DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
role_required VARCHAR(50) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES menu_items(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS user_settings (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNIQUE NOT NULL,
sidebar_position VARCHAR(10) DEFAULT 'left',
sidebar_collapsed BOOLEAN DEFAULT FALSE,
theme VARCHAR(20) DEFAULT 'light',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`,
`CREATE TABLE IF NOT EXISTS toolbar_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
icon VARCHAR(100) DEFAULT '',
action VARCHAR(100) NOT NULL,
shortcut VARCHAR(50) DEFAULT '',
sort_order INT DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
separator BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);`,
}
}
if _, err := db.Exec(createUsersTable); err != nil {
return fmt.Errorf("failed to create users table: %w", err)
for _, query := range queries {
if _, err := db.Exec(query); err != nil {
return fmt.Errorf("failed to execute migration: %w", err)
}
}
// Seed default menu items
if err := db.seedMenuItems(); err != nil {
return fmt.Errorf("failed to seed menu items: %w", err)
}
// Seed default toolbar items
if err := db.seedToolbarItems(); err != nil {
return fmt.Errorf("failed to seed toolbar items: %w", err)
}
log.Println("Database migration completed successfully")
return nil
}
func (db *DB) seedMenuItems() error {
// Check if menu items already exist
var count int
err := db.QueryRow("SELECT COUNT(*) FROM menu_items").Scan(&count)
if err != nil {
return err
}
if count > 0 {
return nil // Already seeded
}
menuItems := []struct {
parentID *int64
title string
icon string
route string
sortOrder int
roleRequired string
}{
{nil, "Dashboard", "home", "/dashboard", 1, ""},
{nil, "Verwaltung", "settings", "", 2, "admin"},
{nil, "Berichte", "chart", "", 3, ""},
{nil, "Einstellungen", "cog", "", 4, ""},
}
// Insert root items
for _, item := range menuItems {
var result int64
if db.Type == "sqlite" {
res, err := db.Exec(`INSERT INTO menu_items (parent_id, title, icon, route, sort_order, role_required) VALUES (?, ?, ?, ?, ?, ?)`,
item.parentID, item.title, item.icon, item.route, item.sortOrder, item.roleRequired)
if err != nil {
return err
}
result, _ = res.LastInsertId()
} else {
res, err := db.Exec(`INSERT INTO menu_items (parent_id, title, icon, route, sort_order, role_required) VALUES (?, ?, ?, ?, ?, ?)`,
item.parentID, item.title, item.icon, item.route, item.sortOrder, item.roleRequired)
if err != nil {
return err
}
result, _ = res.LastInsertId()
}
// Add sub-items based on parent
switch item.title {
case "Verwaltung":
subItems := []struct {
title string
icon string
route string
order int
}{
{"Benutzer", "users", "/users", 1},
{"Rollen", "shield", "/roles", 2},
{"Gruppen", "folder", "/groups", 3},
}
for _, sub := range subItems {
_, err := db.Exec(`INSERT INTO menu_items (parent_id, title, icon, route, sort_order, role_required) VALUES (?, ?, ?, ?, ?, ?)`,
result, sub.title, sub.icon, sub.route, sub.order, "admin")
if err != nil {
return err
}
}
case "Berichte":
subItems := []struct {
title string
icon string
route string
order int
}{
{"Aktivitäten", "activity", "/reports/activities", 1},
{"Statistiken", "bar-chart", "/reports/statistics", 2},
{"Export", "download", "/reports/export", 3},
}
for _, sub := range subItems {
_, err := db.Exec(`INSERT INTO menu_items (parent_id, title, icon, route, sort_order, role_required) VALUES (?, ?, ?, ?, ?, ?)`,
result, sub.title, sub.icon, sub.route, sub.order, "")
if err != nil {
return err
}
}
case "Einstellungen":
subItems := []struct {
title string
icon string
route string
order int
}{
{"Profil", "user", "/profile", 1},
{"Sicherheit", "lock", "/settings/security", 2},
{"Anzeige", "monitor", "/settings/display", 3},
}
for _, sub := range subItems {
_, err := db.Exec(`INSERT INTO menu_items (parent_id, title, icon, route, sort_order, role_required) VALUES (?, ?, ?, ?, ?, ?)`,
result, sub.title, sub.icon, sub.route, sub.order, "")
if err != nil {
return err
}
}
}
}
return nil
}
func (db *DB) seedToolbarItems() error {
// Check if toolbar items already exist
var count int
err := db.QueryRow("SELECT COUNT(*) FROM toolbar_items").Scan(&count)
if err != nil {
return err
}
if count > 0 {
return nil // Already seeded
}
toolbarItems := []struct {
title string
icon string
action string
shortcut string
sortOrder int
separator bool
}{
{"Ausschneiden", "scissors", "cut", "Ctrl+X", 1, false},
{"Kopieren", "copy", "copy", "Ctrl+C", 2, false},
{"Einfügen", "clipboard", "paste", "Ctrl+V", 3, true},
{"Rückgängig", "rotate-ccw", "undo", "Ctrl+Z", 4, false},
{"Wiederholen", "rotate-cw", "redo", "Ctrl+Y", 5, true},
{"Speichern", "save", "save", "Ctrl+S", 6, false},
{"Drucken", "printer", "print", "Ctrl+P", 7, true},
{"Suchen", "search", "search", "Ctrl+F", 8, false},
{"Hilfe", "help-circle", "help", "F1", 9, false},
}
for _, item := range toolbarItems {
_, err := db.Exec(`INSERT INTO toolbar_items (title, icon, action, shortcut, sort_order, separator) VALUES (?, ?, ?, ?, ?, ?)`,
item.title, item.icon, item.action, item.shortcut, item.sortOrder, item.separator)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,197 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"admintemplate/internal/database"
"admintemplate/internal/models"
)
type MenuHandler struct {
db *database.DB
}
func NewMenuHandler(db *database.DB) *MenuHandler {
return &MenuHandler{db: db}
}
// GetMenuItems returns all menu items organized in a tree structure
func (h *MenuHandler) GetMenuItems(c *gin.Context) {
userRole := c.GetString("userRole")
// Get all active menu items
rows, err := h.db.Query(`
SELECT id, parent_id, title, icon, route, sort_order, active, role_required
FROM menu_items
WHERE active = 1
ORDER BY sort_order
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch menu items"})
return
}
defer rows.Close()
var allItems []models.MenuItem
for rows.Next() {
var item models.MenuItem
var parentID sql.NullInt64
err := rows.Scan(&item.ID, &parentID, &item.Title, &item.Icon, &item.Route, &item.SortOrder, &item.Active, &item.RoleReq)
if err != nil {
continue
}
if parentID.Valid {
item.ParentID = &parentID.Int64
}
// Filter by role
if item.RoleReq != "" && item.RoleReq != userRole && userRole != "admin" {
continue
}
allItems = append(allItems, item)
}
// Build tree structure
menuTree := buildMenuTree(allItems, nil)
c.JSON(http.StatusOK, menuTree)
}
// buildMenuTree creates a nested menu structure
func buildMenuTree(items []models.MenuItem, parentID *int64) []models.MenuItem {
var result []models.MenuItem
for _, item := range items {
if (parentID == nil && item.ParentID == nil) ||
(parentID != nil && item.ParentID != nil && *parentID == *item.ParentID) {
item.Children = buildMenuTree(items, &item.ID)
result = append(result, item)
}
}
return result
}
// GetToolbarItems returns all toolbar items
func (h *MenuHandler) GetToolbarItems(c *gin.Context) {
rows, err := h.db.Query(`
SELECT id, title, icon, action, shortcut, sort_order, active, separator
FROM toolbar_items
WHERE active = 1
ORDER BY sort_order
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch toolbar items"})
return
}
defer rows.Close()
var items []models.ToolbarItem
for rows.Next() {
var item models.ToolbarItem
err := rows.Scan(&item.ID, &item.Title, &item.Icon, &item.Action, &item.Shortcut, &item.SortOrder, &item.Active, &item.Separator)
if err != nil {
continue
}
items = append(items, item)
}
c.JSON(http.StatusOK, items)
}
// GetUserSettings returns user settings
func (h *MenuHandler) GetUserSettings(c *gin.Context) {
userID := c.GetInt64("userID")
var settings models.UserSettings
err := h.db.QueryRow(`
SELECT id, user_id, sidebar_position, sidebar_collapsed, theme
FROM user_settings
WHERE user_id = ?
`, userID).Scan(&settings.ID, &settings.UserID, &settings.SidebarPosition, &settings.SidebarCollapsed, &settings.Theme)
if err == sql.ErrNoRows {
// Return default settings
settings = models.UserSettings{
UserID: userID,
SidebarPosition: "left",
SidebarCollapsed: false,
Theme: "light",
}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
c.JSON(http.StatusOK, settings)
}
// UpdateUserSettings updates user settings
func (h *MenuHandler) UpdateUserSettings(c *gin.Context) {
userID := c.GetInt64("userID")
var req models.UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if settings exist
var existingID int64
err := h.db.QueryRow("SELECT id FROM user_settings WHERE user_id = ?", userID).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new settings
position := "left"
collapsed := false
theme := "light"
if req.SidebarPosition != nil {
position = *req.SidebarPosition
}
if req.SidebarCollapsed != nil {
collapsed = *req.SidebarCollapsed
}
if req.Theme != nil {
theme = *req.Theme
}
_, err = h.db.Exec(`
INSERT INTO user_settings (user_id, sidebar_position, sidebar_collapsed, theme)
VALUES (?, ?, ?, ?)
`, userID, position, collapsed, theme)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
return
}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check settings"})
return
} else {
// Update existing settings
if req.SidebarPosition != nil {
if _, err := h.db.Exec("UPDATE user_settings SET sidebar_position = ? WHERE user_id = ?", *req.SidebarPosition, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update sidebar position"})
return
}
}
if req.SidebarCollapsed != nil {
if _, err := h.db.Exec("UPDATE user_settings SET sidebar_collapsed = ? WHERE user_id = ?", *req.SidebarCollapsed, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update sidebar collapsed"})
return
}
}
if req.Theme != nil {
if _, err := h.db.Exec("UPDATE user_settings SET theme = ? WHERE user_id = ?", *req.Theme, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update theme"})
return
}
}
}
// Return updated settings
h.GetUserSettings(c)
}

View File

@ -0,0 +1,52 @@
package models
import (
"time"
)
// MenuItem represents a menu entry with optional parent for submenus
type MenuItem struct {
ID int64 `json:"id" db:"id"`
ParentID *int64 `json:"parent_id" db:"parent_id"` // NULL for root items
Title string `json:"title" db:"title"`
Icon string `json:"icon" db:"icon"`
Route string `json:"route" db:"route"`
SortOrder int `json:"sort_order" db:"sort_order"`
Active bool `json:"active" db:"active"`
RoleReq string `json:"role_required" db:"role_required"` // "admin", "user", or "" for all
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Children []MenuItem `json:"children,omitempty"` // Populated in code, not DB
}
// UserSettings stores user preferences
type UserSettings struct {
ID int64 `json:"id" db:"id"`
UserID int64 `json:"user_id" db:"user_id"`
SidebarPosition string `json:"sidebar_position" db:"sidebar_position"` // "left" or "right"
SidebarCollapsed bool `json:"sidebar_collapsed" db:"sidebar_collapsed"`
Theme string `json:"theme" db:"theme"` // "light" or "dark"
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ToolbarItem represents a toolbar button
type ToolbarItem struct {
ID int64 `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Icon string `json:"icon" db:"icon"`
Action string `json:"action" db:"action"` // action identifier
Shortcut string `json:"shortcut" db:"shortcut"`
SortOrder int `json:"sort_order" db:"sort_order"`
Active bool `json:"active" db:"active"`
Separator bool `json:"separator" db:"separator"` // show separator after this item
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// UpdateSettingsRequest for updating user settings
type UpdateSettingsRequest struct {
SidebarPosition *string `json:"sidebar_position"`
SidebarCollapsed *bool `json:"sidebar_collapsed"`
Theme *string `json:"theme"`
}

1513
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,216 @@
<template>
<div
class="main-layout"
:class="{
'sidebar-left': sidebarPosition === 'left',
'sidebar-right': sidebarPosition === 'right',
'sidebar-collapsed': sidebarCollapsed,
'theme-dark': theme === 'dark'
}"
>
<Sidebar
:position="sidebarPosition"
:collapsed="sidebarCollapsed"
:theme="theme"
@update:position="updatePosition"
@update:collapsed="updateCollapsed"
/>
<div class="main-content">
<Toolbar
:sidebarPosition="sidebarPosition"
:theme="theme"
@action="handleToolbarAction"
@update:sidebarPosition="updatePosition"
@update:theme="updateTheme"
/>
<div class="content-area">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Sidebar from './Sidebar.vue'
import Toolbar from './Toolbar.vue'
import { menuService } from '../services/menu'
const sidebarPosition = ref('left')
const sidebarCollapsed = ref(false)
const theme = ref('light')
const updatePosition = async (position) => {
sidebarPosition.value = position
try {
await menuService.updateUserSettings({ sidebar_position: position })
} catch (err) {
console.error('Failed to save position:', err)
}
}
const updateCollapsed = async (collapsed) => {
sidebarCollapsed.value = collapsed
try {
await menuService.updateUserSettings({ sidebar_collapsed: collapsed })
} catch (err) {
console.error('Failed to save collapsed state:', err)
}
}
const updateTheme = async (newTheme) => {
theme.value = newTheme
applyTheme(newTheme)
try {
await menuService.updateUserSettings({ theme: newTheme })
} catch (err) {
console.error('Failed to save theme:', err)
}
}
const applyTheme = (themeName) => {
if (themeName === 'dark') {
document.documentElement.classList.add('dark-theme')
} else {
document.documentElement.classList.remove('dark-theme')
}
}
const handleToolbarAction = (action) => {
console.log('Toolbar action:', action)
}
const loadSettings = async () => {
try {
const settings = await menuService.getUserSettings()
sidebarPosition.value = settings.sidebar_position || 'left'
sidebarCollapsed.value = settings.sidebar_collapsed || false
theme.value = settings.theme || 'light'
applyTheme(theme.value)
} catch (err) {
console.error('Failed to load settings:', err)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.main-layout {
display: flex;
min-height: 100vh;
background: #f1f5f9;
transition: background-color 0.3s ease;
}
.main-layout.theme-dark {
background: #0f172a;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
transition: margin 0.3s ease;
}
/* Sidebar left positioning */
.sidebar-left .main-content {
margin-left: 250px;
}
.sidebar-left.sidebar-collapsed .main-content {
margin-left: 60px;
}
/* Sidebar right positioning */
.sidebar-right .main-content {
margin-right: 250px;
}
.sidebar-right.sidebar-collapsed .main-content {
margin-right: 60px;
}
.content-area {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.theme-dark .content-area {
color: #e2e8f0;
}
/* Dark theme card styles */
.theme-dark :deep(.card) {
background: #1e293b;
border-color: #334155;
color: #e2e8f0;
}
.theme-dark :deep(.card-title) {
color: #f1f5f9;
}
.theme-dark :deep(.table) {
color: #e2e8f0;
}
.theme-dark :deep(.table td),
.theme-dark :deep(.table th) {
border-color: #334155;
}
.theme-dark :deep(h1),
.theme-dark :deep(h2),
.theme-dark :deep(h3) {
color: #f1f5f9;
}
.theme-dark :deep(.stat-card) {
background: #1e293b;
border-color: #334155;
}
.theme-dark :deep(.stat-value) {
color: #f1f5f9;
}
.theme-dark :deep(.stat-label) {
color: #94a3b8;
}
.theme-dark :deep(.form-input),
.theme-dark :deep(.form-select) {
background: #0f172a;
border-color: #334155;
color: #e2e8f0;
}
.theme-dark :deep(.btn-secondary) {
background: #334155;
color: #e2e8f0;
}
.theme-dark :deep(.btn-secondary:hover) {
background: #475569;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar-left .main-content,
.sidebar-right .main-content {
margin-left: 0;
margin-right: 0;
}
.sidebar-left.sidebar-collapsed .main-content,
.sidebar-right.sidebar-collapsed .main-content {
margin-left: 60px;
margin-right: 0;
}
}
</style>

View File

@ -0,0 +1,457 @@
<template>
<aside
class="sidebar"
:class="{
'sidebar-left': position === 'left',
'sidebar-right': position === 'right',
'sidebar-collapsed': collapsed
}"
>
<div class="sidebar-header">
<div class="sidebar-logo" v-if="!collapsed">
<span class="logo-icon">&#9881;</span>
<span class="logo-text">AdminTemplate</span>
</div>
<button class="collapse-btn" @click="toggleCollapse" :title="collapsed ? 'Erweitern' : 'Einklappen'">
<span v-if="position === 'left'">{{ collapsed ? '&#9654;' : '&#9664;' }}</span>
<span v-else>{{ collapsed ? '&#9664;' : '&#9654;' }}</span>
</button>
</div>
<nav class="sidebar-nav">
<ul class="menu-list">
<li
v-for="item in menuItems"
:key="item.id"
class="menu-item"
:class="{ 'has-children': item.children && item.children.length > 0 }"
>
<div
class="menu-link"
:class="{ 'active': isActive(item), 'expanded': isExpanded(item.id) }"
@click="handleMenuClick(item)"
>
<span class="menu-icon" v-html="getIcon(item.icon)"></span>
<span class="menu-title" v-if="!collapsed">{{ item.title }}</span>
<span
class="menu-arrow"
v-if="!collapsed && item.children && item.children.length > 0"
>
{{ isExpanded(item.id) ? '&#9660;' : '&#9654;' }}
</span>
</div>
<!-- Submenu -->
<ul
v-if="item.children && item.children.length > 0"
class="submenu"
:class="{ 'submenu-open': isExpanded(item.id) && !collapsed }"
>
<li v-for="child in item.children" :key="child.id" class="submenu-item">
<router-link
:to="child.route"
class="submenu-link"
:class="{ 'active': $route.path === child.route }"
>
<span class="submenu-icon" v-html="getIcon(child.icon)"></span>
<span class="submenu-title">{{ child.title }}</span>
</router-link>
</li>
</ul>
</li>
</ul>
</nav>
<div class="sidebar-footer" v-if="!collapsed">
<div class="position-toggle">
<button
class="position-btn"
:class="{ 'active': position === 'left' }"
@click="setPosition('left')"
title="Sidebar links"
>L</button>
<button
class="position-btn"
:class="{ 'active': position === 'right' }"
@click="setPosition('right')"
title="Sidebar rechts"
>R</button>
</div>
</div>
</aside>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { menuService } from '../services/menu'
const route = useRoute()
const router = useRouter()
const props = defineProps({
position: {
type: String,
default: 'left'
},
collapsed: {
type: Boolean,
default: false
},
theme: {
type: String,
default: 'light'
}
})
const emit = defineEmits(['update:position', 'update:collapsed'])
const menuItems = ref([])
const expandedItems = ref([])
const icons = {
'home': '&#127968;',
'settings': '&#9881;',
'chart': '&#128200;',
'cog': '&#9881;',
'users': '&#128101;',
'shield': '&#128737;',
'folder': '&#128193;',
'activity': '&#128202;',
'bar-chart': '&#128202;',
'download': '&#11015;',
'user': '&#128100;',
'lock': '&#128274;',
'monitor': '&#128187;',
'default': '&#9679;'
}
const getIcon = (iconName) => {
return icons[iconName] || icons['default']
}
const isActive = (item) => {
if (item.route && route.path === item.route) return true
if (item.children) {
return item.children.some(child => route.path === child.route)
}
return false
}
const isExpanded = (itemId) => {
return expandedItems.value.includes(itemId)
}
const toggleExpand = (itemId) => {
const index = expandedItems.value.indexOf(itemId)
if (index > -1) {
expandedItems.value.splice(index, 1)
} else {
expandedItems.value.push(itemId)
}
}
const handleMenuClick = (item) => {
if (item.children && item.children.length > 0) {
toggleExpand(item.id)
} else if (item.route) {
router.push(item.route)
}
}
const toggleCollapse = () => {
emit('update:collapsed', !props.collapsed)
}
const setPosition = (pos) => {
emit('update:position', pos)
}
const loadMenu = async () => {
try {
menuItems.value = await menuService.getMenuItems()
// Auto-expand active parent
menuItems.value.forEach(item => {
if (item.children && item.children.some(child => route.path === child.route)) {
if (!expandedItems.value.includes(item.id)) {
expandedItems.value.push(item.id)
}
}
})
} catch (err) {
console.error('Failed to load menu:', err)
}
}
onMounted(loadMenu)
// Watch for route changes to expand parent menu
watch(() => route.path, () => {
menuItems.value.forEach(item => {
if (item.children && item.children.some(child => route.path === child.route)) {
if (!expandedItems.value.includes(item.id)) {
expandedItems.value.push(item.id)
}
}
})
})
</script>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
width: 250px;
height: 100vh;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
color: #e2e8f0;
transition: all 0.3s ease;
position: fixed;
top: 0;
z-index: 1000;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.sidebar-left {
left: 0;
}
.sidebar-right {
right: 0;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
}
.sidebar-collapsed {
width: 60px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #334155;
min-height: 60px;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
font-size: 1.5rem;
}
.logo-text {
font-weight: 600;
font-size: 1.1rem;
white-space: nowrap;
}
.collapse-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.collapse-btn:hover {
background: #334155;
color: #e2e8f0;
}
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
margin: 2px 8px;
}
.menu-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
gap: 0.75rem;
}
.menu-link:hover {
background: #334155;
}
.menu-link.active {
background: #3b82f6;
color: white;
}
.menu-link.expanded {
background: #1e3a5f;
}
.menu-icon {
font-size: 1.1rem;
min-width: 24px;
text-align: center;
}
.menu-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-arrow {
font-size: 0.7rem;
transition: transform 0.2s;
}
.submenu {
list-style: none;
padding: 0;
margin: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.submenu-open {
max-height: 500px;
}
.submenu-item {
margin: 2px 0;
}
.submenu-link {
display: flex;
align-items: center;
padding: 0.5rem 1rem 0.5rem 2.5rem;
color: #94a3b8;
text-decoration: none;
border-radius: 6px;
transition: all 0.2s;
gap: 0.5rem;
}
.submenu-link:hover {
background: #334155;
color: #e2e8f0;
}
.submenu-link.active {
background: #1e40af;
color: white;
}
.submenu-icon {
font-size: 0.9rem;
}
.submenu-title {
font-size: 0.9rem;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid #334155;
}
.position-toggle {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.position-btn {
width: 36px;
height: 36px;
border: 1px solid #475569;
background: #1e293b;
color: #94a3b8;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.position-btn:hover {
background: #334155;
color: #e2e8f0;
}
.position-btn.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Collapsed state adjustments */
.sidebar-collapsed .menu-link {
justify-content: center;
padding: 0.75rem;
}
.sidebar-collapsed .menu-icon {
margin: 0;
}
.sidebar-collapsed .submenu {
position: absolute;
left: 60px;
top: 0;
background: #1e293b;
border-radius: 6px;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.2);
min-width: 200px;
max-height: none;
display: none;
}
.sidebar-collapsed .menu-item:hover .submenu {
display: block;
}
.sidebar-collapsed .submenu-link {
padding-left: 1rem;
}
.sidebar-right.sidebar-collapsed .submenu {
left: auto;
right: 60px;
}
/* Scrollbar styling */
.sidebar-nav::-webkit-scrollbar {
width: 6px;
}
.sidebar-nav::-webkit-scrollbar-track {
background: #1e293b;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
.sidebar-nav::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
</style>

View File

@ -0,0 +1,816 @@
<template>
<div class="toolbar" :class="{ 'toolbar-dark': theme === 'dark' }">
<div class="toolbar-group">
<template v-for="(item, index) in toolbarItems" :key="item.id">
<button
class="toolbar-btn"
:title="`${item.title} (${item.shortcut})`"
@click="handleAction(item.action)"
>
<span class="toolbar-icon" v-html="getIcon(item.icon)"></span>
<span class="toolbar-label">{{ item.title }}</span>
</button>
<div v-if="item.separator" class="toolbar-separator"></div>
</template>
</div>
<div class="toolbar-spacer"></div>
<div class="toolbar-group toolbar-right">
<!-- Settings Dropdown -->
<div class="dropdown" ref="settingsDropdown">
<button
class="toolbar-btn dropdown-toggle"
@click="toggleSettingsMenu"
title="Einstellungen"
>
<span class="toolbar-icon">&#9881;</span>
</button>
<div class="dropdown-menu" :class="{ 'show': showSettingsMenu }">
<div class="dropdown-header">Erscheinungsbild</div>
<button
class="dropdown-item"
:class="{ 'active': theme === 'light' }"
@click="setTheme('light')"
>
<span class="dropdown-icon">&#9788;</span>
Hell
<span v-if="theme === 'light'" class="check-icon">&#10003;</span>
</button>
<button
class="dropdown-item"
:class="{ 'active': theme === 'dark' }"
@click="setTheme('dark')"
>
<span class="dropdown-icon">&#9790;</span>
Dunkel
<span v-if="theme === 'dark'" class="check-icon">&#10003;</span>
</button>
<div class="dropdown-divider"></div>
<div class="dropdown-header">Sidebar Position</div>
<button
class="dropdown-item"
:class="{ 'active': sidebarPosition === 'left' }"
@click="setSidebarPosition('left')"
>
<span class="dropdown-icon">&#9664;</span>
Links
<span v-if="sidebarPosition === 'left'" class="check-icon">&#10003;</span>
</button>
<button
class="dropdown-item"
:class="{ 'active': sidebarPosition === 'right' }"
@click="setSidebarPosition('right')"
>
<span class="dropdown-icon">&#9654;</span>
Rechts
<span v-if="sidebarPosition === 'right'" class="check-icon">&#10003;</span>
</button>
</div>
</div>
<!-- User Dropdown -->
<div class="dropdown" ref="userDropdown">
<button
class="toolbar-btn user-btn dropdown-toggle"
@click="toggleUserMenu"
>
<span class="user-avatar">{{ userInitials }}</span>
<span class="user-name">{{ authStore.user?.username }}</span>
<span class="user-role badge" :class="authStore.isAdmin ? 'badge-admin' : 'badge-user'">
{{ authStore.user?.role }}
</span>
<span class="dropdown-arrow">&#9660;</span>
</button>
<div class="dropdown-menu" :class="{ 'show': showUserMenu }">
<div class="dropdown-header">
<div class="user-info-header">
<span class="user-avatar-large">{{ userInitials }}</span>
<div>
<div class="user-name-large">{{ authStore.user?.username }}</div>
<div class="user-email">{{ authStore.user?.email }}</div>
</div>
</div>
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="goToProfile">
<span class="dropdown-icon">&#128100;</span>
Profil
</button>
<button class="dropdown-item" @click="showPasswordModal = true; showUserMenu = false">
<span class="dropdown-icon">&#128274;</span>
Passwort ändern
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item-danger" @click="handleLogout">
<span class="dropdown-icon">&#128682;</span>
Abmelden
</button>
</div>
</div>
</div>
<!-- Password Change Modal -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>Passwort ändern</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<form @submit.prevent="changePassword">
<div class="form-group">
<label class="form-label">Aktuelles Passwort</label>
<input
v-model="passwordForm.currentPassword"
type="password"
class="form-input"
required
/>
</div>
<div class="form-group">
<label class="form-label">Neues Passwort</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="form-input"
required
minlength="6"
/>
</div>
<div class="form-group">
<label class="form-label">Passwort bestätigen</label>
<input
v-model="passwordForm.confirmPassword"
type="password"
class="form-input"
required
/>
</div>
<div v-if="passwordError" class="alert alert-error">{{ passwordError }}</div>
<div v-if="passwordSuccess" class="alert alert-success">{{ passwordSuccess }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showPasswordModal = false">
Abbrechen
</button>
<button type="submit" class="btn btn-primary">
Speichern
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { menuService } from '../services/menu'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const toolbarItems = ref([])
const props = defineProps({
sidebarPosition: {
type: String,
default: 'left'
},
theme: {
type: String,
default: 'light'
}
})
const emit = defineEmits(['action', 'update:sidebarPosition', 'update:theme'])
// Dropdown states
const showSettingsMenu = ref(false)
const showUserMenu = ref(false)
const settingsDropdown = ref(null)
const userDropdown = ref(null)
// Password modal
const showPasswordModal = ref(false)
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const passwordError = ref('')
const passwordSuccess = ref('')
const userInitials = computed(() => {
const username = authStore.user?.username || ''
return username.substring(0, 2).toUpperCase()
})
const icons = {
'scissors': '&#9986;',
'copy': '&#128203;',
'clipboard': '&#128203;',
'rotate-ccw': '&#8634;',
'rotate-cw': '&#8635;',
'save': '&#128190;',
'printer': '&#128424;',
'search': '&#128269;',
'help-circle': '&#10067;',
'default': '&#9679;'
}
const getIcon = (iconName) => {
return icons[iconName] || icons['default']
}
const toggleSettingsMenu = () => {
showSettingsMenu.value = !showSettingsMenu.value
showUserMenu.value = false
}
const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value
showSettingsMenu.value = false
}
const setTheme = async (newTheme) => {
emit('update:theme', newTheme)
showSettingsMenu.value = false
try {
await menuService.updateUserSettings({ theme: newTheme })
} catch (err) {
console.error('Failed to save theme:', err)
}
}
const setSidebarPosition = async (position) => {
emit('update:sidebarPosition', position)
showSettingsMenu.value = false
try {
await menuService.updateUserSettings({ sidebar_position: position })
} catch (err) {
console.error('Failed to save sidebar position:', err)
}
}
const goToProfile = () => {
showUserMenu.value = false
router.push('/profile')
}
const handleLogout = () => {
showUserMenu.value = false
authStore.logout()
router.push('/login')
}
const changePassword = async () => {
passwordError.value = ''
passwordSuccess.value = ''
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
passwordError.value = 'Die Passwörter stimmen nicht überein'
return
}
if (passwordForm.value.newPassword.length < 6) {
passwordError.value = 'Das Passwort muss mindestens 6 Zeichen lang sein'
return
}
try {
// TODO: Implement password change API
// await authService.changePassword(passwordForm.value)
passwordSuccess.value = 'Passwort erfolgreich geändert'
setTimeout(() => {
showPasswordModal.value = false
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
passwordSuccess.value = ''
}, 1500)
} catch (err) {
passwordError.value = err.response?.data?.error || 'Fehler beim Ändern des Passworts'
}
}
const handleAction = (action) => {
emit('action', action)
switch (action) {
case 'cut':
document.execCommand('cut')
break
case 'copy':
document.execCommand('copy')
break
case 'paste':
navigator.clipboard.readText().then(text => {
document.execCommand('insertText', false, text)
}).catch(() => {
console.log('Paste not available')
})
break
case 'undo':
document.execCommand('undo')
break
case 'redo':
document.execCommand('redo')
break
case 'save':
console.log('Save action triggered')
break
case 'print':
window.print()
break
case 'search':
break
case 'help':
window.open('/help', '_blank')
break
}
}
// Close dropdowns when clicking outside
const handleClickOutside = (event) => {
if (settingsDropdown.value && !settingsDropdown.value.contains(event.target)) {
showSettingsMenu.value = false
}
if (userDropdown.value && !userDropdown.value.contains(event.target)) {
showUserMenu.value = false
}
}
const loadToolbar = async () => {
try {
toolbarItems.value = await menuService.getToolbarItems()
} catch (err) {
console.error('Failed to load toolbar:', err)
toolbarItems.value = [
{ id: 1, title: 'Ausschneiden', icon: 'scissors', action: 'cut', shortcut: 'Ctrl+X', separator: false },
{ id: 2, title: 'Kopieren', icon: 'copy', action: 'copy', shortcut: 'Ctrl+C', separator: false },
{ id: 3, title: 'Einfügen', icon: 'clipboard', action: 'paste', shortcut: 'Ctrl+V', separator: true },
{ id: 4, title: 'Speichern', icon: 'save', action: 'save', shortcut: 'Ctrl+S', separator: false },
]
}
}
onMounted(() => {
loadToolbar()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
height: 44px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0;
padding: 0 0.5rem;
gap: 0.25rem;
}
.toolbar-dark {
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
border-bottom-color: #334155;
}
.toolbar-dark .toolbar-btn {
color: #94a3b8;
}
.toolbar-dark .toolbar-btn:hover {
background: #334155;
border-color: #475569;
color: #e2e8f0;
}
.toolbar-dark .toolbar-separator {
background: #475569;
}
.toolbar-dark .user-name {
color: #e2e8f0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.6rem;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: #475569;
font-size: 0.85rem;
transition: all 0.15s;
}
.toolbar-btn:hover {
background: #e2e8f0;
border-color: #cbd5e1;
}
.toolbar-btn:active {
background: #cbd5e1;
}
.toolbar-icon {
font-size: 1rem;
}
.toolbar-label {
display: none;
}
@media (min-width: 1024px) {
.toolbar-label {
display: inline;
}
}
.toolbar-separator {
width: 1px;
height: 24px;
background: #cbd5e1;
margin: 0 0.5rem;
}
.toolbar-spacer {
flex: 1;
}
.toolbar-right {
gap: 0.5rem;
}
/* Dropdown styles */
.dropdown {
position: relative;
}
.dropdown-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dropdown-arrow {
font-size: 0.6rem;
margin-left: 0.25rem;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
min-width: 200px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
z-index: 1000;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
}
.dropdown-menu.show {
opacity: 1;
visibility: visible;
transform: translateY(4px);
}
.toolbar-dark .dropdown-menu {
background: #1e293b;
border-color: #334155;
}
.dropdown-header {
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toolbar-dark .dropdown-header {
color: #94a3b8;
}
.dropdown-divider {
height: 1px;
background: #e2e8f0;
margin: 0.25rem 0;
}
.toolbar-dark .dropdown-divider {
background: #334155;
}
.dropdown-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.6rem 1rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: #334155;
font-size: 0.9rem;
transition: all 0.15s;
gap: 0.75rem;
}
.dropdown-item:hover {
background: #f1f5f9;
}
.dropdown-item.active {
background: #eff6ff;
color: #1d4ed8;
}
.toolbar-dark .dropdown-item {
color: #e2e8f0;
}
.toolbar-dark .dropdown-item:hover {
background: #334155;
}
.toolbar-dark .dropdown-item.active {
background: #1e3a5f;
color: #60a5fa;
}
.dropdown-item-danger {
color: #dc2626;
}
.dropdown-item-danger:hover {
background: #fef2f2;
}
.toolbar-dark .dropdown-item-danger {
color: #f87171;
}
.toolbar-dark .dropdown-item-danger:hover {
background: #450a0a;
}
.dropdown-icon {
font-size: 1rem;
width: 20px;
text-align: center;
}
.check-icon {
margin-left: auto;
color: #22c55e;
}
/* User button styles */
.user-btn {
padding: 0.35rem 0.75rem;
}
.user-avatar {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.user-avatar-large {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 600;
}
.user-info-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-name-large {
font-weight: 600;
color: #1e293b;
font-size: 0.95rem;
}
.toolbar-dark .user-name-large {
color: #e2e8f0;
}
.user-email {
font-size: 0.8rem;
color: #64748b;
}
.user-name {
font-weight: 500;
color: #334155;
font-size: 0.9rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-admin {
background: #dbeafe;
color: #1e40af;
}
.badge-user {
background: #dcfce7;
color: #166534;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: white;
border-radius: 12px;
width: 400px;
max-width: 90%;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
}
.toolbar-dark .modal-content {
background: #1e293b;
color: #e2e8f0;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.toolbar-dark .modal-header {
border-bottom-color: #334155;
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #64748b;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #334155;
}
.modal-content form {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.9rem;
color: #334155;
}
.toolbar-dark .form-label {
color: #e2e8f0;
}
.form-input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.toolbar-dark .form-input {
background: #0f172a;
border-color: #334155;
color: #e2e8f0;
}
.btn {
padding: 0.6rem 1.25rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #e2e8f0;
color: #475569;
}
.btn-secondary:hover {
background: #cbd5e1;
}
.alert {
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
margin-top: 0.75rem;
}
.alert-error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.alert-success {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
}
</style>

View File

@ -0,0 +1,23 @@
import api from './api'
export const menuService = {
async getMenuItems() {
const response = await api.get('/menu')
return response.data
},
async getToolbarItems() {
const response = await api.get('/toolbar')
return response.data
},
async getUserSettings() {
const response = await api.get('/settings')
return response.data
},
async updateUserSettings(settings) {
const response = await api.put('/settings', settings)
return response.data
}
}

View File

@ -1,7 +1,5 @@
<template>
<div>
<Navbar />
<div class="container">
<MainLayout>
<h1>Dashboard</h1>
<div class="stats-grid">
@ -56,15 +54,14 @@
</tbody>
</table>
</div>
</div>
</div>
</MainLayout>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { userService } from '../services/users'
import Navbar from '../components/Navbar.vue'
import MainLayout from '../components/MainLayout.vue'
const authStore = useAuthStore()
const userCount = ref(0)

View File

@ -1,7 +1,5 @@
<template>
<div>
<Navbar />
<div class="container">
<MainLayout>
<h1>Profil</h1>
<div class="card">
@ -45,13 +43,12 @@
</tbody>
</table>
</div>
</div>
</div>
</MainLayout>
</template>
<script setup>
import { useAuthStore } from '../stores/auth'
import Navbar from '../components/Navbar.vue'
import MainLayout from '../components/MainLayout.vue'
const authStore = useAuthStore()

View File

@ -1,7 +1,5 @@
<template>
<div>
<Navbar />
<div class="container">
<MainLayout>
<div class="flex-between mb-3">
<h1>Benutzerverwaltung</h1>
<button @click="showCreateModal = true" class="btn btn-success">
@ -100,14 +98,13 @@
</form>
</div>
</div>
</div>
</div>
</MainLayout>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { userService } from '../services/users'
import Navbar from '../components/Navbar.vue'
import MainLayout from '../components/MainLayout.vue'
const users = ref([])
const error = ref('')