aggiunto e testato quasar apps

This commit is contained in:
fabio 2026-03-01 20:42:27 +01:00
parent cdcacadb5f
commit 66a3cc7cdb
73 changed files with 1559 additions and 389 deletions

View File

@ -1,4 +1,4 @@
.PHONY: tw-build tw-watch htmx-copy flowbite-copy assets server test db-reset fmt
.PHONY: tw-build tw-watch htmx-copy flags-copy assets server test db-reset fmt
tw-build:
npm run tw:build
@ -9,10 +9,10 @@ tw-watch:
htmx-copy:
mkdir -p web/static/vendor && cp node_modules/htmx.org/dist/htmx.min.js web/static/vendor/htmx.min.js
flowbite-copy:
mkdir -p web/static/vendor && cp node_modules/flowbite/dist/flowbite.min.js web/static/vendor/flowbite.js
flags-copy:
mkdir -p web/static/vendor/flags && cp assets/flags/*.svg web/static/vendor/flags/
assets: htmx-copy flowbite-copy tw-build
assets: htmx-copy flags-copy tw-build
server:
go run ./cmd/server

View File

@ -18,6 +18,14 @@ Terminale 2:
make server
```
Admin SPA (Quasar):
- il backend serve `quasar/admin_section/dist/spa` sotto `/admin` (protetto da auth + ruolo admin)
- build frontend admin: `cd quasar/admin_section && npm i && npm run build`
Private SPA (Quasar):
- il backend serve `quasar/private_section/dist/spa` sotto `/private` (protetto da auth)
- build frontend private: `cd quasar/private_section && npm i && npm run build`
`make assets` esegue:
- copia di `node_modules/flowbite/dist/flowbite.min.js` in `web/static/vendor/flowbite.js`
- build Tailwind in `web/static/css/app.css`
@ -65,8 +73,8 @@ DB_PG_DSN=postgres://trustcontact:trustcontact@localhost:5432/trustcontact?sslmo
## Template Directories
- Public: `web/templates/public`
- Private: `web/templates/private`
- Admin: `web/templates/admin`
- Private: `quasar/private_section/dist/spa` (SPA servita da Go sotto `/private`)
- Admin: `quasar/admin_section/dist/spa` (SPA servita da Go sotto `/admin`)
## Email in Develop

5
assets/flags/ch.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Swiss flag">
<rect width="32" height="32" fill="#d52b1e"/>
<rect x="13" y="6" width="6" height="20" fill="#ffffff"/>
<rect x="6" y="13" width="20" height="6" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

5
assets/flags/de.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="German flag">
<rect width="48" height="10.67" x="0" y="0" fill="#000000"/>
<rect width="48" height="10.67" x="0" y="10.67" fill="#dd0000"/>
<rect width="48" height="10.66" x="0" y="21.34" fill="#ffce00"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

9
assets/flags/en.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="English flag">
<rect width="48" height="32" fill="#012169"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#ffffff" stroke-width="6"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#c8102e" stroke-width="3"/>
<rect x="20" y="0" width="8" height="32" fill="#ffffff"/>
<rect x="0" y="12" width="48" height="8" fill="#ffffff"/>
<rect x="22" y="0" width="4" height="32" fill="#c8102e"/>
<rect x="0" y="14" width="48" height="4" fill="#c8102e"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

53
assets/flags/en_us.svg Normal file
View File

@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="United States flag">
<rect width="48" height="32" fill="#b22234"/>
<g fill="#ffffff">
<rect y="2.46" width="48" height="2.46"/>
<rect y="7.38" width="48" height="2.46"/>
<rect y="12.30" width="48" height="2.46"/>
<rect y="17.22" width="48" height="2.46"/>
<rect y="22.14" width="48" height="2.46"/>
<rect y="27.06" width="48" height="2.46"/>
</g>
<rect width="20" height="17.23" fill="#3c3b6e"/>
<g fill="#ffffff">
<circle cx="2.2" cy="2.2" r="0.7"/>
<circle cx="5.4" cy="2.2" r="0.7"/>
<circle cx="8.6" cy="2.2" r="0.7"/>
<circle cx="11.8" cy="2.2" r="0.7"/>
<circle cx="15.0" cy="2.2" r="0.7"/>
<circle cx="18.2" cy="2.2" r="0.7"/>
<circle cx="3.8" cy="4.4" r="0.7"/>
<circle cx="7.0" cy="4.4" r="0.7"/>
<circle cx="10.2" cy="4.4" r="0.7"/>
<circle cx="13.4" cy="4.4" r="0.7"/>
<circle cx="16.6" cy="4.4" r="0.7"/>
<circle cx="2.2" cy="6.6" r="0.7"/>
<circle cx="5.4" cy="6.6" r="0.7"/>
<circle cx="8.6" cy="6.6" r="0.7"/>
<circle cx="11.8" cy="6.6" r="0.7"/>
<circle cx="15.0" cy="6.6" r="0.7"/>
<circle cx="18.2" cy="6.6" r="0.7"/>
<circle cx="3.8" cy="8.8" r="0.7"/>
<circle cx="7.0" cy="8.8" r="0.7"/>
<circle cx="10.2" cy="8.8" r="0.7"/>
<circle cx="13.4" cy="8.8" r="0.7"/>
<circle cx="16.6" cy="8.8" r="0.7"/>
<circle cx="2.2" cy="11" r="0.7"/>
<circle cx="5.4" cy="11" r="0.7"/>
<circle cx="8.6" cy="11" r="0.7"/>
<circle cx="11.8" cy="11" r="0.7"/>
<circle cx="15.0" cy="11" r="0.7"/>
<circle cx="18.2" cy="11" r="0.7"/>
<circle cx="3.8" cy="13.2" r="0.7"/>
<circle cx="7.0" cy="13.2" r="0.7"/>
<circle cx="10.2" cy="13.2" r="0.7"/>
<circle cx="13.4" cy="13.2" r="0.7"/>
<circle cx="16.6" cy="13.2" r="0.7"/>
<circle cx="2.2" cy="15.4" r="0.7"/>
<circle cx="5.4" cy="15.4" r="0.7"/>
<circle cx="8.6" cy="15.4" r="0.7"/>
<circle cx="11.8" cy="15.4" r="0.7"/>
<circle cx="15.0" cy="15.4" r="0.7"/>
<circle cx="18.2" cy="15.4" r="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

5
assets/flags/fr.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="French flag">
<rect width="16" height="32" x="0" y="0" fill="#0055a4"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ef4135"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

5
assets/flags/it.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="Italian flag">
<rect width="16" height="32" x="0" y="0" fill="#009246"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ce2b37"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -1,4 +1,5 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@layer utilities {
.flag-lang {

BIN
cmd/server/__debug_bin2658940089 Executable file

Binary file not shown.

View File

@ -1,49 +1,27 @@
package controllers
import (
"bytes"
"html/template"
"path/filepath"
"github.com/gofiber/fiber/v2"
)
type AdminController struct{}
type AdminController struct {
spaDir string
}
func NewAdminController() *AdminController {
return &AdminController{}
func NewAdminController(spaDir string) *AdminController {
return &AdminController{spaDir: spaDir}
}
func (ac *AdminController) Dashboard(c *fiber.Ctx) error {
return renderAdminPage(c, "Admin")
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func renderAdminPage(c *fiber.Ctx, title string) error {
viewData := map[string]any{
"Title": title,
"NavSection": "admin",
}
for k, v := range localsTemplateData(c) {
viewData[k] = v
}
files := []string{
"web/templates/layout.html",
"web/templates/public/_navbar.html",
"web/templates/partials/language_dropdown.html",
"web/templates/public/_flash.html",
"web/templates/admin/admin.html",
}
tmpl, err := template.ParseFiles(files...)
if err != nil {
return err
}
var out bytes.Buffer
if err := tmpl.ExecuteTemplate(&out, "layout.html", viewData); err != nil {
return err
}
c.Type("html", "utf-8")
return c.Send(out.Bytes())
func (ac *AdminController) Fallback(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *AdminController) Favicon(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "favicon.ico"))
}

View File

@ -25,13 +25,6 @@ func (ac *AuthController) ShowHome(c *fiber.Ctx) error {
})
}
func (ac *AuthController) ShowWelcome(c *fiber.Ctx) error {
return renderPrivate(c, "welcome.html", map[string]any{
"Title": "Welcome",
"NavSection": "private",
})
}
func (ac *AuthController) ShowSignup(c *fiber.Ctx) error {
return renderPublic(c, "signup.html", map[string]any{
"Title": "Sign up",
@ -97,7 +90,7 @@ func (ac *AuthController) Login(c *fiber.Ctx) error {
if err := httpmw.SetFlashSuccess(c, "Login effettuato"); err != nil {
return err
}
return c.Redirect("/welcome")
return c.Redirect("/private")
}
func (ac *AuthController) Logout(c *fiber.Ctx) error {

View File

@ -0,0 +1,27 @@
package controllers
import (
"path/filepath"
"github.com/gofiber/fiber/v2"
)
type PrivateController struct {
spaDir string
}
func NewPrivateController(spaDir string) *PrivateController {
return &PrivateController{spaDir: spaDir}
}
func (ac *PrivateController) Dashboard(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *PrivateController) Fallback(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "index.html"))
}
func (ac *PrivateController) Favicon(c *fiber.Ctx) error {
return c.SendFile(filepath.Join(ac.spaDir, "favicon.ico"))
}

View File

@ -1,27 +0,0 @@
package controllers
import (
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
)
type UsersController struct {
usersService *services.UsersService
}
func NewUsersController(usersService *services.UsersService) *UsersController {
return &UsersController{usersService: usersService}
}
func (uc *UsersController) Index(c *fiber.Ctx) error {
return renderAdminPage(c, "Admin")
}
func (uc *UsersController) Table(c *fiber.Ctx) error {
return renderAdminPage(c, "Admin")
}
func (uc *UsersController) Modal(c *fiber.Ctx) error {
return renderAdminPage(c, "Admin")
}

View File

@ -2,6 +2,7 @@ package http
import (
"fmt"
"path/filepath"
"trustcontact/internal/config"
"trustcontact/internal/controllers"
httpmw "trustcontact/internal/http/middleware"
@ -28,9 +29,10 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
return fmt.Errorf("init auth service: %w", err)
}
authController := controllers.NewAuthController(authService)
usersService := services.NewUsersService(database)
usersController := controllers.NewUsersController(usersService)
adminController := controllers.NewAdminController()
privateSPADir := filepath.FromSlash("quasar/private_section/dist/spa")
privateController := controllers.NewPrivateController(privateSPADir)
adminSPADir := filepath.FromSlash("quasar/admin_section/dist/spa")
adminController := controllers.NewAdminController(adminSPADir)
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
@ -49,20 +51,32 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
app.Get("/reset-password", authController.ShowResetPassword)
app.Post("/reset-password", authController.ResetPassword)
app.Get("/forbidden", authController.ShowForbidden)
app.Get("/welcome", httpmw.RequireAuth(), authController.ShowWelcome)
app.Post("/preferences/lang", httpmw.RequireAuth(), authController.UpdateLanguage)
app.Post("/preferences/theme", httpmw.RequireAuth(), authController.UpdateTheme)
// Quasar admin SPA assets are emitted with absolute paths (/assets, /icons, /favicon.ico).
// Protect them with the same auth/admin middleware used by /admin.
app.Use("/assets", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Use("/icons", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Get("/favicon.ico", httpmw.RequireAuth(), httpmw.RequireAdmin(), privateController.Favicon)
app.Static("/assets", filepath.Join(privateSPADir, "assets"))
app.Static("/icons", filepath.Join(privateSPADir, "icons"))
private := app.Group("/private", httpmw.RequireAuth(), httpmw.RequireAdmin())
private.Get("/", func(c *fiber.Ctx) error {
return c.Redirect("/admin/users")
})
private.Get("/", privateController.Dashboard)
private.Get("/*", privateController.Fallback)
// Quasar admin SPA assets are emitted with absolute paths (/assets, /icons, /favicon.ico).
// Protect them with the same auth/admin middleware used by /admin.
app.Use("/assets", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Use("/icons", httpmw.RequireAuth(), httpmw.RequireAdmin())
app.Get("/favicon.ico", httpmw.RequireAuth(), httpmw.RequireAdmin(), adminController.Favicon)
app.Static("/assets", filepath.Join(adminSPADir, "assets"))
app.Static("/icons", filepath.Join(adminSPADir, "icons"))
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
admin.Get("/", adminController.Dashboard)
admin.Get("/users", usersController.Index)
admin.Get("/users/table", usersController.Table)
admin.Get("/users/:id/modal", usersController.Modal)
admin.Get("/*", adminController.Fallback)
return nil
}

1063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
"tailwindcss": "^4.1.13"
},
"dependencies": {
"flowbite": "^3.1.2",
"htmx.org": "^2.0.6"
}
}

View File

@ -3,6 +3,10 @@
import { defineConfig } from '#q-app/wrappers';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import dotenv from 'dotenv';
dotenv.config({ path: resolve(__dirname, '../../.env') });
export default defineConfig((ctx) => {
return {
@ -33,6 +37,9 @@ export default defineConfig((ctx) => {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
env: {
SITE_URL: process.env.SITE_URL || '',
},
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',

View File

@ -1,37 +0,0 @@
<template>
<div>
<p>{{ title }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="increment">
{{ todo.id }} - {{ todo.content }}
</li>
</ul>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Todo, Meta } from './models';
interface Props {
title: string;
todos?: Todo[];
meta: Meta;
active: boolean;
}
const props = withDefaults(defineProps<Props>(), {
todos: () => [],
});
const clickCount = ref(0);
function increment() {
clickCount.value += 1;
return clickCount.value;
}
const todoCount = computed(() => props.todos.length);
</script>

View File

@ -1,8 +0,0 @@
export interface Todo {
id: number;
content: string;
}
export interface Meta {
totalCount: number;
}

View File

@ -1,6 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
SITE_URL: string | undefined;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}

View File

@ -13,8 +13,13 @@
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<q-list>
<q-item-label header> List Items </q-item-label>
<q-item
clickable
tag="a"
:href="privateLink"
>
<q-item-section>Private</q-item-section>
</q-item>
</q-list>
</q-drawer>
@ -28,6 +33,8 @@
import { ref } from 'vue';
const leftDrawerOpen = ref(false);
const siteUrl = (process.env.SITE_URL || '').replace(/\/+$/, '');
const privateLink = `${siteUrl}/private`;
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;

View File

@ -15,29 +15,29 @@
"postinstall": "quasar prepare"
},
"dependencies": {
"vue-i18n": "^11.0.0",
"pinia": "^3.0.1",
"@quasar/extras": "^1.16.4",
"quasar": "^2.16.0",
"vue": "^3.5.22",
"vue-router": "^5.0.0"
"@quasar/extras": "^1.17.0",
"pinia": "^3.0.4",
"quasar": "^2.18.6",
"vue": "^3.5.29",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.4.0",
"vue-tsc": "^3.0.7",
"@vue/eslint-config-typescript": "^14.4.0",
"vite-plugin-checker": "^0.11.0",
"vue-eslint-parser": "^10.2.0",
"@vue/eslint-config-prettier": "^10.1.0",
"prettier": "^3.3.3",
"@types/node": "^20.5.9",
"@eslint/js": "^9.39.3",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@quasar/app-vite": "^2.1.0",
"autoprefixer": "^10.4.2",
"typescript": "^5.9.2"
"@quasar/app-vite": "^2.4.1",
"@types/node": "^20.19.35",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-plugin-vue": "^10.8.0",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"vite-plugin-checker": "^0.11.0",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20",

View File

@ -9,68 +9,68 @@ importers:
.:
dependencies:
'@quasar/extras':
specifier: ^1.16.4
specifier: ^1.17.0
version: 1.17.0
pinia:
specifier: ^3.0.1
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3))
quasar:
specifier: ^2.16.0
specifier: ^2.18.6
version: 2.18.6
vue:
specifier: ^3.5.22
specifier: ^3.5.29
version: 3.5.29(typescript@5.9.3)
vue-i18n:
specifier: ^11.0.0
specifier: ^11.2.8
version: 11.2.8(vue@3.5.29(typescript@5.9.3))
vue-router:
specifier: ^5.0.0
specifier: ^5.0.3
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
devDependencies:
'@eslint/js':
specifier: ^9.14.0
specifier: ^9.39.3
version: 9.39.3
'@intlify/unplugin-vue-i18n':
specifier: ^4.0.0
version: 4.0.0(rollup@4.59.0)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))
'@quasar/app-vite':
specifier: ^2.1.0
specifier: ^2.4.1
version: 2.4.1(@types/node@20.19.35)(eslint@9.39.3)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(quasar@2.18.6)(rollup@4.59.0)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))(yaml@2.8.2)
'@types/node':
specifier: ^20.5.9
specifier: ^20.19.35
version: 20.19.35
'@vue/eslint-config-prettier':
specifier: ^10.1.0
specifier: ^10.2.0
version: 10.2.0(eslint@9.39.3)(prettier@3.8.1)
'@vue/eslint-config-typescript':
specifier: ^14.4.0
specifier: ^14.7.0
version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(vue-eslint-parser@10.4.0(eslint@9.39.3)))(eslint@9.39.3)(typescript@5.9.3)
autoprefixer:
specifier: ^10.4.2
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.6)
eslint:
specifier: ^9.14.0
specifier: ^9.39.3
version: 9.39.3
eslint-plugin-vue:
specifier: ^10.4.0
specifier: ^10.8.0
version: 10.8.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(vue-eslint-parser@10.4.0(eslint@9.39.3))
globals:
specifier: ^16.4.0
specifier: ^16.5.0
version: 16.5.0
prettier:
specifier: ^3.3.3
specifier: ^3.8.1
version: 3.8.1
typescript:
specifier: ^5.9.2
specifier: ^5.9.3
version: 5.9.3
vite-plugin-checker:
specifier: ^0.11.0
version: 0.11.0(eslint@9.39.3)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.35)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@5.9.3))
vue-eslint-parser:
specifier: ^10.2.0
specifier: ^10.4.0
version: 10.4.0(eslint@9.39.3)
vue-tsc:
specifier: ^3.0.7
specifier: ^3.2.5
version: 3.2.5(typescript@5.9.3)
packages:

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 859 B

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -3,6 +3,10 @@
import { defineConfig } from '#q-app/wrappers';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import dotenv from 'dotenv';
dotenv.config({ path: resolve(__dirname, '../../.env') });
export default defineConfig((ctx) => {
return {
@ -33,6 +37,9 @@ export default defineConfig((ctx) => {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
env: {
SITE_URL: process.env.SITE_URL || '',
},
target: {
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20',

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1,6 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
SITE_URL: string | undefined;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}

View File

@ -0,0 +1,50 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title> Quasar App </q-toolbar-title>
<div>Quasar v{{ $q.version }}</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<q-list>
<q-item-label header> items list </q-item-label>
<q-item
clickable
tag="a"
:href="privateLink"
>
<q-item-section>Private</q-item-section>
</q-item>
<q-item
clickable
tag="a"
:href="adminLink"
>
<q-item-section>Admin</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const leftDrawerOpen = ref(false);
const siteUrl = (process.env.SITE_URL || '').replace(/\/+$/, '');
const privateLink = `${siteUrl}/private`;
const adminLink = `${siteUrl}/admin`;
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

View File

@ -0,0 +1,9 @@
<template>
<q-page class="row items-center justify-evenly">
private page
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@ -0,0 +1,7 @@
{
"extends": "./.quasar/tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
},
"exclude": ["node_modules", "dist"]
}

View File

@ -1,27 +0,0 @@
<template>
<q-item clickable tag="a" target="_blank" :href="link">
<q-item-section v-if="icon" avatar>
<q-icon :name="icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ title }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
export interface EssentialLinkProps {
title: string;
caption?: string;
link?: string;
icon?: string;
}
withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',
link: '#',
icon: '',
});
</script>

View File

@ -1,37 +0,0 @@
<template>
<div>
<p>{{ title }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="increment">
{{ todo.id }} - {{ todo.content }}
</li>
</ul>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Todo, Meta } from './models';
interface Props {
title: string;
todos?: Todo[];
meta: Meta;
active: boolean;
}
const props = withDefaults(defineProps<Props>(), {
todos: () => [],
});
const clickCount = ref(0);
function increment() {
clickCount.value += 1;
return clickCount.value;
}
const todoCount = computed(() => props.todos.length);
</script>

View File

@ -1,8 +0,0 @@
export interface Todo {
id: number;
content: string;
}
export interface Meta {
totalCount: number;
}

View File

@ -1,81 +0,0 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<q-toolbar-title> Quasar App </q-toolbar-title>
<div>Quasar v{{ $q.version }}</div>
</q-toolbar>
</q-header>
<q-drawer v-model="leftDrawerOpen" show-if-above bordered>
<q-list>
<q-item-label header> Essential Links </q-item-label>
<EssentialLink v-for="link in linksList" :key="link.title" v-bind="link" />
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
const linksList: EssentialLinkProps[] = [
{
title: 'Docs',
caption: 'quasar.dev',
icon: 'school',
link: 'https://quasar.dev',
},
{
title: 'Github',
caption: 'github.com/quasarframework',
icon: 'code',
link: 'https://github.com/quasarframework',
},
{
title: 'Discord Chat Channel',
caption: 'chat.quasar.dev',
icon: 'chat',
link: 'https://chat.quasar.dev',
},
{
title: 'Forum',
caption: 'forum.quasar.dev',
icon: 'record_voice_over',
link: 'https://forum.quasar.dev',
},
{
title: 'Twitter',
caption: '@quasarframework',
icon: 'rss_feed',
link: 'https://twitter.quasar.dev',
},
{
title: 'Facebook',
caption: '@QuasarFramework',
icon: 'public',
link: 'https://facebook.quasar.dev',
},
{
title: 'Quasar Awesome',
caption: 'Community Quasar projects',
icon: 'favorite',
link: 'https://awesome.quasar.dev',
},
];
const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>

View File

@ -1,43 +0,0 @@
<template>
<q-page class="row items-center justify-evenly">
<example-component
title="Example component"
active
:todos="todos"
:meta="meta"
></example-component>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { Todo, Meta } from 'components/models';
import ExampleComponent from 'components/ExampleComponent.vue';
const todos = ref<Todo[]>([
{
id: 1,
content: 'ct1',
},
{
id: 2,
content: 'ct2',
},
{
id: 3,
content: 'ct3',
},
{
id: 4,
content: 'ct4',
},
{
id: 5,
content: 'ct5',
},
]);
const meta = ref<Meta>({
totalCount: 1200,
});
</script>

View File

@ -1,3 +0,0 @@
{
"extends": "./.quasar/tsconfig.json"
}

View File

@ -2,9 +2,8 @@ module.exports = {
darkMode: "class",
content: [
"./web/templates/**/*.{html,gohtml}",
"./web/static/**/*.js",
"./node_modules/flowbite/**/*.js"
"./web/static/**/*.js"
],
theme: { extend: {} },
plugins: [require("flowbite/plugin")]
plugins: []
};

