@@ -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>({
function StyledSelect<T extends string>({
value,
value,
onChange,
onChange,
@@ -519,6 +576,36 @@ export default function AdminDashboard() {
showToast('Portal settings saved successfully!'); // Replaced window.alert
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
// 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";
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 && (
{card.extraMedia && card.extraMedia.length > 0 && (
<span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span>
<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>
</span>
</div>
</div>
</div>
</div>
@@ -615,8 +705,11 @@ export default function AdminDashboard() {
<div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
<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>
<div className="flex gap-8">
<div className="flex gap-8">
@@ -721,6 +814,32 @@ export default function AdminDashboard() {
</div>
</div>
</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">
<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">
<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'}
{savingPortal ? 'Saving...' : 'Save Portal Settings'}
@@ -769,11 +888,21 @@ export default function AdminDashboard() {
{ value: 'INFO_PAGE', label: 'Info Page' },
{ value: 'INFO_PAGE', label: 'Info Page' },
{ value: 'IMAGE_GALLERY', label: 'Image Gallery' },
{ value: 'IMAGE_GALLERY', label: 'Image Gallery' },
{ value: 'BOOK', label: 'Flip-Book' },
{ value: 'BOOK', label: 'Flip-Book' },
{ value: 'FULLSCREEN_LOCK', label: 'Fullscreen Lock (kiosk)' },
...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
]}
]}
/>
/>
</div>
</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>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">URL</label>
<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..." />
<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} />
<CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
</div>
</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">
<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">
<label className="flex items-start gap-3 cursor-pointer">
<input
<input
@@ -852,18 +981,29 @@ export default function AdminDashboard() {
)}
)}
</div>
</div>
<div className="space-y-5">
<div className="space-y-5">
{/* Cover Image */}
{/* Cover Image — per FULLSCREEN_LOCK è il contenuto kiosk a tutto schermo e accetta anche video */}
<div>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">
<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>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<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>}
{uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
</div>
</div>
{isEditing.imageUrl && (
{isEditing.imageUrl && (
<div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
<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
<a
href={isEditing.imageUrl}
href={isEditing.imageUrl}
download={extractFileName(isEditing.imageUrl)}
download={extractFileName(isEditing.imageUrl)}
@@ -880,8 +1020,8 @@ export default function AdminDashboard() {
)}
)}
</div>
</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>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">
<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>
Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become images)</span>