setup and render template file, create new logo in different shapes
@@ -36,10 +36,10 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="border-b border-white shadow">
|
<header class="shadow">
|
||||||
<div id="header-brand" class="h-full w-auto">
|
<div id="header-brand" class="h-full w-auto p-2">
|
||||||
<a href="/" aria-label="Dynamic Collection — home" class="inline-block h-full">
|
<a href="/" aria-label="Dynamic Collection — home" class="inline-block h-full w-auto">
|
||||||
<img src="/icons/lunds-unversitet.svg" alt="Lunds universitet" width="636" height="130" class="h-full w-auto" />
|
<img src="/logo/logo_dyncoll_graphic.png" alt="Dynamic Collection" width="432" height="320" class="h-full w-auto" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav id="header-menu" aria-label="Menu principale" class="h-full w-auto"></nav>
|
<nav id="header-menu" aria-label="Menu principale" class="h-full w-auto"></nav>
|
||||||
@@ -47,13 +47,68 @@
|
|||||||
|
|
||||||
<main id="main-container"></main>
|
<main id="main-container"></main>
|
||||||
|
|
||||||
<aside class="user-panel" aria-label="Area utente">
|
<!-- Il pannello utente (#user-panel) e il backdrop sono iniettati via TS
|
||||||
<div class="user-card"></div>
|
(setUserPanel) solo per gli utenti autenticati. -->
|
||||||
<nav aria-label="Menu utente"></nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<footer id="main-footer" class="h-[500px] w-full">
|
<footer id="main-footer">
|
||||||
<img src="/logo/logo_footer_dark.png" class="h-1/2 w-auto" alt="">
|
<div id="footer-info" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div id="footer-scope">
|
||||||
|
<p><span class="fw-bold">Dynamic Collections</span> is an initiative of</p>
|
||||||
|
<ul class="">
|
||||||
|
<li><a href="https://www.darklab.lu.se/" class="link link-hover" target="_blank">DARKLab</a></li>
|
||||||
|
<li><a href="https://www.lu.se/" class="link link-hover" target="_blank">Lund University</a></li>
|
||||||
|
<li><a href="http://vcg.isti.cnr.it/" class="link link-hover" target="_blank">Visual Computing Lab (ISTI-CNR)</a></li>
|
||||||
|
<li><p class="mt-3">© 2022 - <span id="currentYear"></span> <span class="text-(--dc-secondary-dark)">DARKLab</span></p></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="footer-license">
|
||||||
|
<h6 class="fw-bold">Licensing Information</h6>
|
||||||
|
<ul class="">
|
||||||
|
<li>
|
||||||
|
<strong>Text & Documentation:</strong> <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" class="link link-hover">CC BY 4.0</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Database Content:</strong> <a href="https://creativecommons.org/publicdomain/zero/1.0/" target="_blank" class="link link-hover">CC0 1.0 Universal</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Source Code:</strong> <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" class="link link-hover" target="_blank" title="Affero GPL 3.0 deed">GNU Affero General Public License</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Images & 3D Models:</strong> Individual licensing applies
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="footer-repo">
|
||||||
|
<ul class="list-unstyled small">
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/LundDarkLab/adc" class="link link-hover" target="_blank" title="source code repository [external link]">
|
||||||
|
<i class=""></i>
|
||||||
|
Source code on GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://codeberg.org/LundDarkLab/adc" class="link link-hover" target="_blank" title="source code mirror on Codeberg [external link]">
|
||||||
|
<i class=""></i>
|
||||||
|
Mirror on Codeberg
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://lunddarklab.github.io/adc/" class="link link-hover" target="_blank" title="platform documentation [external link]">
|
||||||
|
<i class=""></i>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://doi.org/10.5281/zenodo.20669650" class="link link-hover" target="_blank" title="citable software archive on Zenodo [external link]">
|
||||||
|
<i class=""></i>
|
||||||
|
Zenodo — DOI 10.5281/zenodo.20669650
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="footer-menu"></div>
|
||||||
|
</div>
|
||||||
|
<div id="footer-logo" class="mt-10 pt-5 border-t border-neutral-300"></div>
|
||||||
</footer>
|
</footer>
|
||||||
<script type="module" src="./src/pages/index/index.ts"></script>
|
<script type="module" src="./src/pages/index/index.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
9
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "dyncoll-frontend",
|
"name": "dyncoll-frontend",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@fontsource/titillium-web": "^5.2.8",
|
"@fontsource/titillium-web": "^5.2.8",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -114,6 +115,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/rajdhani": {
|
||||||
|
"version": "5.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz",
|
||||||
|
"integrity": "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fontsource/titillium-web": {
|
"node_modules/@fontsource/titillium-web": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/titillium-web/-/titillium-web-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/titillium-web/-/titillium-web-5.2.8.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest run --coverage",
|
"test": "vitest run --coverage --passWithNoTests",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@fontsource/titillium-web": "^5.2.8",
|
"@fontsource/titillium-web": "^5.2.8",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
1
frontend/public/logo/LundUniversity_footer.svg
Normal file
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 16 KiB |
BIN
frontend/public/logo/logo_dyncoll.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/public/logo/logo_dyncoll_graphic.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 1006 KiB |
BIN
frontend/public/logo/logo_footer_white.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import "@/styles/main.css"
|
import "@/styles/main.css"
|
||||||
import { setHeaderMenu } from "./ui";
|
import { renderUI } from "./ui";
|
||||||
|
import type { AuthUser } from "@/shared/auth";
|
||||||
|
|
||||||
|
|
||||||
type BootstrapOptions = {
|
type BootstrapOptions = {
|
||||||
@@ -7,20 +8,41 @@ type BootstrapOptions = {
|
|||||||
onReady?: () => void | Promise<void>;
|
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> {
|
export async function bootstrap(options: BootstrapOptions = {}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
activeLink = null,
|
activeLink = null,
|
||||||
onReady,
|
onReady,
|
||||||
} = options;
|
} = 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?.();
|
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{
|
function setActiveLink(activeLink:string): void{
|
||||||
const link = document.getElementById(activeLink);
|
const link = document.getElementById(activeLink);
|
||||||
link?.classList.add('active');
|
link?.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ type IconName =
|
|||||||
| 'shield'
|
| 'shield'
|
||||||
| 'book-open'
|
| 'book-open'
|
||||||
| 'log-in'
|
| 'log-in'
|
||||||
| 'menu';
|
| 'menu'
|
||||||
|
| 'log-out'
|
||||||
|
| 'user'
|
||||||
|
| 'settings'
|
||||||
|
| 'layout-dashboard';
|
||||||
|
|
||||||
type Visibility =
|
type Visibility =
|
||||||
| 'always'
|
| '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
|
// Tipi per sidebar
|
||||||
// type SidebarAction = 'logout';
|
// type SidebarAction = 'logout';
|
||||||
// type SidebarItem = {
|
// type SidebarItem = {
|
||||||
|
|||||||
@@ -1,22 +1,217 @@
|
|||||||
import type { AuthUser } from "@/shared/auth";
|
import type { AuthUser } from "@/shared/auth";
|
||||||
import { getVisibleItems } from "./menuTypes";
|
import { logoutUser } from "@/shared/auth";
|
||||||
import { createIcons, House, MapPin, Award, Shield, BookOpen, LogIn, Menu } from 'lucide';
|
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');
|
const nav = document.getElementById('header-menu');
|
||||||
if (!nav) {
|
if (!nav) return;
|
||||||
console.error('Header target element not found');
|
buildMenu(nav, getVisibleItems(user !== null));
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
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);
|
addFooterLogo();
|
||||||
console.log(user);
|
}
|
||||||
items.forEach(link =>{
|
|
||||||
|
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');
|
const a = document.createElement('a');
|
||||||
a.id = link.id;
|
a.id = item.id;
|
||||||
a.href = link.href
|
a.href = item.href ?? '#';
|
||||||
a.classList.add('h-14', 'w-10');
|
a.className = 'flex gap-2';
|
||||||
a.innerHTML = `<i data-lucide="${link.ico}"></i><span>${link.label || ''}</span>`;
|
a.innerHTML = `${icon}<span>${item.label}</span>`;
|
||||||
nav.appendChild(a);
|
li.appendChild(a);
|
||||||
})
|
}
|
||||||
createIcons({ icons: { House, MapPin, Award, Shield, BookOpen, LogIn, Menu }});
|
return li;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { bootstrap } from "@/config/bootstrap"
|
import { bootstrap } from "@/config/bootstrap"
|
||||||
|
|
||||||
bootstrap({
|
bootstrap({
|
||||||
|
activeLink: 'homeLink',
|
||||||
onReady:() => init()
|
onReady:() => init()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createIcons, TriangleAlert } from 'lucide';
|
import { createIcons, TriangleAlert } from 'lucide';
|
||||||
import { escapeHTML } from '../shared/utils';
|
import { escapeHTML } from '../utils';
|
||||||
|
|
||||||
export interface ConfirmOptions {
|
export interface ConfirmOptions {
|
||||||
/** Titolo del modal (default: "Conferma"). */
|
/** Titolo del modal (default: "Conferma"). */
|
||||||
|
|||||||
36
frontend/src/shared/utils.ts
Normal 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> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
@import '@fontsource/titillium-web/400.css';
|
@import '@fontsource/titillium-web/400.css';
|
||||||
@import '@fontsource/titillium-web/700.css'; /* per grassetto vero */
|
@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";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui"; /* NOSONAR */
|
@plugin "daisyui"; /* NOSONAR */
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Titillium Web", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-secondary: "Rajdhani", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root{
|
:root{
|
||||||
|
--dc-header-h: 60px;
|
||||||
--dc-primary: rgb(34, 69, 138);
|
--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-white: rgb(255, 255, 255);
|
||||||
--dc-dark-blue: rgb(0, 15, 46);
|
--dc-dark-gray: rgb(52, 58, 64);
|
||||||
--dc-dark-gray: rgb(34, 34, 34);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body{
|
body{
|
||||||
@@ -25,8 +31,8 @@ header {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 70px;
|
height: var(--dc-header-h);
|
||||||
padding: 5px 0 5px 10px;
|
padding: 0;
|
||||||
color: var(--dc-white);
|
color: var(--dc-white);
|
||||||
background-color: var(--dc-primary);
|
background-color: var(--dc-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -43,15 +49,111 @@ header {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
#header-menu a{
|
#header-menu a{
|
||||||
width:60px;
|
width:70px;
|
||||||
display: flex;
|
height:100%;
|
||||||
|
margin-bottom: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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{
|
#main-container{
|
||||||
position:relative;
|
position:relative;
|
||||||
margin-top:70px;
|
margin-top: var(--dc-header-h);
|
||||||
|
min-height: 90dvh;
|
||||||
flex:1;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||