2
web/static/css/app.css Normal file

File diff suppressed because one or more lines are too long

5
web/static/vendor/flags/ch.svg vendored Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Swiss flag">
<rect width="32" height="32" fill="#d52b1e"/>
<rect x="13" y="6" width="6" height="20" fill="#ffffff"/>
<rect x="6" y="13" width="20" height="6" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

5
web/static/vendor/flags/de.svg vendored Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="German flag">
<rect width="48" height="10.67" x="0" y="0" fill="#000000"/>
<rect width="48" height="10.67" x="0" y="10.67" fill="#dd0000"/>
<rect width="48" height="10.66" x="0" y="21.34" fill="#ffce00"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

9
web/static/vendor/flags/en.svg vendored Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="English flag">
<rect width="48" height="32" fill="#012169"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#ffffff" stroke-width="6"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#c8102e" stroke-width="3"/>
<rect x="20" y="0" width="8" height="32" fill="#ffffff"/>
<rect x="0" y="12" width="48" height="8" fill="#ffffff"/>
<rect x="22" y="0" width="4" height="32" fill="#c8102e"/>
<rect x="0" y="14" width="48" height="4" fill="#c8102e"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

53
web/static/vendor/flags/en_us.svg vendored Normal file
View File

@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="United States flag">
<rect width="48" height="32" fill="#b22234"/>
<g fill="#ffffff">
<rect y="2.46" width="48" height="2.46"/>
<rect y="7.38" width="48" height="2.46"/>
<rect y="12.30" width="48" height="2.46"/>
<rect y="17.22" width="48" height="2.46"/>
<rect y="22.14" width="48" height="2.46"/>
<rect y="27.06" width="48" height="2.46"/>
</g>
<rect width="20" height="17.23" fill="#3c3b6e"/>
<g fill="#ffffff">
<circle cx="2.2" cy="2.2" r="0.7"/>
<circle cx="5.4" cy="2.2" r="0.7"/>
<circle cx="8.6" cy="2.2" r="0.7"/>
<circle cx="11.8" cy="2.2" r="0.7"/>
<circle cx="15.0" cy="2.2" r="0.7"/>
<circle cx="18.2" cy="2.2" r="0.7"/>
<circle cx="3.8" cy="4.4" r="0.7"/>
<circle cx="7.0" cy="4.4" r="0.7"/>
<circle cx="10.2" cy="4.4" r="0.7"/>
<circle cx="13.4" cy="4.4" r="0.7"/>
<circle cx="16.6" cy="4.4" r="0.7"/>
<circle cx="2.2" cy="6.6" r="0.7"/>
<circle cx="5.4" cy="6.6" r="0.7"/>
<circle cx="8.6" cy="6.6" r="0.7"/>
<circle cx="11.8" cy="6.6" r="0.7"/>
<circle cx="15.0" cy="6.6" r="0.7"/>
<circle cx="18.2" cy="6.6" r="0.7"/>
<circle cx="3.8" cy="8.8" r="0.7"/>
<circle cx="7.0" cy="8.8" r="0.7"/>
<circle cx="10.2" cy="8.8" r="0.7"/>
<circle cx="13.4" cy="8.8" r="0.7"/>
<circle cx="16.6" cy="8.8" r="0.7"/>
<circle cx="2.2" cy="11" r="0.7"/>
<circle cx="5.4" cy="11" r="0.7"/>
<circle cx="8.6" cy="11" r="0.7"/>
<circle cx="11.8" cy="11" r="0.7"/>
<circle cx="15.0" cy="11" r="0.7"/>
<circle cx="18.2" cy="11" r="0.7"/>
<circle cx="3.8" cy="13.2" r="0.7"/>
<circle cx="7.0" cy="13.2" r="0.7"/>
<circle cx="10.2" cy="13.2" r="0.7"/>
<circle cx="13.4" cy="13.2" r="0.7"/>
<circle cx="16.6" cy="13.2" r="0.7"/>
<circle cx="2.2" cy="15.4" r="0.7"/>
<circle cx="5.4" cy="15.4" r="0.7"/>
<circle cx="8.6" cy="15.4" r="0.7"/>
<circle cx="11.8" cy="15.4" r="0.7"/>
<circle cx="15.0" cy="15.4" r="0.7"/>
<circle cx="18.2" cy="15.4" r="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

