scaffolding frontend common dom element

This commit is contained in:
Giuseppe Naponiello
2026-06-15 01:16:01 +02:00
parent 36bcc9a842
commit 611c554490
24 changed files with 945 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
import "@/styles/main.css"
import { setHeaderMenu } from "./ui";
type BootstrapOptions = {
@@ -12,6 +13,8 @@ export async function bootstrap(options: BootstrapOptions = {}): Promise<void> {
onReady,
} = options;
setHeaderMenu(null);
if(activeLink !== null){setActiveLink(activeLink);}
await onReady?.();

View File

@@ -0,0 +1,73 @@
type IconName =
| 'house'
| 'map-pin'
| 'award'
| 'shield'
| 'book-open'
| 'log-in'
| 'menu';
type Visibility =
| 'always'
| 'guest-only'
| 'auth-only';
type MenuItem = {
id: string;
href: string;
ico: IconName;
label?: string; // opzionale: il toggle utente non ha label
visibility: Visibility;
};
export const ROLE = {
ADMIN: 1,
SUPERVISOR: 2,
USER: 3,
GUEST: 4,
} as const;
export type RoleId = (typeof ROLE)[keyof typeof ROLE];
// Array
const menu: MenuItem[] = [
{ id: 'homeLink', href: '/', ico: 'house', label: 'Home', visibility: 'always' },
{ id: 'mapLink', href: '/', ico: 'map-pin', label: 'Map', visibility: 'always' },
{ id: 'creditsLink', href: 'https://www.darklab.lu.se/digital-collections/dynamic-collections/credits/', ico: 'award', label: 'Credits', visibility: 'always' },
{ id: 'legalLink', href: '/policy', ico: 'shield', label: 'Legal', visibility: 'always' },
{ id: 'docsLink', href: 'https://lunddarklab.github.io/adc/', ico: 'book-open', label: 'Docs', visibility: 'always' },
{ id: 'loginLink', href: '/login', ico: 'log-in', label: 'Login', visibility: 'guest-only' },
{ id: 'userMenuToggle', href: '#', ico: 'menu', visibility: 'auth-only' },
];
export function getVisibleItems(isLoggedIn: boolean) {
return menu.filter(item =>
(item.visibility === 'always' ||
(item.visibility === 'auth-only' && isLoggedIn) ||
(item.visibility === 'guest-only' && !isLoggedIn))
);
}
// Tipi per sidebar
// type SidebarAction = 'logout';
// type SidebarItem = {
// id?: string;
// label: string;
// ico: IconName;
// href?: string; // se presente, la voce è un link
// action?: SidebarAction; // se presente, la voce è un pulsante con comportamento
// };
// Gruppo di voci del menu laterale. `roles` elenca i ruoli che vedono il gruppo:
// per spostare/aggiungere un link basta intervenire qui, senza toccare il DOM.
// type SidebarGroup = {
// title?: string; // titolo di sezione (header colorato); assente nel primo gruppo
// roles: RoleId[];
// items: SidebarItem[];
// };
// Menu laterale (utenti autenticati). I gruppi sono filtrati per ruolo:
// sposta una voce in un altro gruppo per cambiarne la visibilità.
// const ALL_AUTH: RoleId[] = [ROLE.ADMIN, ROLE.SUPERVISOR, ROLE.USER];

View File

@@ -0,0 +1,22 @@
import type { AuthUser } from "@/shared/auth";
import { getVisibleItems } from "./menuTypes";
import { createIcons, House, MapPin, Award, Shield, BookOpen, LogIn, Menu } from 'lucide';
export function setHeaderMenu(user: AuthUser | null): void {
const nav = document.getElementById('header-menu');
if (!nav) {
console.error('Header target element not found');
return;
}
const items = getVisibleItems(false);
console.log(user);
items.forEach(link =>{
const a = document.createElement('a');
a.id = link.id;
a.href = link.href
a.classList.add('h-14', 'w-10');
a.innerHTML = `<i data-lucide="${link.ico}"></i><span>${link.label || ''}</span>`;
nav.appendChild(a);
})
createIcons({ icons: { House, MapPin, Award, Shield, BookOpen, LogIn, Menu }});
}

View File

@@ -0,0 +1,60 @@
import { showToast } from "./components/domEl";
type SetupStatus = 'password_required' | '2fa_setup_required' | 'complete';
export interface AuthUser {
id: number;
name: string;
email: string;
role_id: number;
setup_status: SetupStatus;
}
let _cachedUser: AuthUser | null | undefined = undefined;
export async function getAuthUser(forceRefresh = false): Promise<AuthUser | null> {
if (!forceRefresh && _cachedUser !== undefined) {
return _cachedUser;
}
try {
const response = await fetch('/api/user', {
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
console.log('getAuthUser status:', response.status);
const data = response.ok ? (await response.json() as AuthUser) : null;
console.log('getAuthUser data:', data);
_cachedUser = data;
} catch {
_cachedUser = null;
}
return _cachedUser;
}
export function clearAuthCache(): void {
_cachedUser = undefined;
}
export async function isAuthenticated(): Promise<boolean> {
return (await getAuthUser()) !== null;
}
export async function getCsrfCookie(): Promise<void> {
await fetch('/sanctum/csrf-cookie', { credentials: 'include' });
}
export async function logoutUser(){
await fetch('/api/logout', {
method: 'POST',
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
clearAuthCache();
showToast('Logout effettuato. Verrai reindirizzato alla home...', 'success');
setTimeout(() => { globalThis.location.href = '/'; }, 5000);
}

View File

@@ -0,0 +1,81 @@
import { createIcons, TriangleAlert } from 'lucide';
import { escapeHTML } from '../shared/utils';
export interface ConfirmOptions {
/** Titolo del modal (default: "Conferma"). */
title?: string;
/** Messaggio principale; i "\n" vengono resi come a capo. */
message: string;
/** Etichetta del pulsante di conferma (default: "Conferma"). */
confirmLabel?: string;
/** Etichetta del pulsante di annullamento (default: "Annulla"). */
cancelLabel?: string;
/** Variante cromatica: "danger" per azioni distruttive (default). */
variant?: 'danger' | 'primary';
}
/**
* Modal di conferma basato su <dialog> DaisyUI: alternativa controllabile e
* coerente cross-browser a globalThis.confirm(). Risolve true se l'utente
* conferma, false se annulla (anche via ESC o click sul backdrop).
*
* Il dialog viene creato e rimosso ad ogni chiamata, così non restano
* listener appesi né markup orfano nel DOM.
*/
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
const {
title = 'Conferma',
message,
confirmLabel = 'Conferma',
cancelLabel = 'Annulla',
variant = 'danger',
} = options;
return new Promise((resolve) => {
const confirmClass = variant === 'danger' ? 'btn-error' : 'btn-primary';
const iconClass = variant === 'danger' ? 'text-error' : 'text-primary';
const dialog = document.createElement('dialog');
dialog.className = 'modal';
dialog.innerHTML = `
<div class="modal-box">
<h3 class="flex items-center gap-2 text-lg font-bold">
<i data-lucide="triangle-alert" class="size-5 ${iconClass}"></i>
${escapeHTML(title)}
</h3>
<p class="py-4 whitespace-pre-line">${escapeHTML(message)}</p>
<div class="modal-action">
<button type="button" data-action="cancel" class="btn btn-ghost">${escapeHTML(cancelLabel)}</button>
<button type="button" data-action="confirm" class="btn ${confirmClass}">${escapeHTML(confirmLabel)}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit">chiudi</button>
</form>`;
document.body.appendChild(dialog);
createIcons({ icons: { TriangleAlert }, root: dialog });
let settled = false;
const settle = (result: boolean): void => {
if (settled) return;
settled = true;
resolve(result);
dialog.close();
};
dialog.querySelector('[data-action="confirm"]')?.addEventListener('click', () => settle(true));
dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => settle(false));
// Chiusura via ESC o click sul backdrop = annulla; al close rimuovo il nodo
dialog.addEventListener('close', () => {
if (!settled) {
settled = true;
resolve(false);
}
dialog.remove();
});
dialog.showModal();
});
}

View File

@@ -0,0 +1,31 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info';
// Mappa esplicita: Tailwind deve trovare le classi come stringhe letterali complete per includerle nel bundle
const toastAlertClass: Record<ToastType, string> = {
success: 'alert-success',
error: 'alert-error',
warning: 'alert-warning',
info: 'alert-info',
};
export function showToast(message: string, type: ToastType = 'info', duration = 3000): void {
// container toast — uno solo nel DOM
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast toast-top toast-center z-50';
document.body.appendChild(container);
}
const alert = document.createElement('div');
alert.className = `alert ${toastAlertClass[type]} shadow-lg transition-opacity duration-300`;
alert.innerHTML = `<span>${message}</span>`;
container.appendChild(alert);
// rimozione con fade out
setTimeout(() => {
alert.classList.add('opacity-0');
setTimeout(() => alert.remove(), 300);
}, duration);
}

View File

@@ -1,8 +1,57 @@
@import '@fontsource/titillium-web/400.css';
@import '@fontsource/titillium-web/700.css'; /* per grassetto vero */
@import "tailwindcss";
@plugin "daisyui"; /* NOSONAR */
@theme {
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
}
:root{
--dc-primary: rgb(34, 69, 138);
--dc-dark-blue: rgb(0, 15, 46);
--dc-white: rgb(255, 255, 255);
--dc-dark-blue: rgb(0, 15, 46);
--dc-dark-gray: rgb(34, 34, 34);
}
body{
min-height: 100dvh;
display: flex;
flex-direction: column;
background-color: var(--color-base-200);
}
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 70px;
padding: 5px 0 5px 10px;
color: var(--dc-white);
background-color: var(--dc-primary);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
z-index: 1000;
}
#header-menu{
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
}
#header-menu a{
width:60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#main-container{
position:relative;
margin-top:70px;
flex:1;
}