'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 (

{len} / {limit} · {remaining < 0 ? `${Math.abs(remaining)} over limit` : `${remaining} remaining`}

); } 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 }>({}); // Hex input per il theme color: stato locale che resta libero durante la digitazione, // committa su portal.themeColor solo quando la stringa e' un hex completo valido. const [hexInput, setHexInput] = useState('#1e3a8a'); useEffect(() => { if (portal.themeColor) setHexInput(portal.themeColor); }, [portal.themeColor]); // 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); }; 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) => { 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(`Could not reach the server. Check your network connection and try again. Details: ${(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(`Could not reach the server. Check your network connection and try again. Details: ${(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, 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) => { 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(`Could not reach the server. Check your network connection and try again. Details: ${(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(`Could not reach the server. Check your network connection and try again. Details: ${(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(`Could not reach the server. Check your network connection and try again. Details: ${(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 */}

Captive Portal CMS

Local Administration

View Live Portal ↗
{/* 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) ?
{/* 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} />
setPortal({...portal, themeColor: e.target.value})} className="h-12 w-12 rounded cursor-pointer border-0 p-0" /> { const v = e.target.value; setHexInput(v); // Commit a portal.themeColor solo se la stringa e' un hex pieno e valido. if (/^#[0-9a-fA-F]{6}$/.test(v)) setPortal({ ...portal, themeColor: v.toLowerCase() }); }} onBlur={() => { // Se l'utente esce dal campo con un valore non valido, ripristina l'ultimo valore noto. if (!/^#[0-9a-fA-F]{6}$/.test(hexInput)) setHexInput(portal.themeColor || '#1e3a8a'); }} maxLength={7} spellCheck={false} placeholder="#RRGGBB" aria-label="Theme color hex" className={`font-mono text-sm px-3 py-2 rounded border outline-none focus:ring-2 focus:ring-blue-500 w-32 ${/^#[0-9a-fA-F]{6}$/.test(hexInput) ? 'border-gray-300 text-gray-900' : 'border-red-500 text-red-600'}`} />
{/* NEW: Max Columns Setting updated for 3 */}
setPortal({...portal, maxGridColumns: parseInt(e.target.value)})} className="w-full mt-3 accent-blue-600" />
345678