diff --git a/.vscode/settings.json b/.vscode/settings.json index 25a4c6e..0604e95 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,13 @@ // segnala falsi positivi "Unknown at-rule". Le elabora @tailwindcss/vite. "css.lint.unknownAtRules": "ignore", "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore" + "less.lint.unknownAtRules": "ignore", + + // SonarLint (estensione, diversa dal linter CSS di VS Code): disattiva la regola + // css:S4662 "Unknown at-rule", che segnala le at-rule di Tailwind v4 + // (@theme, @plugin, @apply, @custom-variant, @source, @utility). Coerente con + // l'esclusione lato server in sonar-project.properties. + "sonarlint.rules": { + "css:S4662": { "level": "off" } + } } diff --git a/Makefile b/Makefile index 0da2afb..716755d 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ DC := docker compose EXEC := $(DC) exec -u $(UID):$(GID) backend .DEFAULT_GOAL := help -.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker fe-install fe-rebuild fe-dev fe-build permissions +.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker fe-install fe-add fe-rebuild fe-dev fe-build permissions help: ## Mostra questo aiuto @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' @@ -45,13 +45,15 @@ tinker: ## REPL artisan (container) ## --- Frontend / Node --- # In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl -# nel volume anonimo. Su host npm serve solo a tenere package-lock + node_modules -# host sincronizzati per il type-check dell'IDE; dopo ogni modifica a package.json -# rigenerare l'immagine frontend con `make fe-rebuild`. -fe-install: ## npm install host (aggiorna package-lock + node_modules host per l'IDE) +# nel volume anonimo. npm gira sull'HOST (package.json/lock + node_modules host per +# il type-check dell'IDE); poi si rigenera immagine + volume node_modules del container. +# AGGIUNGERE UNA LIBRERIA: make fe-add p="leaflet" && make fe-rebuild +fe-install: ## npm install host da package.json (package-lock + node_modules host per l'IDE) cd frontend && npm install -fe-rebuild: ## Ricostruisce l'immagine frontend dopo modifiche a package.json (aggiorna node_modules del container) - $(DC) up -d --build frontend +fe-add: ## Aggiunge una dipendenza (host) — es: make fe-add p="leaflet" oppure p="-D nome"; poi make fe-rebuild + cd frontend && npm install $(p) +fe-rebuild: ## Rigenera immagine frontend E il volume node_modules del container (dopo modifiche a package.json) + $(DC) up -d --build --renew-anon-volumes frontend fe-dev: ## ALTERNATIVA: vite dev server su host senza Docker (conflitto porta 5173 se lo stack è su) cd frontend && npm run dev fe-build: ## build di produzione su host (smoke test locale) diff --git a/frontend/index.html b/frontend/index.html index ca286f9..910b629 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,60 @@ - - - Dynamic Collection - Index + + + Dynamic Collection — Index + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + Lunds universitet + +
+ +
+ +
+ + + + - \ No newline at end of file + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 21bbfff..04e1c48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,12 @@ "name": "dyncoll-frontend", "version": "3.0.0", "dependencies": { + "@fontsource/titillium-web": "^5.2.8", "axios": "^1.17.0", - "leaflet": "^1.9.4" + "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", + "leaflet": "^1.9.4", + "lucide": "^1.18.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.1", @@ -110,6 +114,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource/titillium-web": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/titillium-web/-/titillium-web-5.2.8.tgz", + "integrity": "sha512-DNhXS1ib/de+LwbJiEmKgB9WAGK6MZfG9qFbJbz1JoY1f4KCt9HDK7lSUtqJPxdK47D2o0vqdWcg5SD+yphWOw==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -155,6 +167,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -992,6 +1009,25 @@ "node": ">=18" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -1785,6 +1821,11 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lucide": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.18.0.tgz", + "integrity": "sha512-xzUa3LbA/Uvn3DCs7B7YfDcOZeqV8aZ4r4OFyCgJSfZGUdMglJiK3PJSbd26SXLLQvGGsYX9PdETCRXvfK4rnA==" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index 141875c..9f96246 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,11 @@ "vitest": "^4.1.8" }, "dependencies": { + "@fontsource/titillium-web": "^5.2.8", "axios": "^1.17.0", - "leaflet": "^1.9.4" + "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", + "leaflet": "^1.9.4", + "lucide": "^1.18.0" } } diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100755 index 0000000..43e5a47 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..159cfbb --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + DC + diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..0a487f9 Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..a00ac98 Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..8af2658 Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/icon-maskable-512.png b/frontend/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..8af2658 Binary files /dev/null and b/frontend/public/icons/icon-maskable-512.png differ diff --git a/frontend/public/icons/lunds-unversitet.svg b/frontend/public/icons/lunds-unversitet.svg new file mode 100755 index 0000000..18ad5cc --- /dev/null +++ b/frontend/public/icons/lunds-unversitet.svg @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/og-image.png b/frontend/public/icons/og-image.png new file mode 100644 index 0000000..6ca13fd Binary files /dev/null and b/frontend/public/icons/og-image.png differ diff --git a/frontend/public/logo/logo_DARKLab_dark.jpg b/frontend/public/logo/logo_DARKLab_dark.jpg new file mode 100644 index 0000000..e2a8b20 Binary files /dev/null and b/frontend/public/logo/logo_DARKLab_dark.jpg differ diff --git a/frontend/public/logo/logo_footer_dark.png b/frontend/public/logo/logo_footer_dark.png new file mode 100644 index 0000000..194c71a Binary files /dev/null and b/frontend/public/logo/logo_footer_dark.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..54dceab --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Dynamic Collection", + "short_name": "DynColl", + "description": "Curated digital collections of archaeological artefacts with interactive 3D visualisation.", + "lang": "en", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "minimal-ui", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#22458a", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/src/config/bootstrap.ts b/frontend/src/config/bootstrap.ts index 961d819..defc77c 100644 --- a/frontend/src/config/bootstrap.ts +++ b/frontend/src/config/bootstrap.ts @@ -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 { onReady, } = options; + setHeaderMenu(null); + if(activeLink !== null){setActiveLink(activeLink);} await onReady?.(); diff --git a/frontend/src/config/menuTypes.ts b/frontend/src/config/menuTypes.ts new file mode 100644 index 0000000..6b585aa --- /dev/null +++ b/frontend/src/config/menuTypes.ts @@ -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]; \ No newline at end of file diff --git a/frontend/src/config/ui.ts b/frontend/src/config/ui.ts index e69de29..54807fa 100644 --- a/frontend/src/config/ui.ts +++ b/frontend/src/config/ui.ts @@ -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 = `${link.label || ''}`; + nav.appendChild(a); + }) + createIcons({ icons: { House, MapPin, Award, Shield, BookOpen, LogIn, Menu }}); +} \ No newline at end of file diff --git a/frontend/src/shared/auth.ts b/frontend/src/shared/auth.ts new file mode 100644 index 0000000..6d70fd8 --- /dev/null +++ b/frontend/src/shared/auth.ts @@ -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 { + 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 { + return (await getAuthUser()) !== null; +} + +export async function getCsrfCookie(): Promise { + 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); +} \ No newline at end of file diff --git a/frontend/src/shared/components/confirmDialog.ts b/frontend/src/shared/components/confirmDialog.ts new file mode 100644 index 0000000..3142916 --- /dev/null +++ b/frontend/src/shared/components/confirmDialog.ts @@ -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 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 { + 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 = ` + + `; + + 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(); + }); +} diff --git a/frontend/src/shared/components/domEl.ts b/frontend/src/shared/components/domEl.ts new file mode 100644 index 0000000..2a0551e --- /dev/null +++ b/frontend/src/shared/components/domEl.ts @@ -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 = { + 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 = `${message}`; + container.appendChild(alert); + + // rimozione con fade out + setTimeout(() => { + alert.classList.add('opacity-0'); + setTimeout(() => alert.remove(), 300); + }, duration); +} \ No newline at end of file diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 2ee3f41..192f70b 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; } \ No newline at end of file diff --git a/frontend/template.html.example b/frontend/template.html.example new file mode 100644 index 0000000..4de9ffa --- /dev/null +++ b/frontend/template.html.example @@ -0,0 +1,24 @@ + + + + + + Dynamic Collection - Index + + +
+
+ +
+ +
+ + + +
+ + + \ No newline at end of file