5
web/static/vendor/flags/fr.svg vendored Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="French flag">
<rect width="16" height="32" x="0" y="0" fill="#0055a4"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ef4135"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

5
web/static/vendor/flags/it.svg vendored Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="Italian flag">
<rect width="16" height="32" x="0" y="0" fill="#009246"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ce2b37"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

1
web/static/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

62
web/static/vendor/theme.js vendored Normal file
View File

@ -0,0 +1,62 @@
(function () {
var STORAGE_KEY = 'theme';
var isAuthenticated = !!window.__TC_IS_AUTHENTICATED;
var serverTheme = (window.__TC_SERVER_THEME || '').toLowerCase();
var hasStoredTheme = false;
function getPreferredTheme() {
var stored = localStorage.getItem(STORAGE_KEY);
hasStoredTheme = stored === 'dark' || stored === 'light';
if (hasStoredTheme) return stored;
if (serverTheme === 'dark' || serverTheme === 'light') return serverTheme;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
var isDark = theme === 'dark';
document.documentElement.classList.toggle('dark', isDark);
var button = document.getElementById('themeToggle');
if (button) {
button.setAttribute('aria-pressed', isDark ? 'true' : 'false');
button.textContent = isDark ? 'Light mode' : 'Dark mode';
}
}
function persistTheme(theme) {
localStorage.setItem(STORAGE_KEY, theme);
hasStoredTheme = true;
}
function sendThemeToServer(theme) {
if (!isAuthenticated) return;
fetch('/preferences/theme', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'theme=' + encodeURIComponent(theme),
}).catch(function () {});
}
window.toggleTheme = function toggleTheme() {
var currentIsDark = document.documentElement.classList.contains('dark');
var nextTheme = currentIsDark ? 'light' : 'dark';
applyTheme(nextTheme);
persistTheme(nextTheme);
sendThemeToServer(nextTheme);
};
window.initThemeToggle = function initThemeToggle() {
applyTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
};
var initialTheme = getPreferredTheme();
applyTheme(initialTheme);
var mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', function (event) {
if (hasStoredTheme) return;
applyTheme(event.matches ? 'dark' : 'light');
});
}
})();

