scaffolding frontend common dom element
This commit is contained in:
@@ -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?.();
|
||||
|
||||
73
frontend/src/config/menuTypes.ts
Normal file
73
frontend/src/config/menuTypes.ts
Normal 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];
|
||||
@@ -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 }});
|
||||
}
|
||||
60
frontend/src/shared/auth.ts
Normal file
60
frontend/src/shared/auth.ts
Normal 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);
|
||||
}
|
||||
81
frontend/src/shared/components/confirmDialog.ts
Normal file
81
frontend/src/shared/components/confirmDialog.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
31
frontend/src/shared/components/domEl.ts
Normal file
31
frontend/src/shared/components/domEl.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user