From 1e135e7681be89b0978bb9336746410b0f9255c6 Mon Sep 17 00:00:00 2001 From: pollutri Date: Tue, 26 May 2026 13:00:22 +0200 Subject: [PATCH] Normalizzazione dei formati vidio e immagine immessi --- app/admin/page.tsx | 115 +++++++- app/api/cards/route.ts | 18 +- app/api/files/route.ts | 11 +- app/api/portals/route.ts | 13 +- app/api/transcode/[id]/route.ts | 26 ++ app/api/upload/route.ts | 205 +++++++++++--- lib/sanitize.ts | 33 +++ lib/transcode.ts | 338 +++++++++++++++++++++++ lib/validation.ts | 79 ++++++ next.config.ts | 1 + package-lock.json | 403 +++++++++++++++++++++++++++- package.json | 5 +- scripts/sanitize-existing-cards.mjs | 85 ++++++ 13 files changed, 1287 insertions(+), 45 deletions(-) create mode 100644 app/api/transcode/[id]/route.ts create mode 100644 lib/sanitize.ts create mode 100644 lib/transcode.ts create mode 100644 lib/validation.ts create mode 100644 scripts/sanitize-existing-cards.mjs diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 8d12c6b..8d03f12 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -3,6 +3,19 @@ import { useState, useEffect, useRef } from 'react'; import { Card, Portal, MediaItem, CardType } from '@/types'; import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; +import { CARD_LIMITS } from '@/lib/validation'; + +type CharCounterProps = { value: string | undefined; limit: number }; +function CharCounter({ value, limit }: CharCounterProps) { + const len = (value ?? '').length; + if (len < limit * 0.8) return null; + const overflow = len > limit; + return ( +

+ {len} / {limit} +

+ ); +} function StyledSelect({ value, @@ -191,6 +204,9 @@ export default function AdminDashboard() { const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null); const [availableFonts, setAvailableFonts] = useState([]); + // Map: expected URL of the future-transcoded file → job state. + // We key by URL (not jobId) so the rendering layer can look it up cheaply. + const [transcodeJobs, setTranscodeJobs] = useState>({}); // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config. const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; @@ -207,6 +223,61 @@ export default function AdminDashboard() { fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); }, []); + // Poll pending transcode jobs every 2s. On 'done' we drop the entry from the + // map; on 'failed' we additionally pull the media URL out of the editor so the + // admin doesn't try to save a broken reference. + useEffect(() => { + const pendingEntries = Object.entries(transcodeJobs).filter( + ([, j]) => j.status === 'queued' || j.status === 'running' + ); + if (pendingEntries.length === 0) return; + + let cancelled = false; + const tick = async () => { + for (const [url, j] of pendingEntries) { + if (cancelled) return; + try { + const res = await fetch(`/api/transcode/${j.jobId}`); + if (!res.ok) continue; + const data = await res.json(); + if (cancelled) return; + + if (data.status === 'done') { + setTranscodeJobs(prev => { + const next = { ...prev }; + delete next[url]; + return next; + }); + } else if (data.status === 'failed' || data.status === 'cancelled') { + setTranscodeJobs(prev => { + const next = { ...prev }; + delete next[url]; + return next; + }); + setIsEditing(prev => prev ? { + ...prev, + extraMedia: (prev.extraMedia || []).filter(m => m.url !== url), + imageUrl: prev.imageUrl === url ? '' : prev.imageUrl, + } : prev); + const msg = data.status === 'failed' + ? `Trascodifica fallita${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}` + : 'Trascodifica annullata'; + showToast(msg, 'error'); + } else { + setTranscodeJobs(prev => prev[url] ? ({ ...prev, [url]: { ...prev[url], status: data.status, progress: data.progress ?? 0 } }) : prev); + } + } catch { + // ignore network glitches; will retry next tick + } + } + }; + + void tick(); + const id = window.setInterval(() => { void tick(); }, 2000); + return () => { cancelled = true; window.clearInterval(id); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Object.keys(transcodeJobs).join('|')]); + const handleUpload = async (e: React.ChangeEvent, field: string, isPortal = false) => { if (!e.target.files?.[0]) return; setUploading(prev => ({ ...prev, [field]: true })); @@ -282,6 +353,11 @@ export default function AdminDashboard() { const data = await res.json(); if (!data.url) continue; + if (data?.transcoding?.jobId) { + const { jobId, status } = data.transcoding; + setTranscodeJobs(prev => ({ ...prev, [data.url]: { jobId, status, progress: 0 } })); + } + if (isVideoFile(file)) { // Video always goes to the gallery so users can play it. uploaded.push({ url: data.url }); @@ -367,11 +443,28 @@ export default function AdminDashboard() { if (!isEditing) return; const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card; - - await fetch('/api/cards', { + + const res = await fetch('/api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard) }); + if (!res.ok) { + let message = 'Errore di salvataggio'; + try { + const body = await res.json(); + if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) { + const first = body.errors[0]; + message = first.limit != null + ? `${first.field}: ${first.message} (${first.actual} / ${first.limit})` + : `${first.field}: ${first.message}`; + } else if (body?.error) { + message = body.error; + } + } catch {} + showToast(message, 'error'); + return; // keep the editor open so the admin can fix + } + setCards(prev => { const exists = prev.find(c => c.id === newCard.id); return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard]; @@ -662,7 +755,8 @@ export default function AdminDashboard() {
- setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" /> + setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" /> +
@@ -683,21 +777,25 @@ export default function AdminDashboard() { setIsEditing({ ...isEditing, actionUrl: e.target.value })} className={inputClasses} placeholder="https://esempio.it/pagina" /> +
setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={inputClasses} placeholder="es. Visita il sito ufficiale" /> +

Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l’URL stessa.

@@ -718,7 +816,8 @@ export default function AdminDashboard() { ) : (
-