|
- '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, UPLOAD_LIMITS } 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 (
- <p className={`text-xs mt-1 text-right ${color}`}>
- {len} / {limit} · {remaining < 0 ? `${Math.abs(remaining)} over limit` : `${remaining} remaining`}
- </p>
- );
- }
-
- 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="Bold"
- >B</button>
- <button
- type="button"
- onClick={() => exec('italic')}
- className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
- title="Italic"
- >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,
- options,
- }: {
- value: T;
- onChange: (v: T) => void;
- options: { value: T; label: string; style?: React.CSSProperties }[];
- }) {
- const [open, setOpen] = useState(false);
- const ref = useRef<HTMLDivElement>(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 (
- <div ref={ref} className="relative">
- <button
- type="button"
- onClick={() => setOpen(o => !o)}
- className={`${inputBase} text-left flex items-center justify-between cursor-pointer`}
- >
- <span className={displayLabel ? '' : 'text-gray-400'} style={current?.style}>{displayLabel || 'Select…'}</span>
- <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
- </button>
- {open && (
- <div className="absolute left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-30 overflow-hidden">
- {options.map(o => (
- <button
- key={o.value}
- type="button"
- onClick={() => { onChange(o.value); setOpen(false); }}
- className={`w-full text-left px-3 py-2.5 hover:bg-blue-50 transition-colors ${o.value === value ? 'bg-blue-100 font-semibold text-blue-700' : 'text-gray-800'}`}
- style={o.style}
- >
- {o.label}
- </button>
- ))}
- </div>
- )}
- </div>
- );
- }
-
- 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<string, string>)[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<string | null> {
- 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<Blob | null> {
- 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<void>((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<void>((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<Blob | null>((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<MediaItem[]> {
- 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<Card[]>([]);
- const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
-
- // Portal State
- const [portal, setPortal] = useState<Partial<Portal>>({});
- 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<string[]>([]);
- // 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<Record<string, { jobId: string; status: string; progress: number }>>({});
-
- // 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);
- };
-
- const refreshFonts = async () => {
- try {
- const res = await fetch(withBasePath('/api/fonts'));
- if (res.ok) setAvailableFonts(await res.json());
- } catch { setAvailableFonts([]); }
- };
-
- useEffect(() => {
- fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards);
- fetch(withBasePath('/api/portals')).then(res => res.json()).then(data => data && setPortal(data));
- void refreshFonts();
- }, []);
-
- const [uploadingFont, setUploadingFont] = useState(false);
- const handleFontUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
- const file = e.target.files?.[0];
- e.target.value = '';
- if (!file) return;
- setUploadingFont(true);
- try {
- const fd = new FormData();
- fd.append('file', file);
- const res = await fetch(withBasePath('/api/admin/fonts'), { method: 'POST', body: fd });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- showToast(data?.error || `Upload error (${res.status})`, 'error');
- return;
- }
- showToast(`Font uploaded: ${data.name}`);
- await refreshFonts();
- // Auto-seleziona il font appena caricato
- if (data.name) setPortal(p => ({ ...p, fontFamily: data.name }));
- } catch (err) {
- showToast(`Network error: ${(err as Error).message}`, 'error');
- } finally {
- setUploadingFont(false);
- }
- };
-
- const handleFontDelete = async (name: string) => {
- if (!window.confirm(`Delete font "${name}"? Portals using this font will fall back to the system font.`)) return;
- try {
- const res = await fetch(withBasePath(`/api/admin/fonts?name=${encodeURIComponent(name)}`), { method: 'DELETE' });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- showToast(data?.error || `Delete error (${res.status})`, 'error');
- return;
- }
- showToast('Font deleted.');
- await refreshFonts();
- if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' }));
- } catch (err) {
- showToast(`Network error: ${(err as Error).message}`, 'error');
- }
- };
-
- // 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<HTMLInputElement>, 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]);
- // Il logo è l'unico upload che ammette SVG (sanitizzato lato server).
- const endpoint = field === 'logoUrl' ? '/api/upload?context=logo' : '/api/upload';
- const res = await fetch(withBasePath(endpoint), { 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<HTMLInputElement>) => {
- 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<HTMLInputElement>) => {
- 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 (
- <div className="min-h-screen bg-gray-50 font-sans pb-12">
- {/* Top Header */}
- <div className="bg-blue-900 text-white shadow-md py-6 px-4">
- <div className="max-w-5xl mx-auto flex justify-between items-center">
- <div>
- <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
- <p className="text-sm text-blue-200">Local Administration</p>
- </div>
- <a href={withBasePath('/')} target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
- View Live Portal ↗
- </a>
- </div>
- </div>
-
- <div className="max-w-5xl mx-auto mt-8 px-4">
- {/* Tab Navigation */}
- <div className="flex space-x-2 mb-6">
- <button onClick={() => setActiveTab('cards')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'cards' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
- Manage Cards
- </button>
- <button onClick={() => setActiveTab('settings')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'settings' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
- Portal Settings
- </button>
- </div>
-
- <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
-
- {/* TAB: CARDS */}
- {activeTab === 'cards' && (
- <div className="p-6 md:p-8">
- <div className="flex justify-between items-center mb-8 border-b pb-4">
- <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
- <button onClick={() => setIsEditing({ title: '', cardType: 'INFO_PAGE', displayOrder: cards.length })} className="bg-blue-600 text-white px-5 py-2.5 rounded-lg shadow-sm hover:bg-blue-700 font-medium">
- + Add New Card
- </button>
- </div>
-
- <div className="space-y-3 mb-8">
- {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
- {cards.map((card, idx) => (
- // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
- <div key={card.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors gap-4">
- <div className="flex items-center gap-4">
- {(() => {
- const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
- if (!previewUrl) {
- return <div className="w-16 h-16 bg-gray-200 rounded-md shadow-sm flex items-center justify-center text-gray-400 text-xs shrink-0">No Image</div>;
- }
- return isVideoUrl(previewUrl)
- ? <video src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
- : <img src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
- })()}
- <div>
- <span className="font-semibold text-gray-800 block">{card.title}</span>
- <span className="text-xs text-gray-500 uppercase tracking-wider">
- {card.cardType}
- {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 ACTIVE</span>
- )}
- </span>
- </div>
- </div>
-
- {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
- <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
- <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
- <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
- <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
- <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
- <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* TAB: SETTINGS */}
- {activeTab === 'settings' && (
- <div className="p-6 md:p-8">
- <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
- <div className="space-y-6">
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
- <input type="text" maxLength={PORTAL_LIMITS.title} value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
- <CharCounter value={portal.title} limit={PORTAL_LIMITS.title} />
- </div>
-
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
- <RichTextMini
- value={portal.welcomeText || ''}
- onChange={html => setPortal({ ...portal, welcomeText: html })}
- limit={PORTAL_LIMITS.welcomeText}
- />
- </div>
-
- <div className="flex gap-8">
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
- <div className="flex items-center gap-4">
- <input type="color" value={portal.themeColor || '#1e3a8a'} onChange={e => setPortal({...portal, themeColor: e.target.value})} className="h-12 w-12 rounded cursor-pointer border-0 p-0" />
- <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
- </div>
- </div>
-
- {/* NEW: Max Columns Setting updated for 3 */}
- <div className="flex-1">
- <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
- <input
- type="range"
- min="3"
- max="8"
- value={portal.maxGridColumns || 5}
- onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
- className="w-full mt-3 accent-blue-600"
- />
- <div className="flex justify-between text-xs text-gray-400 mt-1">
- <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
- </div>
- </div>
- </div>
-
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Portal font</label>
- <style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
- @font-face {
- font-family: '${previewFontFamily(f)}';
- src: url('${withBasePath('/api/fonts?name=' + encodeURIComponent(f))}') format('${fontFormatFromName(f)}');
- font-display: swap;
- }`).join('') }} />
- <StyledSelect<string>
- value={portal.fontFamily ?? ''}
- onChange={(v) => setPortal({ ...portal, fontFamily: v })}
- options={[
- { value: '', label: 'System (Arial)' },
- ...availableFonts.map(f => ({
- value: f,
- label: f.replace(/\.(woff2?|ttf|otf)$/i, ''),
- style: { fontFamily: `'${previewFontFamily(f)}', Arial, Helvetica, sans-serif` },
- })),
- ]}
- />
- <div className="flex items-center gap-3 flex-wrap mt-2">
- <label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
- <input
- type="file"
- accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
- onChange={handleFontUpload}
- disabled={uploadingFont}
- hidden
- />
- {uploadingFont ? 'Uploading…' : 'Upload font…'}
- </label>
- {portal.fontFamily && availableFonts.includes(portal.fontFamily) && (
- <button
- type="button"
- onClick={() => handleFontDelete(portal.fontFamily!)}
- className="text-xs text-red-600 hover:text-red-700 underline"
- title={`Delete font "${portal.fontFamily}"`}
- >
- Delete selected font
- </button>
- )}
- </div>
- <p className="text-xs text-gray-500 mt-1">Supported: <code>.woff2</code>, <code>.woff</code>, <code>.ttf</code>, <code>.otf</code> · max {(UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0)} MB</p>
- </div>
- </div>
-
- <div className="space-y-6">
- {/* Logo Upload with Remove Button */}
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
- <div className="flex items-center gap-3 flex-wrap">
- <label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
- <input type="file" accept="image/*,.svg,image/svg+xml" onChange={e => handleUpload(e, 'logoUrl', true)} hidden />
- Choose image…
- </label>
- {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
- </div>
- {portal.logoUrl && (
- <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
- <img src={withBasePath(portal.logoUrl)} className="h-16 object-contain" alt="Logo Preview" />
- <a href={withBasePath(portal.logoUrl)} download={extractFileName(portal.logoUrl)} className="absolute -top-2 right-6 bg-gray-700 hover:bg-gray-800 text-white w-6 h-6 rounded-full text-xs font-bold shadow flex items-center justify-center" title="Download" aria-label="Download logo">⬇</a>
- <button onClick={() => setPortal({...portal, logoUrl: ''})} className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full text-xs font-bold hover:bg-red-600 shadow">✕</button>
- </div>
- )}
- </div>
-
- {/* Hero Upload with Remove Button */}
- <div>
- <label className="block text-sm font-semibold text-gray-700 mb-1">Background Image</label>
- <div className="flex items-center gap-3 flex-wrap">
- <label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
- <input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} hidden />
- Choose image…
- </label>
- {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
- </div>
- {portal.heroImageUrl && (
- <div className="mt-2 relative rounded shadow border inline-block w-full">
- <img src={withBasePath(portal.heroImageUrl)} className="h-32 w-full object-cover rounded" alt="Background preview" />
- <a href={withBasePath(portal.heroImageUrl)} download={extractFileName(portal.heroImageUrl)} className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold shadow-lg" title="Download" aria-label="Download background">⬇</a>
- <button onClick={() => setPortal({...portal, heroImageUrl: ''})} className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold hover:bg-red-600 shadow-lg">✕</button>
- </div>
- )}
- </div>
-
- <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
- <label className="flex items-center gap-3 cursor-pointer">
- <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
- <div>
- <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
- <span className="block text-xs text-gray-600">Creates a smooth gradient from the top of the image into the solid theme color at the bottom.</span>
- </div>
- </label>
- <label className="flex items-center gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT}
- onChange={e => setPortal({...portal, externalLinkEnabled: e.target.checked})}
- className="w-5 h-5 text-blue-600 rounded"
- />
- <div>
- <span className="block text-sm font-semibold text-gray-900">Enable “External Link” type in the dropdown menu.</span>
- <span className="block text-xs text-gray-600">Existing cards of this type will still remain visible and clickable, even if you disable the “External Link” type.</span>
- </div>
- </label>
- </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">
- The backup contains cards, portal configuration, media (images, videos, PDFs), and uploaded fonts. Restoring overwrites the current state. Clicking the “Save backup (ZIP)” button saves the Cards structure as <code>interceptor-backup-YYYYMMDD-hhmmss.zip</code>.
- </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"
- >
- ⬇ Save 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 ? 'Restoring…' : '⤴ Restore from ZIP'}
- </label>
- </div>
- </div>
-
- <div className="mt-8 pt-6 border-t border-gray-200">
- <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3>
- <p className="text-xs text-gray-500 mb-2">
- “Factory” state restorable with one click. The preset (<code>factory/preset.zip</code>) is prepared on the development machine and distributed to MajorNet machines.
- </p>
- <p className="text-xs text-gray-700 mb-4">
- Current preset: {factoryPreset === null ? '…'
- : factoryPreset.exists
- ? <span className="text-green-700 font-medium">present · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('en-GB') : '?'}</span>
- : <span className="text-gray-400 italic">no preset configured</span>}
- </p>
- <div className="flex flex-wrap gap-3">
- <button
- type="button"
- onClick={handleFactoryReset}
- disabled={factoryResetting || !factoryPreset?.exists}
- title={factoryPreset?.exists ? undefined : 'No preset configured'}
- className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
- >
- {factoryResetting ? 'Reset in progress…' : 'Factory Reset'}
- </button>
- {FACTORY_PRESET_SAVE_ENABLED && (
- <button
- type="button"
- onClick={handleSaveFactoryPreset}
- disabled={savingPreset}
- title="Developer function: save the current state as a new factory preset"
- className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
- >
- {savingPreset ? 'Saving…' : 'Save as Factory Preset (dev)'}
- </button>
- )}
- </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'}
- </button>
- </div>
- </div>
- )}
-
- </div>
- </div>
-
- {/* MODAL FOR EDITING/CREATING CARDS */}
- {isEditing && (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
- onClick={() => setIsEditing(null)} // Click outside to close
- >
- <div
- className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl p-6 md:p-8 relative animate-in fade-in zoom-in-95 duration-200"
- onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
- >
- <button
- onClick={() => setIsEditing(null)}
- className="absolute top-6 right-6 text-gray-400 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center transition-colors"
- >
- ✕
- </button>
-
- <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
- {isEditing.id ? 'Edit Card' : 'Create New Card'}
- </h3>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
- <div className="space-y-5">
- <div>
- <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
- <input type="text" maxLength={CARD_LIMITS.title} value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" />
- <CharCounter value={isEditing.title} limit={CARD_LIMITS.title} />
- </div>
- <div>
- <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
- <StyledSelect<CardType>
- value={(isEditing.cardType || 'INFO_PAGE') as CardType}
- onChange={(v) => setIsEditing({ ...isEditing, cardType: v })}
- options={[
- { 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 === 'FULLSCREEN_LOCK' && (
- <div className="bg-red-50 border border-red-200 rounded-lg p-4">
- <p className="text-sm font-semibold text-red-800">⚠ Kiosk Lock Mode</p>
- <p className="text-xs text-red-700 mt-1">
- This card will take full control of the public portal. All other cards will be hidden until you remove this one.
- Upload an image or video as "Full-screen content" in the section on the right.
- </p>
- </div>
- )}
- {isEditing.cardType !== 'FULLSCREEN_LOCK' && (isEditing.cardType === 'EXTERNAL_LINK' ? (
- <>
- <div>
- <label className="block text-sm font-semibold text-gray-800 mb-1">URL <span className="text-red-600">*</span></label>
- <input
- type="url"
- maxLength={CARD_LIMITS.actionUrl}
- value={isEditing.actionUrl || ''}
- onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
- className={inputClasses}
- placeholder="https://example.com/page"
- />
- <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} />
- </div>
- <div>
- <label className="block text-sm font-semibold text-gray-800 mb-1">Link text</label>
- <input
- type="text"
- maxLength={CARD_LIMITS.shortDescription}
- value={isEditing.shortDescription || ''}
- onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
- className={inputClasses}
- placeholder="e.g. Visit the official site"
- />
- <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
- <p className="text-xs text-gray-500 mt-1">Text displayed as a clickable link in the modal. If empty, the URL itself is shown.</p>
- </div>
- <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
- <label className="flex items-start gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={!!isEditing.redirectOnClick}
- onChange={e => setIsEditing({ ...isEditing, redirectOnClick: e.target.checked })}
- className="w-5 h-5 text-blue-600 rounded mt-0.5"
- />
- <div>
- <span className="block text-sm font-semibold text-gray-900">Redirect on click</span>
- <span className="block text-xs text-gray-600">Cliccando la card sul portale pubblico, il browser apre direttamente l’URL senza mostrare il modale.</span>
- </div>
- </label>
- </div>
- </>
- ) : (
- <div>
- <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
- <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 !== '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
- type="checkbox"
- checked={!!isEditing.autoFullscreen}
- onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
- className="w-5 h-5 text-blue-600 rounded mt-0.5"
- />
- <div>
- <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
- <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
- </div>
- </label>
- <label className="flex items-start gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={!!isEditing.skipPreview}
- onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
- className="w-5 h-5 text-blue-600 rounded mt-0.5"
- />
- <div>
- <span className="block text-sm font-semibold text-gray-900">Cover not in the gallery</span>
- <span className="block text-xs text-gray-600">The cover stays as the card thumbnail only. Combine with “Auto fullscreen” to jump straight into the gallery items.</span>
- </div>
- </label>
- </div>
- )}
- </div>
- <div className="space-y-5">
- {/* 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">
- {isEditing.cardType === 'FULLSCREEN_LOCK'
- ? <>Full-screen content <span className="text-gray-400 font-normal text-xs">(image or 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">
- <label className="inline-block cursor-pointer bg-blue-50 hover:bg-blue-100 text-blue-700 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
- <input
- type="file"
- accept={isEditing.cardType === 'FULLSCREEN_LOCK' ? 'image/*,video/mp4,video/webm,.mp4,.webm,.mov,.m4v' : 'image/*'}
- onChange={e => handleUpload(e, 'imageUrl')}
- hidden
- />
- {isEditing.cardType === 'FULLSCREEN_LOCK' ? 'Choose image or video…' : 'Choose image…'}
- </label>
- {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">
- {isVideoUrl(isEditing.imageUrl) ? (
- <video src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" muted playsInline />
- ) : (
- <img src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" alt="Cover preview" />
- )}
- <a
- href={withBasePath(isEditing.imageUrl)}
- download={extractFileName(isEditing.imageUrl)}
- className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
- title="Download"
- aria-label="Download cover"
- >⬇</a>
- <button
- onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
- className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
- title="Remove cover image"
- >✕</button>
- </div>
- )}
- </div>
-
- {/* 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>
- </label>
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
- <label className="inline-block cursor-pointer bg-purple-50 hover:bg-purple-100 text-purple-700 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
- <input
- type="file"
- accept="image/*,video/*,application/pdf,.pdf,.mov,.qt,.mkv,.avi,.divx,.wmv,.asf,.flv,.f4v,.3gp,.3gpp,.3g2,.mts,.m2ts,.ts,.mpg,.mpeg,.vob,.mxf,.ogv,.ogg"
- multiple
- onChange={handleUploadExtraMedia}
- hidden
- />
- Choose files…
- </label>
- {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
- {pdfProgress && (
- <p className="mt-2 text-sm text-purple-600 font-medium">
- Processing “{pdfProgress.name}”: page {pdfProgress.page} of {pdfProgress.total}
- </p>
- )}
- </div>
-
- {(isEditing.extraMedia || []).length > 0 && (
- <div className="mt-3 space-y-2">
- {(isEditing.extraMedia || []).map((item, i) => {
- const video = isVideoUrl(item.url);
- const tc = transcodeJobs[item.url];
- const isTranscoding = !!tc && (tc.status === 'queued' || tc.status === 'running');
- return (
- <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
- <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
- {video ? (
- <>
- <video src={withBasePath(item.url)} className="w-full h-full object-cover" muted preload="metadata" />
- <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
- </>
- ) : (
- <img src={withBasePath(item.url)} className="w-full h-full object-cover" alt="" />
- )}
- {isTranscoding && (
- <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/75 text-white text-[10px] font-semibold leading-tight gap-0.5">
- <span>Transcoding</span>
- <span>{Math.round((tc.progress || 0) * 100)}%</span>
- </div>
- )}
- <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
- </div>
- <div className="flex-1 min-w-0">
- <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
- {video ? 'Video' : 'Image'}
- </div>
- {video && (
- <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- checked={!!item.autoplay}
- onChange={() => toggleAutoplay(i)}
- className="w-4 h-4 text-blue-600 rounded"
- />
- <span className="text-sm text-gray-700">Autoplay</span>
- </label>
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- checked={!!item.muted}
- onChange={() => toggleMuted(i)}
- className="w-4 h-4 text-blue-600 rounded"
- />
- <span className="text-sm text-gray-700">Muted</span>
- </label>
- </div>
- )}
- </div>
- <button
- onClick={() => moveExtraMedia(i, 'up')}
- className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded shrink-0 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:outline-none"
- title="Move up"
- aria-label="Move up"
- disabled={i === 0}
- >↑</button>
- <button
- onClick={() => moveExtraMedia(i, 'down')}
- className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded shrink-0 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:outline-none"
- title="Move down"
- aria-label="Move down"
- disabled={i === (isEditing.extraMedia || []).length - 1}
- >↓</button>
- <a
- href={withBasePath(item.url)}
- download={extractFileName(item.url)}
- className="bg-gray-500 hover:bg-gray-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0 flex items-center justify-center"
- title="Download"
- aria-label="Download"
- >⬇</a>
- <button
- onClick={() => removeExtraMedia(i)}
- className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
- title="Remove"
- >✕</button>
- </div>
- );
- })}
- </div>
- )}
- </div>
- )}
- </div>
- </div>
-
- <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
- <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
- Cancel
- </button>
- <button onClick={handleSaveCard} className="bg-green-600 text-white px-8 py-2.5 rounded-lg hover:bg-green-700 font-medium shadow-sm transition-colors">
- Save Card
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* CUSTOM CONFIRM DIALOG */}
- {confirmDialog && (
- <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
- <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
- <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
- <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
- <div className="flex justify-end gap-3">
- <button
- onClick={() => setConfirmDialog(null)}
- className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
- >
- Cancel
- </button>
- <button
- onClick={confirmDialog.onConfirm}
- className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* CUSTOM TOAST NOTIFICATION */}
- {toast && (
- <div className={`fixed bottom-6 right-6 z-[70] text-white px-6 py-4 rounded-lg shadow-2xl flex items-start gap-3 animate-in slide-in-from-bottom-5 fade-in duration-300 max-w-md ${
- toast.type === 'error' ? 'bg-red-700' : 'bg-gray-900'
- }`}>
- <div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-sm shrink-0 mt-0.5 ${
- toast.type === 'error' ? 'bg-white text-red-700' : 'bg-green-500 text-gray-900'
- }`}>
- {toast.type === 'error' ? '!' : '✓'}
- </div>
- <span className="font-medium leading-snug">{toast.message}</span>
- </div>
- )}
-
- </div>
- );
- }
|