setup and render template file, create new logo in different shapes

This commit is contained in:
Giuseppe Naponiello
2026-06-17 22:28:31 +02:00
parent 611c554490
commit 34a8ebc6fb
17 changed files with 494 additions and 41 deletions

View File

@@ -1,5 +1,6 @@
import "@/styles/main.css"
import { setHeaderMenu } from "./ui";
import { renderUI } from "./ui";
import type { AuthUser } from "@/shared/auth";
type BootstrapOptions = {
@@ -7,20 +8,41 @@ type BootstrapOptions = {
onReady?: () => void | Promise<void>;
};
// Stub temporaneo: l'endpoint /api/user non è ancora disponibile, quindi
// simuliamo un utente autenticato (ADMIN) per provare header + pannello.
// Quando l'endpoint sarà pronto: importare getAuthUser da "@/shared/auth" e
// sostituire MOCK_USER con `await getAuthUser()` nel punto indicato sotto.
const MOCK_USER: AuthUser = {
id: 1,
name: 'Dev User',
email: 'dev@example.test',
role_id: 1, // ROLE.ADMIN → mostra anche la voce "Admin"
setup_status: 'complete',
};
export async function bootstrap(options: BootstrapOptions = {}): Promise<void> {
const {
activeLink = null,
onReady,
} = options;
setHeaderMenu(null);
// render() è idempotente: lo richiamiamo dopo aver risolto l'utente.
const render = (user: AuthUser | null) => {
renderUI(user);
if (activeLink !== null) { setActiveLink(activeLink); }
};
if(activeLink !== null){setActiveLink(activeLink);}
// 1. paint immediato come ospite (nessuna attesa di rete)
render(null);
await onReady?.();
// 2. upgrade: qui andrà `await getAuthUser()`; per ora usiamo lo stub.
const user: AuthUser | null = MOCK_USER;
if (user) { render(user); }
}
function setActiveLink(activeLink:string): void{
const link = document.getElementById(activeLink);
link?.classList.add('active');
}
}

View File

@@ -5,7 +5,11 @@ type IconName =
| 'shield'
| 'book-open'
| 'log-in'
| 'menu';
| 'menu'
| 'log-out'
| 'user'
| 'settings'
| 'layout-dashboard';
type Visibility =
| 'always'
@@ -50,6 +54,33 @@ export function getVisibleItems(isLoggedIn: boolean) {
);
}
// --- Menu utente (pannello laterale) -------------------------------------
// Mostrato solo agli utenti autenticati. Le voci con `roles` sono visibili
// solo ai ruoli elencati; senza `roles` sono visibili a tutti i loggati.
// Per spostare/aggiungere una voce basta intervenire qui, senza toccare il DOM.
type PanelAction = 'logout';
export type UserPanelItem = {
id: string;
label: string;
ico: IconName;
href?: string; // se presente, la voce è un link
action?: PanelAction; // se presente, la voce è un pulsante con comportamento
roles?: RoleId[]; // assente = tutti gli autenticati
};
const userPanel: UserPanelItem[] = [
{ id: 'dashboardLink', label: 'Dashboard', ico: 'layout-dashboard', href: '/dashboard' },
{ id: 'profileLink', label: 'Profile', ico: 'user', href: '/profile' },
{ id: 'adminLink', label: 'Admin', ico: 'settings', href: '/admin', roles: [ROLE.ADMIN, ROLE.SUPERVISOR] },
{ id: 'logoutBtn', label: 'Logout', ico: 'log-out', action: 'logout' },
];
export function getUserPanelItems(roleId: number): UserPanelItem[] {
return userPanel.filter(item => !item.roles || item.roles.includes(roleId as RoleId));
}
// Tipi per sidebar
// type SidebarAction = 'logout';
// type SidebarItem = {

View File

