| @@ -17,6 +17,63 @@ function CharCounter({ value, limit }: CharCounterProps) { | |||
| ); | |||
| } | |||
| function stripTags(html: string): string { | |||
| if (typeof window === 'undefined' || !html) return ''; | |||
| return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''; | |||
| } | |||
| type RichTextMiniProps = { | |||
| value: string; | |||
| onChange: (html: string) => void; | |||
| limit: number; | |||
| className?: string; | |||
| }; | |||
| function RichTextMini({ value, onChange, limit, className }: RichTextMiniProps) { | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| // Sync iniziale soltanto. Aggiornare innerHTML durante l'editing perderebbe la | |||
| // posizione del cursore, quindi confidiamo che onInput tenga value e DOM allineati. | |||
| useEffect(() => { | |||
| if (ref.current && ref.current.innerHTML !== value) { | |||
| ref.current.innerHTML = value || ''; | |||
| } | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, []); | |||
| const exec = (cmd: 'bold' | 'italic') => { | |||
| ref.current?.focus(); | |||
| document.execCommand(cmd); | |||
| onChange(ref.current?.innerHTML || ''); | |||
| }; | |||
| return ( | |||
| <div> | |||
| <div className="flex gap-1 mb-1"> | |||
| <button | |||
| type="button" | |||
| onClick={() => exec('bold')} | |||
| className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100" | |||
| title="Grassetto" | |||
| >B</button> | |||
| <button | |||
| type="button" | |||
| onClick={() => exec('italic')} | |||
| className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100" | |||
| title="Corsivo" | |||
| >I</button> | |||
| </div> | |||
| <div | |||
| ref={ref} | |||
| contentEditable | |||
| suppressContentEditableWarning | |||
| onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)} | |||
| className={className ?? 'w-full border border-gray-300 rounded-lg p-2.5 min-h-[8rem] bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500'} | |||
| /> | |||
| <CharCounter value={stripTags(value)} limit={limit} /> | |||
| </div> | |||
| ); | |||
| } | |||
| function StyledSelect<T extends string>({ | |||
| value, | |||
| onChange, | |||
| @@ -519,6 +576,36 @@ export default function AdminDashboard() { | |||
| showToast('Portal settings saved successfully!'); // Replaced window.alert | |||
| }; | |||
| const handleBackupDownload = () => { | |||
| window.location.href = '/api/admin/backup'; | |||
| }; | |||
| const [restoring, setRestoring] = useState(false); | |||
| const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const file = e.target.files?.[0]; | |||
| e.target.value = ''; | |||
| if (!file) return; | |||
| if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return; | |||
| setRestoring(true); | |||
| try { | |||
| const fd = new FormData(); | |||
| fd.append('file', file); | |||
| const res = await fetch('/api/admin/restore', { method: 'POST', body: fd }); | |||
| const data = await res.json().catch(() => ({})); | |||
| if (!res.ok) { | |||
| showToast(data?.error || `Errore ripristino (${res.status})`, 'error'); | |||
| return; | |||
| } | |||
| showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`); | |||
| setTimeout(() => window.location.reload(), 1200); | |||
| } catch (err) { | |||
| showToast(`Errore di rete: ${(err as Error).message}`, 'error'); | |||
| } finally { | |||
| setRestoring(false); | |||
| } | |||
| }; | |||
| // Shared Input Classes for high contrast | |||
| const inputClasses = "w-full border border-gray-300 p-2.5 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400"; | |||
| @@ -582,6 +669,9 @@ export default function AdminDashboard() { | |||
| {card.extraMedia && card.extraMedia.length > 0 && ( | |||
| <span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span> | |||
| )} | |||
| {card.cardType === 'FULLSCREEN_LOCK' && ( | |||
| <span className="ml-2 bg-red-100 text-red-700 px-2 py-0.5 rounded font-bold text-[10px] tracking-wider">LOCK ATTIVA</span> | |||
| )} | |||
| </span> | |||
| </div> | |||
| </div> | |||
| @@ -615,8 +705,11 @@ export default function AdminDashboard() { | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label> | |||
| <textarea maxLength={PORTAL_LIMITS.welcomeText} value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} /> | |||
| <CharCounter value={portal.welcomeText} limit={PORTAL_LIMITS.welcomeText} /> | |||
| <RichTextMini | |||
| value={portal.welcomeText || ''} | |||
| onChange={html => setPortal({ ...portal, welcomeText: html })} | |||
| limit={PORTAL_LIMITS.welcomeText} | |||
| /> | |||
| </div> | |||
| <div className="flex gap-8"> | |||
| @@ -721,6 +814,32 @@ export default function AdminDashboard() { | |||
| </div> | |||
| </div> | |||
| <div className="mt-10 pt-6 border-t border-gray-200"> | |||
| <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Backup & Restore</h3> | |||
| <p className="text-xs text-gray-500 mb-4"> | |||
| Il backup contiene card, configurazione portale, media (immagini, video, PDF) e font caricati. Il ripristino sovrascrive lo stato attuale; la cartella precedente viene conservata come <code>data.bak-<timestamp></code> per sicurezza. | |||
| </p> | |||
| <div className="flex flex-wrap gap-3"> | |||
| <button | |||
| type="button" | |||
| onClick={handleBackupDownload} | |||
| className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm" | |||
| > | |||
| ⬇ Scarica backup ZIP | |||
| </button> | |||
| <label className={`cursor-pointer inline-flex items-center bg-amber-600 text-white px-5 py-2.5 rounded-lg hover:bg-amber-700 font-medium shadow-sm ${restoring ? 'opacity-60 cursor-not-allowed' : ''}`}> | |||
| <input | |||
| type="file" | |||
| accept=".zip,application/zip,application/x-zip-compressed" | |||
| onChange={handleRestoreUpload} | |||
| disabled={restoring} | |||
| hidden | |||
| /> | |||
| {restoring ? 'Ripristino in corso…' : '⤴ Ripristina da ZIP…'} | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end"> | |||
| <button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors"> | |||
| {savingPortal ? 'Saving...' : 'Save Portal Settings'} | |||
| @@ -769,11 +888,21 @@ export default function AdminDashboard() { | |||
| { value: 'INFO_PAGE', label: 'Info Page' }, | |||
| { value: 'IMAGE_GALLERY', label: 'Image Gallery' }, | |||
| { value: 'BOOK', label: 'Flip-Book' }, | |||
| { value: 'FULLSCREEN_LOCK', label: 'Fullscreen Lock (kiosk)' }, | |||
| ...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []), | |||
| ]} | |||
| /> | |||
| </div> | |||
| {isEditing.cardType === 'EXTERNAL_LINK' ? ( | |||
| {isEditing.cardType === 'FULLSCREEN_LOCK' && ( | |||
| <div className="bg-red-50 border border-red-200 rounded-lg p-4"> | |||
| <p className="text-sm font-semibold text-red-800">⚠ Modalità Kiosk Lock</p> | |||
| <p className="text-xs text-red-700 mt-1"> | |||
| Questa card prenderà il controllo totale del portale pubblico. Tutte le altre card saranno nascoste finché non rimuovi questa. | |||
| Carica un'immagine o un video come "Contenuto a schermo intero" nella sezione a destra. | |||
| </p> | |||
| </div> | |||
| )} | |||
| {isEditing.cardType !== 'FULLSCREEN_LOCK' && (isEditing.cardType === 'EXTERNAL_LINK' ? ( | |||
| <> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label> | |||
| @@ -821,8 +950,8 @@ export default function AdminDashboard() { | |||
| <textarea maxLength={CARD_LIMITS.shortDescription} value={isEditing.shortDescription || ''} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." /> | |||
| <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} /> | |||
| </div> | |||
| )} | |||
| {isEditing.cardType !== 'BOOK' && ( | |||
| ))} | |||
| {isEditing.cardType !== 'BOOK' && isEditing.cardType !== 'FULLSCREEN_LOCK' && ( | |||
| <div className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3"> | |||
| <label className="flex items-start gap-3 cursor-pointer"> | |||
| <input | |||
| @@ -852,18 +981,29 @@ export default function AdminDashboard() { | |||
| )} | |||
| </div> | |||
| <div className="space-y-5"> | |||
| {/* Cover Image */} | |||
| {/* Cover Image — per FULLSCREEN_LOCK è il contenuto kiosk a tutto schermo e accetta anche video */} | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1"> | |||
| Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span> | |||
| {isEditing.cardType === 'FULLSCREEN_LOCK' | |||
| ? <>Contenuto a schermo intero <span className="text-gray-400 font-normal text-xs">(immagine o video)</span></> | |||
| : <>Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span></>} | |||
| </label> | |||
| <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors"> | |||
| <input type="file" accept="image/*" onChange={e => handleUpload(e, 'imageUrl')} className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer" /> | |||
| <input | |||
| type="file" | |||
| accept={isEditing.cardType === 'FULLSCREEN_LOCK' ? 'image/*,video/mp4,video/webm,.mp4,.webm,.mov,.m4v' : 'image/*'} | |||
| onChange={e => handleUpload(e, 'imageUrl')} | |||
| className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer" | |||
| /> | |||
| {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>} | |||
| </div> | |||
| {isEditing.imageUrl && ( | |||
| <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group"> | |||
| <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" /> | |||
| {isVideoUrl(isEditing.imageUrl) ? ( | |||
| <video src={isEditing.imageUrl} className="w-full h-32 object-cover" muted playsInline /> | |||
| ) : ( | |||
| <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" /> | |||
| )} | |||
| <a | |||
| href={isEditing.imageUrl} | |||
| download={extractFileName(isEditing.imageUrl)} | |||
| @@ -880,8 +1020,8 @@ export default function AdminDashboard() { | |||
| )} | |||
| </div> | |||
| {/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) */} | |||
| {isEditing.cardType !== 'INFO_PAGE' && ( | |||
| {/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) e FULLSCREEN_LOCK (solo contenuto kiosk) */} | |||
| {isEditing.cardType !== 'INFO_PAGE' && isEditing.cardType !== 'FULLSCREEN_LOCK' && ( | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1"> | |||
| Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become images)</span> | |||
| @@ -0,0 +1,59 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { spawn } from 'node:child_process'; | |||
| import path from 'node:path'; | |||
| import { checkSystemBin } from '@/lib/system-bins'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export const maxDuration = 600; | |||
| const DATA_DIR = path.join(process.cwd(), 'data'); | |||
| function timestamp(): string { | |||
| const d = new Date(); | |||
| const pad = (n: number) => String(n).padStart(2, '0'); | |||
| return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; | |||
| } | |||
| export async function GET() { | |||
| if (!(await checkSystemBin('zip', '-v'))) { | |||
| return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 }); | |||
| } | |||
| // -r ricorsivo, - stdout, -x esclude pattern relativi al cwd. | |||
| // Stato runtime escluso: .tmp/, transcode-jobs.json. | |||
| const child = spawn( | |||
| 'zip', | |||
| [ | |||
| '-r', '-', | |||
| 'cards.txt', 'portals.txt', 'uploads', 'fonts', | |||
| '-x', 'uploads/.tmp/*', | |||
| ], | |||
| { cwd: DATA_DIR }, | |||
| ); | |||
| // Adatta stdout di Node a Web ReadableStream per la Response di Next. | |||
| const stream = new ReadableStream<Uint8Array>({ | |||
| start(controller) { | |||
| child.stdout.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); | |||
| child.stdout.on('end', () => controller.close()); | |||
| child.on('error', (err) => controller.error(err)); | |||
| child.on('exit', code => { | |||
| if (code !== 0 && code !== null) { | |||
| // 12 = "nothing to do" (cartella vuota). Tutto il resto è errore vero. | |||
| if (code !== 12) controller.error(new Error(`zip exited with code ${code}`)); | |||
| } | |||
| }); | |||
| }, | |||
| cancel() { | |||
| try { child.kill('SIGTERM'); } catch { /* ignore */ } | |||
| }, | |||
| }); | |||
| return new Response(stream, { | |||
| headers: { | |||
| 'Content-Type': 'application/zip', | |||
| 'Content-Disposition': `attachment; filename="interceptop-backup-${timestamp()}.zip"`, | |||
| 'Cache-Control': 'no-store', | |||
| }, | |||
| }); | |||
| } | |||
| @@ -0,0 +1,112 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { spawn } from 'node:child_process'; | |||
| import { mkdir, writeFile, readFile, rename, rm } from 'node:fs/promises'; | |||
| import path from 'node:path'; | |||
| import crypto from 'node:crypto'; | |||
| import { checkSystemBin } from '@/lib/system-bins'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export const maxDuration = 600; | |||
| const PROJECT_ROOT = process.cwd(); | |||
| const DATA_DIR = path.join(PROJECT_ROOT, 'data'); | |||
| const RESTORE_STAGING = path.join(PROJECT_ROOT, '.restore-staging'); | |||
| function runUnzip(zipPath: string, destDir: string): Promise<{ code: number; stderr: string }> { | |||
| return new Promise(resolve => { | |||
| const child = spawn('unzip', ['-o', '-q', zipPath, '-d', destDir]); | |||
| let stderr = ''; | |||
| child.stderr.on('data', d => { stderr += d.toString(); }); | |||
| child.on('error', () => resolve({ code: -1, stderr: stderr || 'unzip non avviato' })); | |||
| child.on('exit', code => resolve({ code: code ?? -1, stderr })); | |||
| }); | |||
| } | |||
| export async function POST(request: Request) { | |||
| if (!(await checkSystemBin('unzip', '-v'))) { | |||
| return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 }); | |||
| } | |||
| const sessionId = crypto.randomUUID(); | |||
| const sessionDir = path.join(RESTORE_STAGING, sessionId); | |||
| const zipPath = path.join(sessionDir, 'backup.zip'); | |||
| const extractDir = path.join(sessionDir, 'extract'); | |||
| try { | |||
| const formData = await request.formData(); | |||
| const file = formData.get('file') as File | null; | |||
| if (!file) { | |||
| return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 }); | |||
| } | |||
| await mkdir(extractDir, { recursive: true }); | |||
| const buf = Buffer.from(await file.arrayBuffer()); | |||
| await writeFile(zipPath, buf); | |||
| const { code, stderr } = await runUnzip(zipPath, extractDir); | |||
| if (code !== 0) { | |||
| return NextResponse.json( | |||
| { error: 'Impossibile estrarre lo ZIP.', detail: stderr.split('\n').slice(0, 3).join(' ') }, | |||
| { status: 400 }, | |||
| ); | |||
| } | |||
| // Validazione struttura: cards.txt e portals.txt devono esistere E essere JSON validi. | |||
| let cardsCount = 0; | |||
| let portalsCount = 0; | |||
| try { | |||
| const cardsRaw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8'); | |||
| const cardsParsed = JSON.parse(cardsRaw || '[]'); | |||
| if (!Array.isArray(cardsParsed)) throw new Error('cards.txt non è un array JSON'); | |||
| cardsCount = cardsParsed.length; | |||
| } catch (e) { | |||
| return NextResponse.json( | |||
| { error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` }, | |||
| { status: 400 }, | |||
| ); | |||
| } | |||
| try { | |||
| const portalsRaw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8'); | |||
| const portalsParsed = JSON.parse(portalsRaw || '[]'); | |||
| if (!Array.isArray(portalsParsed)) throw new Error('portals.txt non è un array JSON'); | |||
| portalsCount = portalsParsed.length; | |||
| } catch (e) { | |||
| return NextResponse.json( | |||
| { error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` }, | |||
| { status: 400 }, | |||
| ); | |||
| } | |||
| // Atomic swap: data → data.bak-<ts>, extract → data. | |||
| const ts = Date.now(); | |||
| const backupOldDir = path.join(PROJECT_ROOT, `data.bak-${ts}`); | |||
| try { | |||
| await rename(DATA_DIR, backupOldDir); | |||
| } catch (err) { | |||
| const e = err as NodeJS.ErrnoException; | |||
| if (e.code !== 'ENOENT') throw err; | |||
| // data/ non esisteva: ok, andiamo avanti | |||
| } | |||
| try { | |||
| await rename(extractDir, DATA_DIR); | |||
| } catch (err) { | |||
| // Revert: rimettiamo data/ al suo posto. | |||
| try { await rename(backupOldDir, DATA_DIR); } catch { /* ignore */ } | |||
| throw err; | |||
| } | |||
| // Cleanup zip + cartella staging della sessione (l'extract è già stato spostato). | |||
| try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ } | |||
| return NextResponse.json({ | |||
| ok: true, | |||
| restored: { cards: cardsCount, portals: portalsCount }, | |||
| previousDataBackup: path.basename(backupOldDir), | |||
| }); | |||
| } catch (error) { | |||
| console.error('Restore error:', error); | |||
| // Cleanup staging in caso di errore non gestito sopra. | |||
| try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ } | |||
| return NextResponse.json({ error: 'Errore durante il ripristino.' }, { status: 500 }); | |||
| } | |||
| } | |||
| @@ -1,7 +1,7 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { revalidatePath } from 'next/cache'; // ADD THIS | |||
| import { getPortals, savePortals } from '@/lib/db'; | |||
| import { isValidHexColor } from '@/lib/sanitize'; | |||
| import { isValidHexColor, sanitizeWelcomeText } from '@/lib/sanitize'; | |||
| import { validatePortal } from '@/lib/validation'; | |||
| import { Portal } from '@/types'; | |||
| @@ -16,6 +16,12 @@ export async function POST(request: Request) { | |||
| try { | |||
| const incomingPortal: Portal = await request.json(); | |||
| // Sanifica l'HTML del welcome text prima di validare la lunghezza, così il counter | |||
| // riflette il contenuto effettivamente persistito. | |||
| if (typeof incomingPortal.welcomeText === 'string') { | |||
| incomingPortal.welcomeText = sanitizeWelcomeText(incomingPortal.welcomeText); | |||
| } | |||
| const { valid, errors } = validatePortal(incomingPortal); | |||
| if (!valid) { | |||
| return NextResponse.json({ error: 'Validation failed', errors }, { status: 400 }); | |||
| @@ -1,14 +1,22 @@ | |||
| import { getCards, getPortals } from '@/lib/db'; | |||
| import PublicGrid from '@/components/PublicGrid'; | |||
| import HeroBanner from '@/components/HeroBanner'; | |||
| import FullscreenLock from '@/components/FullscreenLock'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export default async function PublicHomePage() { | |||
| const portals = await getPortals(); | |||
| const cards = await getCards(); | |||
| const portal = portals[0] || {}; | |||
| // Kiosk takeover: una card FULLSCREEN_LOCK fa sparire grid, hero e tutto il resto. | |||
| const sortedCards = [...cards].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); | |||
| const lockCard = sortedCards.find(c => c.cardType === 'FULLSCREEN_LOCK'); | |||
| if (lockCard) { | |||
| return <FullscreenLock card={lockCard} />; | |||
| } | |||
| return ( | |||
| <main className="min-h-screen bg-gray-100 font-sans"> | |||
| <HeroBanner portal={portal} /> | |||
| @@ -0,0 +1,36 @@ | |||
| 'use client'; | |||
| import { Card } from '@/types'; | |||
| const VIDEO_RE = /\.(mp4|m4v|webm|mov|qt|ogv|ogg)(\?|$)/i; | |||
| export default function FullscreenLock({ card }: { card: Card }) { | |||
| const url = card.imageUrl; | |||
| const isVideo = !!url && VIDEO_RE.test(url); | |||
| return ( | |||
| <div | |||
| className="fixed inset-0 z-[9999] bg-black flex items-center justify-center select-none" | |||
| onContextMenu={(e) => e.preventDefault()} | |||
| > | |||
| {!url ? ( | |||
| <p className="text-white/70 text-lg">Lock card senza contenuto. Apri /admin per aggiungere immagine o video.</p> | |||
| ) : isVideo ? ( | |||
| <video | |||
| src={url} | |||
| className="w-full h-full object-contain" | |||
| autoPlay | |||
| loop | |||
| muted | |||
| playsInline | |||
| /> | |||
| ) : ( | |||
| <img | |||
| src={url} | |||
| alt="" | |||
| className="w-full h-full object-contain" | |||
| draggable={false} | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -34,9 +34,12 @@ export default function HeroBanner({ portal }: { portal: Partial<Portal> }) { | |||
| <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 drop-shadow-md"> | |||
| {portal?.title || 'Welcome to the Network'} | |||
| </h1> | |||
| <p className="text-lg md:text-2xl drop-shadow-md font-light"> | |||
| {portal?.welcomeText || 'Please contact administration to set up.'} | |||
| </p> | |||
| <div | |||
| className="text-lg md:text-2xl drop-shadow-md font-light" | |||
| dangerouslySetInnerHTML={{ | |||
| __html: portal?.welcomeText || 'Please contact administration to set up.', | |||
| }} | |||
| /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| @@ -27,6 +27,18 @@ export function sanitizeCardHtml(input: string | null | undefined): string { | |||
| return sanitizeHtml(input, CARD_HTML_CONFIG); | |||
| } | |||
| // Welcome text: solo formattazione inline base + a-capo. Niente link, niente liste. | |||
| const WELCOME_TEXT_CONFIG: sanitizeHtml.IOptions = { | |||
| allowedTags: ['b', 'i', 'strong', 'em', 'br', 'p', 'div', 'span'], | |||
| allowedAttributes: {}, | |||
| disallowedTagsMode: 'discard', | |||
| }; | |||
| export function sanitizeWelcomeText(input: string | null | undefined): string { | |||
| if (!input) return ''; | |||
| return sanitizeHtml(input, WELCOME_TEXT_CONFIG); | |||
| } | |||
| const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; | |||
| export function isValidHexColor(value: unknown): value is string { | |||
| return typeof value === 'string' && HEX_COLOR_RE.test(value); | |||
| @@ -0,0 +1,19 @@ | |||
| // Verifica disponibilità di binari di sistema invocati via child_process. | |||
| // Cache in-memory per processo: i binari non vengono installati/disinstallati durante un run. | |||
| import { spawn } from 'node:child_process'; | |||
| const cache = new Map<string, boolean>(); | |||
| export async function checkSystemBin(name: string, versionArg = '-version'): Promise<boolean> { | |||
| const hit = cache.get(name); | |||
| if (hit !== undefined) return hit; | |||
| const ok = await new Promise<boolean>(resolve => { | |||
| const p = spawn(name, [versionArg]); | |||
| p.on('error', () => resolve(false)); | |||
| p.on('exit', code => resolve(code === 0)); | |||
| }); | |||
| if (!ok) console.warn(`[system-bins] '${name}' non trovato su PATH`); | |||
| cache.set(name, ok); | |||
| return ok; | |||
| } | |||
| @@ -11,6 +11,7 @@ const VALID_CARD_TYPES: readonly CardType[] = [ | |||
| 'IMAGE_GALLERY', | |||
| 'SERVICE_REQUEST', | |||
| 'BOOK', | |||
| 'FULLSCREEN_LOCK', | |||
| ] as const; | |||
| const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']); | |||
| @@ -1,4 +1,4 @@ | |||
| export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST' | 'BOOK'; | |||
| export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST' | 'BOOK' | 'FULLSCREEN_LOCK'; | |||
| export type MediaItem = { | |||
| url: string; | |||