View File

@ -11,7 +11,7 @@
<script src="/static/vendor/theme.js?v={{.BuildHash}}"></script>
<link rel="stylesheet" href="/static/css/app.css?v={{.BuildHash}}">
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/flowbite.js"></script>
</head>
<body class="flex min-h-screen flex-col bg-white text-gray-900 antialiased dark:bg-gray-900 dark:text-gray-100">
{{template "navbar" .}}
@ -45,7 +45,7 @@
'forgot.title': 'Password dimenticata', 'forgot.subtitle': 'Inserisci la tua email per ricevere il link di reset.', 'forgot.submit': 'Invia link reset', 'forgot.back_login': 'Torna al login',
'reset.title': 'Reset password', 'reset.subtitle': 'Imposta una nuova password.', 'reset.new_password': 'Nuova password', 'reset.submit': 'Aggiorna password', 'reset.invalid_token': 'Token mancante o non valido.',
'verify.title': 'Verifica email', 'verify.p1': 'Controlla la casella di posta e apri il link di verifica ricevuto.', 'verify.p2': 'Se il link è scaduto, ripeti la registrazione o contatta supporto.', 'verify.go_login': 'Vai al login',
'welcome.title': 'Dashboard', 'welcome.back_prefix': 'Bentornato', 'welcome.generic': 'Benvenuto.', 'welcome.quick_links': 'Link rapidi',
'private.title': 'Dashboard', 'private.back_prefix': 'Bentornato', 'private.generic': 'Benvenuto.', 'private.quick_links': 'Link rapidi',
'admin.dashboard.title': 'Admin Dashboard', 'admin.dashboard.area': 'Area amministrazione.', 'admin.users_count': 'Utenti', 'admin.current_role': 'Ruolo corrente', 'admin.navigation': 'Navigazione', 'admin.manage_users': 'Gestione utenti',
'users.title': 'Users', 'users.subtitle': 'Ricerca, ordinamento e paging server-side via HTMX.', 'users.new_user': 'Nuovo Utente', 'users.search': 'Search', 'users.search_placeholder': 'Cerca nome o email', 'users.page_size': 'Page size', 'users.search_button': 'Cerca', 'users.user_detail': 'Dettaglio utente', 'users.actions': 'Azioni', 'users.open': 'Apri', 'users.none': 'Nessun utente trovato.', 'users.total': 'Totale', 'users.users_label': 'utenti', 'users.page': 'Pagina', 'users.prev': 'Prev', 'users.next': 'Next', 'users.close': 'Chiudi',
'users.new_user_modal_title': 'Nuovo utente', 'users.new_user_modal_placeholder': 'Placeholder UI Flowbite. La creazione utente può essere collegata a una route backend quando disponibile.',
@ -60,7 +60,7 @@
'forgot.title': 'Forgot Password', 'forgot.subtitle': 'Enter your email to receive a reset link.', 'forgot.submit': 'Send reset link', 'forgot.back_login': 'Back to login',
'reset.title': 'Reset Password', 'reset.subtitle': 'Set a new password.', 'reset.new_password': 'New password', 'reset.submit': 'Update password', 'reset.invalid_token': 'Missing or invalid token.',
'verify.title': 'Verify email', 'verify.p1': 'Check your inbox and open the verification link.', 'verify.p2': 'If the link expired, sign up again or contact support.', 'verify.go_login': 'Go to login',
'welcome.title': 'Dashboard', 'welcome.back_prefix': 'Welcome back', 'welcome.generic': 'Welcome.', 'welcome.quick_links': 'Quick links',
'private.title': 'Dashboard', 'private.back_prefix': 'Signed in as', 'private.generic': 'Signed in.', 'private.quick_links': 'Quick links',
'admin.dashboard.title': 'Admin Dashboard', 'admin.dashboard.area': 'Administration area.', 'admin.users_count': 'Users', 'admin.current_role': 'Current role', 'admin.navigation': 'Navigation', 'admin.manage_users': 'Manage users',
'users.title': 'Users', 'users.subtitle': 'Search, sorting and server-side paging via HTMX.', 'users.new_user': 'New user', 'users.search': 'Search', 'users.search_placeholder': 'Search by name or email', 'users.page_size': 'Page size', 'users.search_button': 'Search', 'users.user_detail': 'User details', 'users.actions': 'Actions', 'users.open': 'Open', 'users.none': 'No users found.', 'users.total': 'Total', 'users.users_label': 'users', 'users.page': 'Page', 'users.prev': 'Prev', 'users.next': 'Next', 'users.close': 'Close',
'users.new_user_modal_title': 'New user', 'users.new_user_modal_placeholder': 'Flowbite placeholder UI. Connect creation to backend route when available.',
@ -75,7 +75,7 @@
'forgot.title': 'Passwort vergessen', 'forgot.subtitle': 'Geben Sie Ihre E-Mail ein, um einen Reset-Link zu erhalten.', 'forgot.submit': 'Reset-Link senden', 'forgot.back_login': 'Zurück zum Login',
'reset.title': 'Passwort zurücksetzen', 'reset.subtitle': 'Legen Sie ein neues Passwort fest.', 'reset.new_password': 'Neues Passwort', 'reset.submit': 'Passwort aktualisieren', 'reset.invalid_token': 'Token fehlt oder ist ungültig.',
'verify.title': 'E-Mail verifizieren', 'verify.p1': 'Öffnen Sie die Verifizierungs-E-Mail in Ihrem Posteingang.', 'verify.p2': 'Wenn der Link abgelaufen ist, registrieren Sie sich erneut oder kontaktieren Sie den Support.', 'verify.go_login': 'Zum Login',
'welcome.title': 'Dashboard', 'welcome.back_prefix': 'Willkommen zurück', 'welcome.generic': 'Willkommen.', 'welcome.quick_links': 'Schnelllinks',
'private.title': 'Dashboard', 'private.back_prefix': 'Willkommen zurück', 'private.generic': 'Willkommen.', 'private.quick_links': 'Schnelllinks',
'admin.dashboard.title': 'Admin-Dashboard', 'admin.dashboard.area': 'Administrationsbereich.', 'admin.users_count': 'Benutzer', 'admin.current_role': 'Aktuelle Rolle', 'admin.navigation': 'Navigation', 'admin.manage_users': 'Benutzer verwalten',
'users.title': 'Benutzer', 'users.subtitle': 'Suche, Sortierung und serverseitiges Paging via HTMX.', 'users.new_user': 'Neuer Benutzer', 'users.search': 'Suche', 'users.search_placeholder': 'Nach Name oder E-Mail suchen', 'users.page_size': 'Seitengröße', 'users.search_button': 'Suchen', 'users.user_detail': 'Benutzerdetails', 'users.actions': 'Aktionen', 'users.open': 'Öffnen', 'users.none': 'Keine Benutzer gefunden.', 'users.total': 'Gesamt', 'users.users_label': 'Benutzer', 'users.page': 'Seite', 'users.prev': 'Zurück', 'users.next': 'Weiter', 'users.close': 'Schließen',
'users.new_user_modal_title': 'Neuer Benutzer', 'users.new_user_modal_placeholder': 'Flowbite-Placeholder-UI. Bei Bedarf mit Backend-Route verbinden.',
@ -89,7 +89,7 @@
'forgot.title': 'Mot de passe oublié', 'forgot.subtitle': 'Entrez votre email pour recevoir un lien de réinitialisation.', 'forgot.submit': 'Envoyer le lien', 'forgot.back_login': 'Retour à la connexion',
'reset.title': 'Réinitialiser le mot de passe', 'reset.subtitle': 'Définissez un nouveau mot de passe.', 'reset.new_password': 'Nouveau mot de passe', 'reset.submit': 'Mettre à jour', 'reset.invalid_token': 'Jeton manquant ou invalide.',
'verify.title': 'Vérifier lemail', 'verify.p1': 'Vérifiez votre boîte mail et ouvrez le lien de vérification.', 'verify.p2': 'Si le lien a expiré, réinscrivez-vous ou contactez le support.', 'verify.go_login': 'Aller à la connexion',
'welcome.title': 'Tableau de bord', 'welcome.back_prefix': 'Bon retour', 'welcome.generic': 'Bienvenue.', 'welcome.quick_links': 'Liens rapides',
'private.title': 'Tableau de bord', 'private.back_prefix': 'Bon retour', 'private.generic': 'Bienvenue.', 'private.quick_links': 'Liens rapides',
'admin.dashboard.title': 'Tableau de bord admin', 'admin.dashboard.area': 'Zone dadministration.', 'admin.users_count': 'Utilisateurs', 'admin.current_role': 'Rôle actuel', 'admin.navigation': 'Navigation', 'admin.manage_users': 'Gérer les utilisateurs',
'users.title': 'Utilisateurs', 'users.subtitle': 'Recherche, tri et pagination côté serveur via HTMX.', 'users.new_user': 'Nouvel utilisateur', 'users.search': 'Recherche', 'users.search_placeholder': 'Rechercher par nom ou email', 'users.page_size': 'Taille de page', 'users.search_button': 'Rechercher', 'users.user_detail': 'Détails utilisateur', 'users.actions': 'Actions', 'users.open': 'Ouvrir', 'users.none': 'Aucun utilisateur trouvé.', 'users.total': 'Total', 'users.users_label': 'utilisateurs', 'users.page': 'Page', 'users.prev': 'Préc.', 'users.next': 'Suiv.', 'users.close': 'Fermer',
'users.new_user_modal_title': 'Nouvel utilisateur', 'users.new_user_modal_placeholder': 'UI Flowbite placeholder. Connecter à une route backend si nécessaire.',
@ -207,22 +207,60 @@
});
}
function reinitFlowbiteComponents(target) {
if (typeof window.initDropdowns === 'function') window.initDropdowns();
if (typeof window.initModals === 'function') {
if (!target || target.id === 'usersTableContainer') {
window.initModals();
function initNavbarComponents(root) {
(root || document).querySelectorAll('[data-collapse-toggle]').forEach(function (button) {
if (button.dataset.tcBound === '1') return;
button.dataset.tcBound = '1';
var targetId = button.getAttribute('data-collapse-toggle');
var target = document.getElementById(targetId);
if (!target) return;
button.addEventListener('click', function () {
var isHidden = target.classList.contains('hidden');
target.classList.toggle('hidden', !isHidden);
button.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
});
});
var userButton = document.getElementById('user-menu-button');
var userDropdown = document.getElementById('user-dropdown');
if (userButton && userDropdown && userButton.dataset.tcBound !== '1') {
userButton.dataset.tcBound = '1';
userButton.addEventListener('click', function (event) {
event.preventDefault();
var isHidden = userDropdown.classList.contains('hidden');
userDropdown.classList.toggle('hidden', !isHidden);
userButton.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
});
}
if (!window.__tcNavbarDocBound) {
window.__tcNavbarDocBound = true;
document.addEventListener('click', function (event) {
var btn = document.getElementById('user-menu-button');
var menu = document.getElementById('user-dropdown');
if (!btn || !menu || menu.classList.contains('hidden')) return;
if (btn.contains(event.target) || menu.contains(event.target)) return;
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
var btn = document.getElementById('user-menu-button');
var menu = document.getElementById('user-dropdown');
if (!btn || !menu) return;
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
});
}
}
reinitFlowbiteComponents();
initNavbarComponents(document);
applyTranslations(document);
if (typeof window.initThemeToggle === 'function') {
window.initThemeToggle();
}
document.body.addEventListener('htmx:afterSwap', function (evt) {
reinitFlowbiteComponents(evt.target || null);
initNavbarComponents(evt.target || document);
applyTranslations(evt.target || document);
if (typeof window.initThemeToggle === 'function') {
window.initThemeToggle();

View File

@ -16,7 +16,6 @@
<ul class="mt-4 flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 p-4 text-sm font-medium md:mt-0 md:flex-row md:items-center md:gap-1 md:border-0 md:bg-transparent md:p-0 dark:border-gray-700 dark:bg-gray-800 md:dark:bg-transparent">
{{if eq .NavSection "home"}}
{{if .CurrentUser}}
<li><a href="/welcome" class="block rounded-lg px-3 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">Welcome</a></li>
{{else}}
<li><a href="/login" class="block rounded-lg px-3 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700" data-i18n="nav.login">Login</a></li>
{{end}}
@ -53,6 +52,10 @@
<span class="block truncate text-sm text-gray-500 dark:text-gray-400">{{.CurrentUser.Email}}</span>
</div>
<div class="py-2">
<a href="/private" class="block w-full rounded-lg px-2 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">Private</a>
{{if eq .CurrentUser.Role "admin"}}
<a href="/admin" class="block w-full rounded-lg px-2 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700" data-i18n="nav.admin">Admin</a>
{{end}}
<form action="/logout" method="post" class="px-2">
<button type="submit" class="block w-full rounded-lg px-2 py-2 text-left text-sm text-red-700 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900/40" data-i18n="nav.logout">Logout</button>
</form>