@@ -1,22 +1,217 @@
import type { AuthUser } from "@/shared/auth";
import { getVisibleItems } from "./menuTypes";
import { createIcons, House, MapPin, Award, Shield, BookOpen, LogIn, Menu } from 'lucide';
import { logoutUser } from "@/shared/auth";
import { getVisibleItems, getUserPanelItems, type UserPanelItem } from "./menuTypes";
import { getCurrentDate } from "@/shared/utils";
import {
createIcons,
House, MapPin, Award, Shield, BookOpen, LogIn, Menu,
LogOut, User, Settings, LayoutDashboard,
} from 'lucide';
export function setHeaderMenu(user: AuthUser | null): void {
type Img = {
src: string;
alt:string;
class?:string | null;
}
type MenuLink = {
id: string;
href: string;
ico: string;
label?: string;
};
const footerImg: Img[] = [
{src: '/logo/LundUniversity_footer.svg', alt: 'Lund University Logo'},
{src: '/logo/logo_footer_white.png', alt:'DarkLab Logo'}
];
// Set unico di icone Lucide usate nelle isole (header, pannello, footer).
const icons = { House, MapPin, Award, Shield, BookOpen, LogIn, Menu, LogOut, User, Settings, LayoutDashboard };
export function renderUI(user: AuthUser | null): void {
setLogoHeader();
setHeaderMenu(user);
setUserPanel(user);
setFooterContent(user);
setCopyrightCurrentYear();
createIcons({ icons });
}
function setCopyrightCurrentYear(){
const currentYear = document.getElementById("currentYear");
if(!currentYear) return;
currentYear.textContent = String(getCurrentDate().year);
}
// ---------------------------------------------------------------------------
// Brand
// Sorgente unica del logo header: il browser sceglie nativamente per breakpoint
// (scarica una sola immagine, reattivo al resize, nessun flash).
const headerLogo = {
alt: 'Dynamic Collection',
fallback: '/logo/logo_dyncoll_graphic.png',
sources: [
{ media: '(min-width: 48rem)', src: '/logo/logo_dyncoll.png' },
],
};
function setLogoHeader(): void{
const anchor = document.querySelector<HTMLAnchorElement>('#header-brand a');
if(!anchor) return;
const picture = document.createElement('picture');
picture.className = 'block h-full';
headerLogo.sources.forEach(s => {
const source = document.createElement('source');
source.media = s.media;
source.srcset = s.src;
picture.appendChild(source);
});
const img = document.createElement('img');
img.src = headerLogo.fallback;
img.alt = headerLogo.alt;
img.width = 432;
img.height = 320;
img.className = 'h-full w-auto';
picture.appendChild(img);
anchor.replaceChildren(picture);
}
// ---------------------------------------------------------------------------
// Header menu (link principali sempre visibili; ultimo slot = Login | hamburger)
function setHeaderMenu(user: AuthUser | null): void {
const nav = document.getElementById('header-menu');
if (!nav) {
console.error('Header target element not found');
return;
if (!nav) return;
buildMenu(nav, getVisibleItems(user !== null));
}
function setFooterContent(user: AuthUser | null): void{
const footerMenu = document.getElementById('footer-menu');
if(footerMenu){
buildMenu(footerMenu, getVisibleItems(user !== null), { exclude: ['userMenuToggle'] });
}
const items = getVisibleItems(false);
console.log(user);
items.forEach(link =>{
addFooterLogo();
}
function addFooterLogo(){
const footerLogo = document.getElementById('footer-logo');
if(!footerLogo) return;
footerLogo.replaceChildren(...footerImg.map(img => {
const el = document.createElement('img');
el.src = img.src;
el.alt = img.alt;
return el;
}));
}
function buildMenu(dom: HTMLElement, links: MenuLink[], opts: { exclude?: string[] } = {}): void {
const exclude = opts.exclude ?? [];
const isFooter = dom.id === 'footer-menu';
dom.replaceChildren(...links
.filter(link => !exclude.includes(link.id))
.map(link => {
const a = document.createElement('a');
a.id = link.id;
a.href = link.href;
a.classList.add('flex');
if(isFooter){ a.classList.add('link', 'link-hover','gap-2', 'mb-2'); }
const label = link.label ? `<span>${link.label}</span>` : '';
a.innerHTML = `<i data-lucide="${link.ico}"></i>${label}`;
return a;
}));
}
// ---------------------------------------------------------------------------
// Pannello utente (solo loggati): pannello laterale da destra, sotto l'header,
// con backdrop leggero. L'hamburger (#userMenuToggle) resta sempre visibile e
// fa da toggle. Replica il comportamento di v1 senza coprire l'header.
const PANEL_ID = 'user-panel';
const BACKDROP_ID = 'user-panel-backdrop';
let escHandler: ((e: KeyboardEvent) => void) | null = null;
function setUserPanel(user: AuthUser | null): void {
// pulizia: rimuove eventuale pannello precedente (re-render / logout)
document.getElementById(PANEL_ID)?.remove();
document.getElementById(BACKDROP_ID)?.remove();
if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; }
const toggle = document.getElementById('userMenuToggle') as HTMLAnchorElement | null;
if (!user || !toggle) return;
// backdrop (parte sotto l'header, vedi CSS)
const backdrop = document.createElement('div');
backdrop.id = BACKDROP_ID;
backdrop.className = 'user-panel-backdrop';
// pannello
const panel = document.createElement('aside');
panel.id = PANEL_ID;
panel.className = 'user-panel';
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('aria-label', 'Menu utente');
const list = document.createElement('ul');
list.className = 'menu w-full p-4';
getUserPanelItems(user.role_id).forEach(item => {
list.appendChild(buildPanelEntry(item));
});
panel.appendChild(list);
document.body.append(backdrop, panel);
// --- apertura / chiusura ---
const isOpen = () => panel.classList.contains('is-open');
const open = () => {
panel.classList.add('is-open');
backdrop.classList.add('is-open');
panel.setAttribute('aria-hidden', 'false');
toggle.setAttribute('aria-expanded', 'true');
panel.querySelector<HTMLElement>('a, button')?.focus();
};
const close = () => {
panel.classList.remove('is-open');
backdrop.classList.remove('is-open');
panel.setAttribute('aria-hidden', 'true');
toggle.setAttribute('aria-expanded', 'false');
};
const toggleOpen = () => (isOpen() ? close() : open());
// accessibilità sull'hamburger
toggle.setAttribute('role', 'button');
toggle.setAttribute('aria-controls', PANEL_ID);
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-haspopup', 'true');
toggle.addEventListener('click', e => { e.preventDefault(); toggleOpen(); });
backdrop.addEventListener('click', close);
escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen()) { close(); toggle.focus(); }
};
document.addEventListener('keydown', escHandler);
}
function buildPanelEntry(item: UserPanelItem): HTMLLIElement {
const li = document.createElement('li');
const icon = `<i data-lucide="${item.ico}"></i>`;
if (item.action === 'logout') {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = item.id;
btn.className = 'flex gap-2 w-full text-left';
btn.innerHTML = `${icon}<span>${item.label}</span>`;
btn.addEventListener('click', () => { void logoutUser(); });
li.appendChild(btn);
} else {
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 }});
}
a.id = item.id;
a.href = item.href ?? '#';
a.className = 'flex gap-2';
a.innerHTML = `${icon}<span>${item.label}</span>`;
li.appendChild(a);
}
return li;
}

