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,40 +58,269 @@ 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
queries = []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
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 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
`
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,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
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,70 +1,67 @@
<template>
<div>
<Navbar />
<div class="container">
<h1>Dashboard</h1>
<MainLayout>
<h1>Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ userCount }}</div>
<div class="stat-label">Benutzer</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ activeUsers }}</div>
<div class="stat-label">Aktive Benutzer</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ authStore.user?.role }}</div>
<div class="stat-label">Ihre Rolle</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ userCount }}</div>
<div class="stat-label">Benutzer</div>
</div>
<div class="card">
<h2 class="card-title">Willkommen, {{ authStore.user?.username }}!</h2>
<p>Sie sind erfolgreich angemeldet.</p>
<p v-if="authStore.isAdmin" class="mt-2">
Als Administrator haben Sie Zugriff auf die Benutzerverwaltung.
</p>
<div class="stat-card">
<div class="stat-value">{{ activeUsers }}</div>
<div class="stat-label">Aktive Benutzer</div>
</div>
<div class="card">
<h2 class="card-title">Systeminfo</h2>
<table class="table">
<tbody>
<tr>
<td><strong>Benutzername:</strong></td>
<td>{{ authStore.user?.username }}</td>
</tr>
<tr>
<td><strong>E-Mail:</strong></td>
<td>{{ authStore.user?.email }}</td>
</tr>
<tr>
<td><strong>Rolle:</strong></td>
<td>
<span :class="authStore.isAdmin ? 'badge badge-info' : 'badge badge-success'">
{{ authStore.user?.role }}
</span>
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="badge badge-success">Aktiv</span>
</td>
</tr>
</tbody>
</table>
<div class="stat-card">
<div class="stat-value">{{ authStore.user?.role }}</div>
<div class="stat-label">Ihre Rolle</div>
</div>
</div>
</div>
<div class="card">
<h2 class="card-title">Willkommen, {{ authStore.user?.username }}!</h2>
<p>Sie sind erfolgreich angemeldet.</p>
<p v-if="authStore.isAdmin" class="mt-2">
Als Administrator haben Sie Zugriff auf die Benutzerverwaltung.
</p>
</div>
<div class="card">
<h2 class="card-title">Systeminfo</h2>
<table class="table">
<tbody>
<tr>
<td><strong>Benutzername:</strong></td>
<td>{{ authStore.user?.username }}</td>
</tr>
<tr>
<td><strong>E-Mail:</strong></td>
<td>{{ authStore.user?.email }}</td>
</tr>
<tr>
<td><strong>Rolle:</strong></td>
<td>
<span :class="authStore.isAdmin ? 'badge badge-info' : 'badge badge-success'">
{{ authStore.user?.role }}
</span>
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="badge badge-success">Aktiv</span>
</td>
</tr>
</tbody>
</table>
</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,57 +1,54 @@
<template>
<div>
<Navbar />
<div class="container">
<h1>Profil</h1>
<MainLayout>
<h1>Profil</h1>
<div class="card">
<h2 class="card-title">Benutzerinformationen</h2>
<table class="table">
<tbody>
<tr>
<td><strong>ID:</strong></td>
<td>{{ authStore.user?.id }}</td>
</tr>
<tr>
<td><strong>Benutzername:</strong></td>
<td>{{ authStore.user?.username }}</td>
</tr>
<tr>
<td><strong>E-Mail:</strong></td>
<td>{{ authStore.user?.email }}</td>
</tr>
<tr>
<td><strong>Rolle:</strong></td>
<td>
<span :class="authStore.isAdmin ? 'badge badge-info' : 'badge badge-success'">
{{ authStore.user?.role }}
</span>
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="badge badge-success">Aktiv</span>
</td>
</tr>
<tr>
<td><strong>Erstellt am:</strong></td>
<td>{{ formatDate(authStore.user?.created_at) }}</td>
</tr>
<tr>
<td><strong>Aktualisiert am:</strong></td>
<td>{{ formatDate(authStore.user?.updated_at) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 class="card-title">Benutzerinformationen</h2>
<table class="table">
<tbody>
<tr>
<td><strong>ID:</strong></td>
<td>{{ authStore.user?.id }}</td>
</tr>
<tr>
<td><strong>Benutzername:</strong></td>
<td>{{ authStore.user?.username }}</td>
</tr>
<tr>
<td><strong>E-Mail:</strong></td>
<td>{{ authStore.user?.email }}</td>
</tr>
<tr>
<td><strong>Rolle:</strong></td>
<td>
<span :class="authStore.isAdmin ? 'badge badge-info' : 'badge badge-success'">
{{ authStore.user?.role }}
</span>
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="badge badge-success">Aktiv</span>
</td>
</tr>
<tr>
<td><strong>Erstellt am:</strong></td>
<td>{{ formatDate(authStore.user?.created_at) }}</td>
</tr>
<tr>
<td><strong>Aktualisiert am:</strong></td>
<td>{{ formatDate(authStore.user?.updated_at) }}</td>
</tr>
</tbody>
</table>
</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('')