'use client';
import { useState, useEffect, useRef } from 'react';
import { Card, Portal, MediaItem, CardType } from '@/types';
import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_PRESET_SAVE_ENABLED } from '@/lib/config';
import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
import { withBasePath } from '@/lib/url';
type CharCounterProps = { value: string | undefined; limit: number };
function CharCounter({ value, limit }: CharCounterProps) {
const len = (value ?? '').length;
const remaining = limit - len;
const overflow = len > limit;
const near = !overflow && len >= limit * 0.8;
const color = overflow ? 'text-red-600 font-semibold' : near ? 'text-amber-600' : 'text-gray-400';
return (
{len} / {limit} · {remaining < 0 ? `${Math.abs(remaining)} oltre il limite` : `${remaining} rimanenti`}
);
}
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(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 (
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'}
/>
);
}
function StyledSelect
({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string; style?: React.CSSProperties }[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const current = options.find(o => o.value === value);
// Fallback: se il value non matcha nessuna opzione (es. tipo disattivato dalla flag), mostra il valore raw prettificato
const displayLabel = current?.label
?? (typeof value === 'string' && value.length > 0
? value.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
: '');
const inputBase = "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";
return (
{open && (
{options.map(o => (
))}
)}
);
}
const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|mkv|avi|divx|wmv|asf|flv|f4v|3gp|3gpp|3g2|mts|m2ts|ts|mpg|mpeg|vob|mxf|ogv|ogg';
// Sottoinsieme di formati video davvero riproducibili dai browser moderni
const PLAYBACK_SUPPORTED_VIDEO = 'mp4|m4v|webm|mov|qt|ogv|ogg';
const PLAYBACK_SUPPORTED_LABEL = 'MP4, M4V, WebM, MOV, OGV';
const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
const isPdfFile = (file: File) =>
file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
const isVideoFile = (file: File) =>
file.type.startsWith('video/') || new RegExp(`\\.(${VIDEO_EXTENSIONS})$`, 'i').test(file.name);
const isPlayableVideoFile = (file: File) =>
new RegExp(`\\.(${PLAYBACK_SUPPORTED_VIDEO})$`, 'i').test(file.name);
const previewFontFamily = (filename: string): string =>
`PortalPreview-${filename.replace(/[^A-Za-z0-9]/g, '_')}`;
const fontFormatFromName = (filename: string): string => {
const ext = filename.match(/\.([^.]+)$/)?.[1].toLowerCase() ?? 'woff2';
return ({ woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' } as Record)[ext] ?? 'woff2';
};
const extractFileName = (url: string): string => {
const match = url.match(/[?&]name=([^&]+)/);
if (match) return decodeURIComponent(match[1]);
const seg = url.split('/').pop() || 'download';
return seg.split('?')[0];
};
async function uploadBlobAsImage(blob: Blob, name: string): Promise {
const formData = new FormData();
formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData });
const data = await res.json();
return data.url || null;
}
async function extractVideoFrame(file: File): Promise {
const url = URL.createObjectURL(file);
try {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
video.src = url;
await new Promise((resolve, reject) => {
video.addEventListener('loadedmetadata', () => resolve(), { once: true });
video.addEventListener('error', () => reject(new Error('video load error')), { once: true });
});
// Seek slightly past 0 — at exactly 0 some codecs return a black frame
video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10));
await new Promise((resolve, reject) => {
video.addEventListener('seeked', () => resolve(), { once: true });
video.addEventListener('error', () => reject(new Error('video seek error')), { once: true });
});
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85)
);
} finally {
URL.revokeObjectURL(url);
}
}
async function pdfToImageItems(
file: File,
onProgress: (page: number, total: number) => void
): Promise {
const pdfjs = await import('pdfjs-dist');
// Worker file is copied to /public via the postinstall script
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
const items: MediaItem[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
onProgress(i, pdf.numPages);
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
await page.render({ canvasContext: ctx, viewport }).promise;
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
if (url) items.push({ url });
}
return items;
}
export default function AdminDashboard() {
const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
// Card State
const [cards, setCards] = useState([]);
const [isEditing, setIsEditing] = useState | null>(null);
// Portal State
const [portal, setPortal] = useState>({});
const [savingPortal, setSavingPortal] = useState(false);
const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
// NEW UI STATES: Toast and Confirm Dialog
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
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;
// Helper to show auto-dismissing toast
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000);
};
useEffect(() => {
fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards);
fetch(withBasePath('/api/portals')).then(res => res.json()).then(data => data && setPortal(data));
fetch(withBasePath('/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(withBasePath(`/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'
? `Transcoding failed${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 }));
const formData = new FormData();
formData.append('file', e.target.files[0]);
const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
if (isPortal) {
setPortal(prev => ({ ...prev, [field]: data.url }));
} else {
setIsEditing(prev => ({ ...prev, [field]: data.url }));
}
}
setUploading(prev => ({ ...prev, [field]: false }));
};
const handleUploadExtraMedia = async (e: React.ChangeEvent) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(prev => ({ ...prev, extraMedia: true }));
const startedWithoutCover = !isEditing?.imageUrl;
let pendingCover: string | null = null;
const canPromote = () => startedWithoutCover && !pendingCover;
// Pre-filtro: scarta video con formati non riproducibili nei browser
const rejected: string[] = [];
const acceptedFiles: File[] = [];
for (const file of Array.from(files)) {
if (isVideoFile(file) && !isPlayableVideoFile(file)) {
rejected.push(file.name);
} else {
acceptedFiles.push(file);
}
}
if (rejected.length > 0) {
const list = rejected.length <= 3
? rejected.join(', ')
: `${rejected.slice(0, 3).join(', ')} and ${rejected.length - 3} more`;
showToast(
`Unsupported format! Supported formats: ${PLAYBACK_SUPPORTED_LABEL}. Skipped files: ${list}`,
'error'
);
}
if (acceptedFiles.length === 0) {
setUploading(prev => ({ ...prev, extraMedia: false }));
e.target.value = '';
return;
}
const uploaded: MediaItem[] = [];
for (const file of acceptedFiles) {
try {
if (isPdfFile(file)) {
const items = await pdfToImageItems(file, (page, total) =>
setPdfProgress({ name: file.name, page, total })
);
setPdfProgress(null);
if (items.length > 0 && canPromote()) {
// Promote the first PDF page to cover; skip it from the gallery to avoid duplication.
pendingCover = items[0].url;
uploaded.push(...items.slice(1));
} else {
uploaded.push(...items);
}
} else {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData });
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 });
// If no cover yet, extract the first frame and use it as the cover.
if (canPromote()) {
try {
const blob = await extractVideoFrame(file);
if (blob) {
const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_');
const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`);
if (posterUrl) pendingCover = posterUrl;
}
} catch (err) {
console.warn('Could not extract video poster for', file.name, err);
}
}
} else {
// Plain image
if (canPromote()) {
// Promote to cover; skip the gallery to avoid duplication.
pendingCover = data.url;
} else {
uploaded.push({ url: data.url });
}
}
}
} catch (err) {
console.error('Upload failed for', file.name, err);
showToast(`Failed to process "${file.name}".`);
setPdfProgress(null);
}
}
setIsEditing(prev => ({
...prev,
imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''),
extraMedia: [...(prev?.extraMedia || []), ...uploaded],
}));
setUploading(prev => ({ ...prev, extraMedia: false }));
e.target.value = '';
};
const removeExtraMedia = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
}));
};
const moveExtraMedia = (index: number, direction: 'up' | 'down') => {
setIsEditing(prev => {
const items = [...(prev?.extraMedia || [])];
if (direction === 'up' && index > 0) {
[items[index - 1], items[index]] = [items[index], items[index - 1]];
} else if (direction === 'down' && index < items.length - 1) {
[items[index + 1], items[index]] = [items[index], items[index + 1]];
} else {
return prev;
}
return { ...prev, extraMedia: items };
});
};
const toggleAutoplay = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).map((m, i) =>
i === index ? { ...m, autoplay: !m.autoplay } : m
),
}));
};
const toggleMuted = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).map((m, i) =>
i === index ? { ...m, muted: !m.muted } : m
),
}));
};
const handleSaveCard = async () => {
if (!isEditing) return;
// External Link: URL obbligatorio (feedback immediato, ribadito anche lato server)
if (isEditing.cardType === 'EXTERNAL_LINK' && !isEditing.actionUrl?.trim()) {
showToast('URL is required for External Link cards', 'error');
return;
}
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
const res = await fetch(withBasePath('/api/cards'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
});
if (!res.ok) {
let message = 'Save error';
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];
});
setIsEditing(null);
};
const handleDeleteCard = (id: string) => {
// Replace window.confirm with our custom dialog
setConfirmDialog({
message: 'Are you sure you want to delete this card? This action cannot be undone.',
onConfirm: async () => {
await fetch(withBasePath(`/api/cards?id=${id}`), { method: 'DELETE' });
setCards(prev => prev.filter(c => c.id !== id));
setConfirmDialog(null);
showToast('Card successfully deleted.');
}
});
};
const moveCard = async (index: number, direction: 'up' | 'down') => {
const newCards = [...cards];
if (direction === 'up' && index > 0) {
[newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
} else if (direction === 'down' && index < newCards.length - 1) {
[newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
} else {
return; // Do nothing if trying to move out of bounds
}
// Recalculate displayOrder for the whole array
const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
// Optimistically update the UI
setCards(updatedCards);
// Persist the new order to the backend
await fetch(withBasePath('/api/cards'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedCards)
});
};
const handleSavePortal = async () => {
setSavingPortal(true);
await fetch(withBasePath('/api/portals'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
});
setSavingPortal(false);
showToast('Portal settings saved successfully!'); // Replaced window.alert
};
const handleBackupDownload = () => {
window.location.href = withBasePath('/api/admin/backup');
};
const [restoring, setRestoring] = useState(false);
const handleRestoreUpload = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (!window.confirm('Restore will overwrite all current data (cards, portal, media, fonts). Continue?')) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(withBasePath('/api/admin/restore'), { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Restore error (${res.status})`, 'error');
return;
}
showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally {
setRestoring(false);
}
};
// Factory preset: la sezione è sempre visibile (per chi accede a /admin); solo
// il bottone "Salva come preset" è gated da FACTORY_PRESET_SAVE_ENABLED.
const [factoryPreset, setFactoryPreset] = useState<{ exists: boolean; sizeBytes?: number; modifiedAt?: string } | null>(null);
const [savingPreset, setSavingPreset] = useState(false);
const [factoryResetting, setFactoryResetting] = useState(false);
const refreshFactoryPreset = async () => {
try {
const res = await fetch(withBasePath('/api/admin/factory-preset'));
if (res.ok) setFactoryPreset(await res.json());
} catch { /* ignore */ }
};
useEffect(() => {
void refreshFactoryPreset();
}, []);
const handleSaveFactoryPreset = async () => {
const msg = factoryPreset?.exists
? 'Overwrite the existing factory preset with the current state?'
: 'Save the current state as factory preset?';
if (!window.confirm(msg)) return;
setSavingPreset(true);
try {
const res = await fetch(withBasePath('/api/admin/factory-preset'), { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Error (${res.status})`, 'error');
return;
}
showToast('Factory preset updated.');
await refreshFactoryPreset();
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally {
setSavingPreset(false);
}
};
const handleFactoryReset = async () => {
if (!window.confirm('FACTORY RESET — all current data will be replaced with the factory preset. Continue?')) return;
setFactoryResetting(true);
try {
const res = await fetch(withBasePath('/api/admin/factory-reset'), { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Error (${res.status})`, 'error');
return;
}
showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally {
setFactoryResetting(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";
return (
{/* Top Header */}
{/* Tab Navigation */}
{/* TAB: CARDS */}
{activeTab === 'cards' && (
Card Grid
{cards.length === 0 &&
No cards available. Create one to get started.
}
{cards.map((card, idx) => (
// CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
{(() => {
const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
if (!previewUrl) {
return
No Image
;
}
return isVideoUrl(previewUrl)
?
:
})
;
})()}
{card.title}
{card.cardType}
{card.extraMedia && card.extraMedia.length > 0 && (
[{card.extraMedia.length}]
)}
{card.cardType === 'FULLSCREEN_LOCK' && (
LOCK ACTIVE
)}
{/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
))}
)}
{/* TAB: SETTINGS */}
{activeTab === 'settings' && (
Global Portal Settings
setPortal({...portal, title: e.target.value})} className={inputClasses} />
setPortal({ ...portal, welcomeText: html })}
limit={PORTAL_LIMITS.welcomeText}
/>
{/* NEW: Max Columns Setting updated for 3 */}