View File

@@ -1,6 +1,7 @@
import { bootstrap } from "@/config/bootstrap"
bootstrap({
activeLink: 'homeLink',
onReady:() => init()
})

View File

@@ -1,5 +1,5 @@
import { createIcons, TriangleAlert } from 'lucide';
import { escapeHTML } from '../shared/utils';
import { escapeHTML } from '../utils';
export interface ConfirmOptions {
/** Titolo del modal (default: "Conferma"). */

View File

@@ -0,0 +1,36 @@
// Escape dei caratteri speciali HTML, per inserire testo non fidato in innerHTML
// senza rischi di injection.
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHTML(input: string): string {
return input.replace(/[&<>"']/g, ch => HTML_ESCAPES[ch] ?? ch);
}
// Scomposizione di una data nei suoi componenti (ora locale).
export interface CurrentDate {
year: number; // es. 2026
month: number; // 1-12 (1-indicizzato, leggibile dall'uomo)
day: number; // giorno del mese, 1-31
hour: number; // 0-23
minutes: number; // 0-59
seconds: number; // 0-59
}
// Ritorna anno/mese/giorno/ora/minuti/secondi della data fornita (default: adesso).
// Il parametro opzionale rende la funzione facilmente testabile.
export function getCurrentDate(date: Date = new Date()): CurrentDate {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
}

View File

@@ -1,16 +1,22 @@
@import '@fontsource/titillium-web/400.css';
@import '@fontsource/titillium-web/700.css'; /* per grassetto vero */
@import '@fontsource/rajdhani/500.css';
@import '@fontsource/rajdhani/700.css'; /* display/titoli, coerente col logo */
@import "tailwindcss";
@plugin "daisyui"; /* NOSONAR */
@theme {
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
--font-secondary: "Rajdhani", ui-sans-serif, system-ui, sans-serif;
}
:root{
--dc-header-h: 60px;
--dc-primary: rgb(34, 69, 138);
--dc-primary-dark: rgb(0, 15, 46);
--dc-secondary: rgb(249, 185, 88);
--dc-secondary-dark: rgb(241, 146, 4);
--dc-white: rgb(255, 255, 255);
--dc-dark-blue: rgb(0, 15, 46);
--dc-dark-gray: rgb(34, 34, 34);
--dc-dark-gray: rgb(52, 58, 64);
}
body{
@@ -25,8 +31,8 @@ header {
top: 0;
left: 0;
width: 100%;
height: 70px;
padding: 5px 0 5px 10px;
height: var(--dc-header-h);
padding: 0;
color: var(--dc-white);
background-color: var(--dc-primary);
display: flex;
@@ -43,15 +49,111 @@ header {
gap: 3px;
}
#header-menu a{
width:60px;
display: flex;
width:70px;
height:100%;
margin-bottom: 0;
flex-direction: column;
align-items: center;
justify-content: center;
}
#header-menu a:not(#userMenuToggle){
border-bottom: 4px solid transparent;
transition: all 500ms ease;
}
#header-menu a.active{
background-color: var(--dc-white);
color: var(--dc-primary);
border-bottom-color: var(--dc-white);
pointer-events: none;
}
#main-container{
position:relative;
margin-top:70px;
margin-top: var(--dc-header-h);
min-height: 90dvh;
flex:1;
}
#main-footer{
padding:50px;
position: relative;
min-height: 200px;
height:auto;
width:100%;
background-color: var(--dc-dark-gray);
color: var(--dc-white);
}
#footer-logo{
width:100%;
height:150px;
display: flex;
align-content: center;
justify-content: center;
}
@media (hover: hover) and (pointer: fine) {
#header-menu a:not(#userMenuToggle):hover{
border-bottom-color: var(--dc-white);
}
}
/* Sotto md (48rem): voci più strette e label più piccola, così il testo resta
sotto le icone senza mandare in overflow l'header su schermi piccoli. */
@media (max-width: 47.999rem) {
#header-menu a{
width: 3rem;
}
#header-menu a span{
font-size: 0.6rem;
line-height: 1.1;
}
}
/* ---------------------------------------------------------------------------
Pannello utente (solo loggati): scorre da destra, parte SOTTO l'header
(top = altezza header), con backdrop leggero. L'header resta sopra e
cliccabile (z-index 1000 > pannello 910 > backdrop 900). */
.user-panel{
position: fixed;
top: var(--dc-header-h);
right: 0;
bottom: 0;
width: min(20rem, 85vw);
z-index: 910;
background-color: var(--dc-white);
color: var(--dc-dark-gray);
box-shadow: -4px 0 16px rgb(0 0 0 / 0.18);
overflow-y: auto;
transform: translateX(100%);
transition: transform 300ms ease;
}
.user-panel.is-open{
transform: translateX(0);
}
.user-panel-backdrop{
position: fixed;
top: var(--dc-header-h);
left: 0;
right: 0;
bottom: 0;
z-index: 900;
background-color: rgb(0 0 0 / 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease;
}
.user-panel-backdrop.is-open{
opacity: 1;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.user-panel,
.user-panel-backdrop{
transition: none;
}
}