Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

1469 Zeilen
74 KiB

  1. 'use client';
  2. import { useState, useEffect, useRef } from 'react';
  3. import { Card, Portal, MediaItem, CardType } from '@/types';
  4. import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_PRESET_SAVE_ENABLED, UPLOAD_LIMITS } from '@/lib/config';
  5. import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
  6. import { withBasePath } from '@/lib/url';
  7. type CharCounterProps = { value: string | undefined; limit: number };
  8. function CharCounter({ value, limit }: CharCounterProps) {
  9. const len = (value ?? '').length;
  10. const remaining = limit - len;
  11. const overflow = len > limit;
  12. const near = !overflow && len >= limit * 0.8;
  13. const color = overflow ? 'text-red-600 font-semibold' : near ? 'text-amber-600' : 'text-gray-400';
  14. return (
  15. <p className={`text-xs mt-1 text-right ${color}`}>
  16. {len} / {limit} · {remaining < 0 ? `${Math.abs(remaining)} over limit` : `${remaining} remaining`}
  17. </p>
  18. );
  19. }
  20. function stripTags(html: string): string {
  21. if (typeof window === 'undefined' || !html) return '';
  22. return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
  23. }
  24. type RichTextMiniProps = {
  25. value: string;
  26. onChange: (html: string) => void;
  27. limit: number;
  28. className?: string;
  29. };
  30. function RichTextMini({ value, onChange, limit, className }: RichTextMiniProps) {
  31. const ref = useRef<HTMLDivElement>(null);
  32. // Sync iniziale soltanto. Aggiornare innerHTML durante l'editing perderebbe la
  33. // posizione del cursore, quindi confidiamo che onInput tenga value e DOM allineati.
  34. useEffect(() => {
  35. if (ref.current && ref.current.innerHTML !== value) {
  36. ref.current.innerHTML = value || '';
  37. }
  38. // eslint-disable-next-line react-hooks/exhaustive-deps
  39. }, []);
  40. const exec = (cmd: 'bold' | 'italic') => {
  41. ref.current?.focus();
  42. document.execCommand(cmd);
  43. onChange(ref.current?.innerHTML || '');
  44. };
  45. return (
  46. <div>
  47. <div className="flex gap-1 mb-1">
  48. <button
  49. type="button"
  50. onClick={() => exec('bold')}
  51. className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
  52. title="Bold"
  53. >B</button>
  54. <button
  55. type="button"
  56. onClick={() => exec('italic')}
  57. className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
  58. title="Italic"
  59. >I</button>
  60. </div>
  61. <div
  62. ref={ref}
  63. contentEditable
  64. suppressContentEditableWarning
  65. onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
  66. 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'}
  67. />
  68. <CharCounter value={stripTags(value)} limit={limit} />
  69. </div>
  70. );
  71. }
  72. function StyledSelect<T extends string>({
  73. value,
  74. onChange,
  75. options,
  76. }: {
  77. value: T;
  78. onChange: (v: T) => void;
  79. options: { value: T; label: string; style?: React.CSSProperties }[];
  80. }) {
  81. const [open, setOpen] = useState(false);
  82. const ref = useRef<HTMLDivElement>(null);
  83. useEffect(() => {
  84. if (!open) return;
  85. const onClick = (e: MouseEvent) => {
  86. if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
  87. };
  88. const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
  89. document.addEventListener('mousedown', onClick);
  90. document.addEventListener('keydown', onKey);
  91. return () => {
  92. document.removeEventListener('mousedown', onClick);
  93. document.removeEventListener('keydown', onKey);
  94. };
  95. }, [open]);
  96. const current = options.find(o => o.value === value);
  97. // Fallback: se il value non matcha nessuna opzione (es. tipo disattivato dalla flag), mostra il valore raw prettificato
  98. const displayLabel = current?.label
  99. ?? (typeof value === 'string' && value.length > 0
  100. ? value.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
  101. : '');
  102. 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";
  103. return (
  104. <div ref={ref} className="relative">
  105. <button
  106. type="button"
  107. onClick={() => setOpen(o => !o)}
  108. className={`${inputBase} text-left flex items-center justify-between cursor-pointer`}
  109. >
  110. <span className={displayLabel ? '' : 'text-gray-400'} style={current?.style}>{displayLabel || 'Select…'}</span>
  111. <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
  112. </button>
  113. {open && (
  114. <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">
  115. {options.map(o => (
  116. <button
  117. key={o.value}
  118. type="button"
  119. onClick={() => { onChange(o.value); setOpen(false); }}
  120. 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'}`}
  121. style={o.style}
  122. >
  123. {o.label}
  124. </button>
  125. ))}
  126. </div>
  127. )}
  128. </div>
  129. );
  130. }
  131. 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';
  132. // Sottoinsieme di formati video davvero riproducibili dai browser moderni
  133. const PLAYBACK_SUPPORTED_VIDEO = 'mp4|m4v|webm|mov|qt|ogv|ogg';
  134. const PLAYBACK_SUPPORTED_LABEL = 'MP4, M4V, WebM, MOV, OGV';
  135. const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
  136. const isPdfFile = (file: File) =>
  137. file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
  138. const isVideoFile = (file: File) =>
  139. file.type.startsWith('video/') || new RegExp(`\\.(${VIDEO_EXTENSIONS})$`, 'i').test(file.name);
  140. const isPlayableVideoFile = (file: File) =>
  141. new RegExp(`\\.(${PLAYBACK_SUPPORTED_VIDEO})$`, 'i').test(file.name);
  142. const previewFontFamily = (filename: string): string =>
  143. `PortalPreview-${filename.replace(/[^A-Za-z0-9]/g, '_')}`;
  144. const fontFormatFromName = (filename: string): string => {
  145. const ext = filename.match(/\.([^.]+)$/)?.[1].toLowerCase() ?? 'woff2';
  146. return ({ woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' } as Record<string, string>)[ext] ?? 'woff2';
  147. };
  148. const extractFileName = (url: string): string => {
  149. const match = url.match(/[?&]name=([^&]+)/);
  150. if (match) return decodeURIComponent(match[1]);
  151. const seg = url.split('/').pop() || 'download';
  152. return seg.split('?')[0];
  153. };
  154. async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> {
  155. const formData = new FormData();
  156. formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
  157. const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData });
  158. // Se il server rifiuta (4xx/5xx) propaga il messaggio specifico, così il chiamante
  159. // (pdfToImageItems / handleUploadExtraMedia) vede la causa invece di un null muto.
  160. if (!res.ok) {
  161. let serverMsg = `HTTP ${res.status}`;
  162. try {
  163. const errBody = await res.json();
  164. if (errBody?.error) serverMsg = errBody.error;
  165. } catch { /* response non era JSON */ }
  166. throw new Error(`/api/upload rejected "${name}": ${serverMsg}`);
  167. }
  168. const data = await res.json();
  169. return data.url || null;
  170. }
  171. async function extractVideoFrame(file: File): Promise<Blob | null> {
  172. const url = URL.createObjectURL(file);
  173. try {
  174. const video = document.createElement('video');
  175. video.muted = true;
  176. video.playsInline = true;
  177. video.preload = 'metadata';
  178. video.src = url;
  179. await new Promise<void>((resolve, reject) => {
  180. video.addEventListener('loadedmetadata', () => resolve(), { once: true });
  181. video.addEventListener('error', () => reject(new Error('video load error')), { once: true });
  182. });
  183. // Seek slightly past 0 — at exactly 0 some codecs return a black frame
  184. video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10));
  185. await new Promise<void>((resolve, reject) => {
  186. video.addEventListener('seeked', () => resolve(), { once: true });
  187. video.addEventListener('error', () => reject(new Error('video seek error')), { once: true });
  188. });
  189. const canvas = document.createElement('canvas');
  190. canvas.width = video.videoWidth;
  191. canvas.height = video.videoHeight;
  192. const ctx = canvas.getContext('2d');
  193. if (!ctx) return null;
  194. ctx.drawImage(video, 0, 0);
  195. return await new Promise<Blob | null>((resolve) =>
  196. canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85)
  197. );
  198. } finally {
  199. URL.revokeObjectURL(url);
  200. }
  201. }
  202. async function pdfToImageItems(
  203. file: File,
  204. onProgress: (page: number, total: number) => void
  205. ): Promise<MediaItem[]> {
  206. // Log step-by-step nella console del browser per poter diagnosticare un fallimento.
  207. // Apri DevTools → Console e filtra per "[pdf]" per vedere ogni passo.
  208. const log = (msg: string, extra?: unknown) => {
  209. if (extra !== undefined) console.info(`[pdf] ${msg}`, extra);
  210. else console.info(`[pdf] ${msg}`);
  211. };
  212. log(`start: file="${file.name}", size=${(file.size / 1024).toFixed(1)} KB, type="${file.type}"`);
  213. let pdfjs;
  214. try {
  215. pdfjs = await import('pdfjs-dist');
  216. log(`pdfjs-dist imported`);
  217. } catch (err) {
  218. throw new Error(`Could not load the PDF library (pdfjs-dist). ${(err as Error).message}`);
  219. }
  220. // Worker file is copied to /public via the postinstall script. Must include the
  221. // basePath so the browser can find it when the app is mounted under /cards.
  222. const workerUrl = withBasePath('/pdf.worker.min.mjs');
  223. pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
  224. log(`worker set to ${workerUrl}`);
  225. const arrayBuffer = await file.arrayBuffer();
  226. log(`file loaded into memory (${arrayBuffer.byteLength} bytes)`);
  227. let pdf;
  228. try {
  229. pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
  230. } catch (err) {
  231. // getDocument fallisce per: worker non raggiungibile (404), PDF cifrato, PDF malformato.
  232. throw new Error(`Could not open the PDF (pdfjs getDocument failed): ${(err as Error).message}. The PDF may be encrypted, corrupted, or the pdf.worker file may not be reachable at ${workerUrl}.`);
  233. }
  234. log(`PDF opened: ${pdf.numPages} pages`);
  235. const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  236. const items: MediaItem[] = [];
  237. for (let i = 1; i <= pdf.numPages; i++) {
  238. log(`page ${i}/${pdf.numPages}: rendering...`);
  239. onProgress(i, pdf.numPages);
  240. try {
  241. const page = await pdf.getPage(i);
  242. const viewport = page.getViewport({ scale: 1.5 });
  243. const canvas = document.createElement('canvas');
  244. canvas.width = viewport.width;
  245. canvas.height = viewport.height;
  246. const ctx = canvas.getContext('2d');
  247. if (!ctx) {
  248. log(`page ${i}: skipped — could not get 2D canvas context`);
  249. continue;
  250. }
  251. await page.render({ canvasContext: ctx, viewport }).promise;
  252. log(`page ${i}: rendered (${viewport.width}x${viewport.height})`);
  253. const blob: Blob = await new Promise((resolve, reject) => {
  254. canvas.toBlob(b => b ? resolve(b) : reject(new Error('canvas.toBlob returned null (PNG encoding failed)')), 'image/png');
  255. });
  256. log(`page ${i}: encoded to PNG (${(blob.size / 1024).toFixed(1)} KB)`);
  257. const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
  258. if (url) {
  259. log(`page ${i}: uploaded → ${url}`);
  260. items.push({ url });
  261. } else {
  262. log(`page ${i}: upload returned no URL (server response had no .url field — check the /api/upload network response)`);
  263. }
  264. } catch (err) {
  265. // Propaga ma con contesto sulla pagina specifica così l'utente vede DOVE.
  266. throw new Error(`Failed on page ${i}: ${(err as Error).message}`);
  267. }
  268. }
  269. log(`finished: ${items.length} pages uploaded successfully`);
  270. return items;
  271. }
  272. export default function AdminDashboard() {
  273. const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
  274. // Card State
  275. const [cards, setCards] = useState<Card[]>([]);
  276. const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
  277. // Portal State
  278. const [portal, setPortal] = useState<Partial<Portal>>({});
  279. const [savingPortal, setSavingPortal] = useState(false);
  280. const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
  281. // Hex input per il theme color: stato locale che resta libero durante la digitazione,
  282. // committa su portal.themeColor solo quando la stringa e' un hex completo valido.
  283. const [hexInput, setHexInput] = useState<string>('#1e3a8a');
  284. useEffect(() => {
  285. if (portal.themeColor) setHexInput(portal.themeColor);
  286. }, [portal.themeColor]);
  287. // NEW UI STATES: Toast and Confirm Dialog
  288. const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
  289. const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
  290. const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null);
  291. const [availableFonts, setAvailableFonts] = useState<string[]>([]);
  292. // Map: expected URL of the future-transcoded file → job state.
  293. // We key by URL (not jobId) so the rendering layer can look it up cheaply.
  294. const [transcodeJobs, setTranscodeJobs] = useState<Record<string, { jobId: string; status: string; progress: number }>>({});
  295. // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config.
  296. const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT;
  297. // Helper to show auto-dismissing toast
  298. const showToast = (message: string, type: 'success' | 'error' = 'success') => {
  299. setToast({ message, type });
  300. setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000);
  301. };
  302. const refreshFonts = async () => {
  303. try {
  304. const res = await fetch(withBasePath('/api/fonts'));
  305. if (res.ok) setAvailableFonts(await res.json());
  306. } catch { setAvailableFonts([]); }
  307. };
  308. useEffect(() => {
  309. fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards);
  310. fetch(withBasePath('/api/portals')).then(res => res.json()).then(data => data && setPortal(data));
  311. void refreshFonts();
  312. }, []);
  313. const [uploadingFont, setUploadingFont] = useState(false);
  314. const handleFontUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  315. const file = e.target.files?.[0];
  316. e.target.value = '';
  317. if (!file) return;
  318. setUploadingFont(true);
  319. try {
  320. const fd = new FormData();
  321. fd.append('file', file);
  322. const res = await fetch(withBasePath('/api/admin/fonts'), { method: 'POST', body: fd });
  323. const data = await res.json().catch(() => ({}));
  324. if (!res.ok) {
  325. showToast(data?.error || `Upload error (${res.status})`, 'error');
  326. return;
  327. }
  328. showToast(`Font uploaded: ${data.name}`);
  329. await refreshFonts();
  330. // Auto-seleziona il font appena caricato
  331. if (data.name) setPortal(p => ({ ...p, fontFamily: data.name }));
  332. } catch (err) {
  333. showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
  334. } finally {
  335. setUploadingFont(false);
  336. }
  337. };
  338. const handleFontDelete = async (name: string) => {
  339. if (!window.confirm(`Delete font "${name}"? Portals using this font will fall back to the system font.`)) return;
  340. try {
  341. const res = await fetch(withBasePath(`/api/admin/fonts?name=${encodeURIComponent(name)}`), { method: 'DELETE' });
  342. const data = await res.json().catch(() => ({}));
  343. if (!res.ok) {
  344. showToast(data?.error || `Delete error (${res.status})`, 'error');
  345. return;
  346. }
  347. showToast('Font deleted.');
  348. await refreshFonts();
  349. if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' }));
  350. } catch (err) {
  351. showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
  352. }
  353. };
  354. // Poll pending transcode jobs every 2s. On 'done' we drop the entry from the
  355. // map; on 'failed' we additionally pull the media URL out of the editor so the
  356. // admin doesn't try to save a broken reference.
  357. useEffect(() => {
  358. const pendingEntries = Object.entries(transcodeJobs).filter(
  359. ([, j]) => j.status === 'queued' || j.status === 'running'
  360. );
  361. if (pendingEntries.length === 0) return;
  362. let cancelled = false;
  363. const tick = async () => {
  364. for (const [url, j] of pendingEntries) {
  365. if (cancelled) return;
  366. try {
  367. const res = await fetch(withBasePath(`/api/transcode/${j.jobId}`));
  368. if (!res.ok) continue;
  369. const data = await res.json();
  370. if (cancelled) return;
  371. if (data.status === 'done') {
  372. setTranscodeJobs(prev => {
  373. const next = { ...prev };
  374. delete next[url];
  375. return next;
  376. });
  377. } else if (data.status === 'failed' || data.status === 'cancelled') {
  378. setTranscodeJobs(prev => {
  379. const next = { ...prev };
  380. delete next[url];
  381. return next;
  382. });
  383. setIsEditing(prev => prev ? {
  384. ...prev,
  385. extraMedia: (prev.extraMedia || []).filter(m => m.url !== url),
  386. imageUrl: prev.imageUrl === url ? '' : prev.imageUrl,
  387. } : prev);
  388. const msg = data.status === 'failed'
  389. ? `Transcoding failed${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}`
  390. : 'Trascodifica annullata';
  391. showToast(msg, 'error');
  392. } else {
  393. setTranscodeJobs(prev => prev[url] ? ({ ...prev, [url]: { ...prev[url], status: data.status, progress: data.progress ?? 0 } }) : prev);
  394. }
  395. } catch {
  396. // ignore network glitches; will retry next tick
  397. }
  398. }
  399. };
  400. void tick();
  401. const id = window.setInterval(() => { void tick(); }, 2000);
  402. return () => { cancelled = true; window.clearInterval(id); };
  403. // eslint-disable-next-line react-hooks/exhaustive-deps
  404. }, [Object.keys(transcodeJobs).join('|')]);
  405. const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
  406. if (!e.target.files?.[0]) return;
  407. setUploading(prev => ({ ...prev, [field]: true }));
  408. const formData = new FormData();
  409. formData.append('file', e.target.files[0]);
  410. // Il logo è l'unico upload che ammette SVG (sanitizzato lato server).
  411. const endpoint = field === 'logoUrl' ? '/api/upload?context=logo' : '/api/upload';
  412. const res = await fetch(withBasePath(endpoint), { method: 'POST', body: formData });
  413. const data = await res.json();
  414. if (data.url) {
  415. if (isPortal) {
  416. setPortal(prev => ({ ...prev, [field]: data.url }));
  417. } else {
  418. setIsEditing(prev => ({ ...prev, [field]: data.url }));
  419. }
  420. }
  421. setUploading(prev => ({ ...prev, [field]: false }));
  422. };
  423. const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
  424. const files = e.target.files;
  425. if (!files || files.length === 0) return;
  426. setUploading(prev => ({ ...prev, extraMedia: true }));
  427. const startedWithoutCover = !isEditing?.imageUrl;
  428. let pendingCover: string | null = null;
  429. const canPromote = () => startedWithoutCover && !pendingCover;
  430. // Pre-filtro: scarta video con formati non riproducibili nei browser
  431. const rejected: string[] = [];
  432. const acceptedFiles: File[] = [];
  433. for (const file of Array.from(files)) {
  434. if (isVideoFile(file) && !isPlayableVideoFile(file)) {
  435. rejected.push(file.name);
  436. } else {
  437. acceptedFiles.push(file);
  438. }
  439. }
  440. if (rejected.length > 0) {
  441. const list = rejected.length <= 3
  442. ? rejected.join(', ')
  443. : `${rejected.slice(0, 3).join(', ')} and ${rejected.length - 3} more`;
  444. showToast(
  445. `Unsupported format! Supported formats: ${PLAYBACK_SUPPORTED_LABEL}. Skipped files: ${list}`,
  446. 'error'
  447. );
  448. }
  449. if (acceptedFiles.length === 0) {
  450. setUploading(prev => ({ ...prev, extraMedia: false }));
  451. e.target.value = '';
  452. return;
  453. }
  454. const uploaded: MediaItem[] = [];
  455. for (const file of acceptedFiles) {
  456. try {
  457. if (isPdfFile(file)) {
  458. const items = await pdfToImageItems(file, (page, total) =>
  459. setPdfProgress({ name: file.name, page, total })
  460. );
  461. setPdfProgress(null);
  462. if (items.length > 0 && canPromote()) {
  463. // Promote the first PDF page to cover; skip it from the gallery to avoid duplication.
  464. pendingCover = items[0].url;
  465. uploaded.push(...items.slice(1));
  466. } else {
  467. uploaded.push(...items);
  468. }
  469. } else {
  470. const formData = new FormData();
  471. formData.append('file', file);
  472. const res = await fetch(withBasePath('/api/upload'), { method: 'POST', body: formData });
  473. const data = await res.json();
  474. if (!data.url) continue;
  475. if (data?.transcoding?.jobId) {
  476. const { jobId, status } = data.transcoding;
  477. setTranscodeJobs(prev => ({ ...prev, [data.url]: { jobId, status, progress: 0 } }));
  478. }
  479. if (isVideoFile(file)) {
  480. // Video always goes to the gallery so users can play it.
  481. uploaded.push({ url: data.url });
  482. // If no cover yet, extract the first frame and use it as the cover.
  483. if (canPromote()) {
  484. try {
  485. const blob = await extractVideoFrame(file);
  486. if (blob) {
  487. const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  488. const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`);
  489. if (posterUrl) pendingCover = posterUrl;
  490. }
  491. } catch (err) {
  492. console.warn('Could not extract video poster for', file.name, err);
  493. }
  494. }
  495. } else {
  496. // Plain image
  497. if (canPromote()) {
  498. // Promote to cover; skip the gallery to avoid duplication.
  499. pendingCover = data.url;
  500. } else {
  501. uploaded.push({ url: data.url });
  502. }
  503. }
  504. }
  505. } catch (err) {
  506. // Dump completo nella console (stack incluso) per il debug step-by-step
  507. // visibile in DevTools → Console. L'utente vede invece la causa nel toast.
  508. const e = err as Error;
  509. console.error(`[upload] Failed to process "${file.name}"`);
  510. console.error(`[upload] Message: ${e?.message ?? '(no message)'}`);
  511. console.error(`[upload] Stack:`, e?.stack ?? '(no stack)');
  512. console.error(`[upload] Raw error object:`, err);
  513. const reason = e?.message || 'unknown error';
  514. showToast(
  515. `Failed to process "${file.name}": ${reason}. Open DevTools (F12) → Console and filter by "[pdf]" or "[upload]" to see step-by-step diagnostics.`,
  516. 'error',
  517. );
  518. setPdfProgress(null);
  519. }
  520. }
  521. setIsEditing(prev => ({
  522. ...prev,
  523. imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''),
  524. extraMedia: [...(prev?.extraMedia || []), ...uploaded],
  525. }));
  526. setUploading(prev => ({ ...prev, extraMedia: false }));
  527. e.target.value = '';
  528. };
  529. const removeExtraMedia = (index: number) => {
  530. setIsEditing(prev => ({
  531. ...prev,
  532. extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
  533. }));
  534. };
  535. const moveExtraMedia = (index: number, direction: 'up' | 'down') => {
  536. setIsEditing(prev => {
  537. const items = [...(prev?.extraMedia || [])];
  538. if (direction === 'up' && index > 0) {
  539. [items[index - 1], items[index]] = [items[index], items[index - 1]];
  540. } else if (direction === 'down' && index < items.length - 1) {
  541. [items[index + 1], items[index]] = [items[index], items[index + 1]];
  542. } else {
  543. return prev;
  544. }
  545. return { ...prev, extraMedia: items };
  546. });
  547. };
  548. const toggleAutoplay = (index: number) => {
  549. setIsEditing(prev => ({
  550. ...prev,
  551. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  552. i === index ? { ...m, autoplay: !m.autoplay } : m
  553. ),
  554. }));
  555. };
  556. const toggleMuted = (index: number) => {
  557. setIsEditing(prev => ({
  558. ...prev,
  559. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  560. i === index ? { ...m, muted: !m.muted } : m
  561. ),
  562. }));
  563. };
  564. const handleSaveCard = async () => {
  565. if (!isEditing) return;
  566. // External Link: URL obbligatorio (feedback immediato, ribadito anche lato server)
  567. if (isEditing.cardType === 'EXTERNAL_LINK' && !isEditing.actionUrl?.trim()) {
  568. showToast('URL is required for External Link cards', 'error');
  569. return;
  570. }
  571. const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
  572. const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
  573. const res = await fetch(withBasePath('/api/cards'), {
  574. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
  575. });
  576. if (!res.ok) {
  577. let message = 'Save error';
  578. try {
  579. const body = await res.json();
  580. if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) {
  581. const first = body.errors[0];
  582. message = first.limit != null
  583. ? `${first.field}: ${first.message} (${first.actual} / ${first.limit})`
  584. : `${first.field}: ${first.message}`;
  585. } else if (body?.error) {
  586. message = body.error;
  587. }
  588. } catch {}
  589. showToast(message, 'error');
  590. return; // keep the editor open so the admin can fix
  591. }
  592. setCards(prev => {
  593. const exists = prev.find(c => c.id === newCard.id);
  594. return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
  595. });
  596. setIsEditing(null);
  597. };
  598. const handleDeleteCard = (id: string) => {
  599. // Replace window.confirm with our custom dialog
  600. setConfirmDialog({
  601. message: 'Are you sure you want to delete this card? This action cannot be undone.',
  602. onConfirm: async () => {
  603. await fetch(withBasePath(`/api/cards?id=${id}`), { method: 'DELETE' });
  604. setCards(prev => prev.filter(c => c.id !== id));
  605. setConfirmDialog(null);
  606. showToast('Card successfully deleted.');
  607. }
  608. });
  609. };
  610. const moveCard = async (index: number, direction: 'up' | 'down') => {
  611. const newCards = [...cards];
  612. if (direction === 'up' && index > 0) {
  613. [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
  614. } else if (direction === 'down' && index < newCards.length - 1) {
  615. [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
  616. } else {
  617. return; // Do nothing if trying to move out of bounds
  618. }
  619. // Recalculate displayOrder for the whole array
  620. const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
  621. // Optimistically update the UI
  622. setCards(updatedCards);
  623. // Persist the new order to the backend
  624. await fetch(withBasePath('/api/cards'), {
  625. method: 'PUT',
  626. headers: { 'Content-Type': 'application/json' },
  627. body: JSON.stringify(updatedCards)
  628. });
  629. };
  630. const handleSavePortal = async () => {
  631. setSavingPortal(true);
  632. await fetch(withBasePath('/api/portals'), {
  633. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
  634. });
  635. setSavingPortal(false);
  636. showToast('Portal settings saved successfully!'); // Replaced window.alert
  637. };
  638. const handleBackupDownload = () => {
  639. window.location.href = withBasePath('/api/admin/backup');
  640. };
  641. const [restoring, setRestoring] = useState(false);
  642. const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  643. const file = e.target.files?.[0];
  644. e.target.value = '';
  645. if (!file) return;
  646. if (!window.confirm('Restore will overwrite all current data (cards, portal, media, fonts). Continue?')) return;
  647. setRestoring(true);
  648. try {
  649. const fd = new FormData();
  650. fd.append('file', file);
  651. const res = await fetch(withBasePath('/api/admin/restore'), { method: 'POST', body: fd });
  652. const data = await res.json().catch(() => ({}));
  653. if (!res.ok) {
  654. showToast(data?.error || `Restore error (${res.status})`, 'error');
  655. return;
  656. }
  657. showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
  658. setTimeout(() => window.location.reload(), 1200);
  659. } catch (err) {
  660. showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
  661. } finally {
  662. setRestoring(false);
  663. }
  664. };
  665. // Factory preset: la sezione è sempre visibile (per chi accede a /admin); solo
  666. // il bottone "Salva come preset" è gated da FACTORY_PRESET_SAVE_ENABLED.
  667. const [factoryPreset, setFactoryPreset] = useState<{ exists: boolean; sizeBytes?: number; modifiedAt?: string } | null>(null);
  668. const [savingPreset, setSavingPreset] = useState(false);
  669. const [factoryResetting, setFactoryResetting] = useState(false);
  670. const refreshFactoryPreset = async () => {
  671. try {
  672. const res = await fetch(withBasePath('/api/admin/factory-preset'));
  673. if (res.ok) setFactoryPreset(await res.json());
  674. } catch { /* ignore */ }
  675. };
  676. useEffect(() => {
  677. void refreshFactoryPreset();
  678. }, []);
  679. const handleSaveFactoryPreset = async () => {
  680. const msg = factoryPreset?.exists
  681. ? 'Overwrite the existing factory preset with the current state?'
  682. : 'Save the current state as factory preset?';
  683. if (!window.confirm(msg)) return;
  684. setSavingPreset(true);
  685. try {
  686. const res = await fetch(withBasePath('/api/admin/factory-preset'), { method: 'POST' });
  687. const data = await res.json().catch(() => ({}));
  688. if (!res.ok) {
  689. showToast(data?.error || `Error (${res.status})`, 'error');
  690. return;
  691. }
  692. showToast('Factory preset updated.');
  693. await refreshFactoryPreset();
  694. } catch (err) {
  695. showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
  696. } finally {
  697. setSavingPreset(false);
  698. }
  699. };
  700. const handleFactoryReset = async () => {
  701. if (!window.confirm('FACTORY RESET — all current data will be replaced with the factory preset. Continue?')) return;
  702. setFactoryResetting(true);
  703. try {
  704. const res = await fetch(withBasePath('/api/admin/factory-reset'), { method: 'POST' });
  705. const data = await res.json().catch(() => ({}));
  706. if (!res.ok) {
  707. showToast(data?.error || `Error (${res.status})`, 'error');
  708. return;
  709. }
  710. showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
  711. setTimeout(() => window.location.reload(), 1200);
  712. } catch (err) {
  713. showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
  714. } finally {
  715. setFactoryResetting(false);
  716. }
  717. };
  718. // Shared Input Classes for high contrast
  719. 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";
  720. return (
  721. <div className="min-h-screen bg-gray-50 font-sans pb-12">
  722. {/* Top Header */}
  723. <div className="bg-blue-900 text-white shadow-md py-6 px-4">
  724. <div className="max-w-5xl mx-auto flex justify-between items-center">
  725. <div>
  726. <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
  727. <p className="text-sm text-blue-200">Local Administration</p>
  728. </div>
  729. <a href={withBasePath('/')} target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
  730. View Live Portal ↗
  731. </a>
  732. </div>
  733. </div>
  734. <div className="max-w-5xl mx-auto mt-8 px-4">
  735. {/* Tab Navigation */}
  736. <div className="flex space-x-2 mb-6">
  737. <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'}`}>
  738. Manage Cards
  739. </button>
  740. <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'}`}>
  741. Portal Settings
  742. </button>
  743. </div>
  744. <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
  745. {/* TAB: CARDS */}
  746. {activeTab === 'cards' && (
  747. <div className="p-6 md:p-8">
  748. <div className="flex justify-between items-center mb-8 border-b pb-4">
  749. <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
  750. <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">
  751. + Add New Card
  752. </button>
  753. </div>
  754. <div className="space-y-3 mb-8">
  755. {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
  756. {cards.map((card, idx) => (
  757. // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
  758. <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">
  759. <div className="flex items-center gap-4">
  760. {(() => {
  761. const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
  762. if (!previewUrl) {
  763. 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>;
  764. }
  765. return isVideoUrl(previewUrl)
  766. ? <video src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
  767. : <img src={withBasePath(previewUrl)} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
  768. })()}
  769. <div>
  770. <span className="font-semibold text-gray-800 block">{card.title}</span>
  771. <span className="text-xs text-gray-500 uppercase tracking-wider">
  772. {card.cardType}
  773. {card.extraMedia && card.extraMedia.length > 0 && (
  774. <span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span>
  775. )}
  776. {card.cardType === 'FULLSCREEN_LOCK' && (
  777. <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>
  778. )}
  779. </span>
  780. </div>
  781. </div>
  782. {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
  783. <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
  784. <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
  785. <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
  786. <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
  787. <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
  788. <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
  789. </div>
  790. </div>
  791. ))}
  792. </div>
  793. </div>
  794. )}
  795. {/* TAB: SETTINGS */}
  796. {activeTab === 'settings' && (
  797. <div className="p-6 md:p-8">
  798. <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
  799. <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
  800. <div className="space-y-6">
  801. <div>
  802. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
  803. <input type="text" maxLength={PORTAL_LIMITS.title} value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
  804. <CharCounter value={portal.title} limit={PORTAL_LIMITS.title} />
  805. </div>
  806. <div>
  807. <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
  808. <RichTextMini
  809. value={portal.welcomeText || ''}
  810. onChange={html => setPortal({ ...portal, welcomeText: html })}
  811. limit={PORTAL_LIMITS.welcomeText}
  812. />
  813. </div>
  814. <div className="flex gap-8">
  815. <div>
  816. <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
  817. <div className="flex items-center gap-4">
  818. <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" />
  819. <input
  820. type="text"
  821. value={hexInput}
  822. onChange={e => {
  823. const v = e.target.value;
  824. setHexInput(v);
  825. // Commit a portal.themeColor solo se la stringa e' un hex pieno e valido.
  826. if (/^#[0-9a-fA-F]{6}$/.test(v)) setPortal({ ...portal, themeColor: v.toLowerCase() });
  827. }}
  828. onBlur={() => {
  829. // Se l'utente esce dal campo con un valore non valido, ripristina l'ultimo valore noto.
  830. if (!/^#[0-9a-fA-F]{6}$/.test(hexInput)) setHexInput(portal.themeColor || '#1e3a8a');
  831. }}
  832. maxLength={7}
  833. spellCheck={false}
  834. placeholder="#RRGGBB"
  835. aria-label="Theme color hex"
  836. 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'}`}
  837. />
  838. </div>
  839. </div>
  840. {/* NEW: Max Columns Setting updated for 3 */}
  841. <div className="flex-1">
  842. <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
  843. <input
  844. type="range"
  845. min="3"
  846. max="8"
  847. value={portal.maxGridColumns || 5}
  848. onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
  849. className="w-full mt-3 accent-blue-600"
  850. />
  851. <div className="flex justify-between text-xs text-gray-400 mt-1">
  852. <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
  853. </div>
  854. </div>
  855. </div>
  856. <div>
  857. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal font</label>
  858. <style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
  859. @font-face {
  860. font-family: '${previewFontFamily(f)}';
  861. src: url('${withBasePath('/api/fonts?name=' + encodeURIComponent(f))}') format('${fontFormatFromName(f)}');
  862. font-display: swap;
  863. }`).join('') }} />
  864. <StyledSelect<string>
  865. value={portal.fontFamily ?? ''}
  866. onChange={(v) => setPortal({ ...portal, fontFamily: v })}
  867. options={[
  868. { value: '', label: 'System (Arial)' },
  869. ...availableFonts.map(f => ({
  870. value: f,
  871. label: f.replace(/\.(woff2?|ttf|otf)$/i, ''),
  872. style: { fontFamily: `'${previewFontFamily(f)}', Arial, Helvetica, sans-serif` },
  873. })),
  874. ]}
  875. />
  876. <div className="flex items-center gap-3 flex-wrap mt-2">
  877. <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">
  878. <input
  879. type="file"
  880. accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
  881. onChange={handleFontUpload}
  882. disabled={uploadingFont}
  883. hidden
  884. />
  885. {uploadingFont ? 'Uploading…' : 'Upload font…'}
  886. </label>
  887. {portal.fontFamily && availableFonts.includes(portal.fontFamily) && (
  888. <button
  889. type="button"
  890. onClick={() => handleFontDelete(portal.fontFamily!)}
  891. className="text-xs text-red-600 hover:text-red-700 underline"
  892. title={`Delete font "${portal.fontFamily}"`}
  893. >
  894. Delete selected font
  895. </button>
  896. )}
  897. </div>
  898. <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>
  899. </div>
  900. </div>
  901. <div className="space-y-6">
  902. {/* Logo Upload with Remove Button */}
  903. <div>
  904. <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
  905. <div className="flex items-center gap-3 flex-wrap">
  906. <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">
  907. <input type="file" accept="image/*,.svg,image/svg+xml" onChange={e => handleUpload(e, 'logoUrl', true)} hidden />
  908. Choose image…
  909. </label>
  910. {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  911. </div>
  912. {portal.logoUrl && (
  913. <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
  914. <img src={withBasePath(portal.logoUrl)} className="h-16 object-contain" alt="Logo Preview" />
  915. <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>
  916. <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>
  917. </div>
  918. )}
  919. </div>
  920. {/* Hero Upload with Remove Button */}
  921. <div>
  922. <label className="block text-sm font-semibold text-gray-700 mb-1">Background Image</label>
  923. <div className="flex items-center gap-3 flex-wrap">
  924. <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">
  925. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} hidden />
  926. Choose image…
  927. </label>
  928. {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  929. </div>
  930. {portal.heroImageUrl && (
  931. <div className="mt-2 relative rounded shadow border inline-block w-full">
  932. <img src={withBasePath(portal.heroImageUrl)} className="h-32 w-full object-cover rounded" alt="Background preview" />
  933. <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>
  934. <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>
  935. </div>
  936. )}
  937. </div>
  938. <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
  939. <label className="flex items-center gap-3 cursor-pointer">
  940. <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
  941. <div>
  942. <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
  943. <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>
  944. </div>
  945. </label>
  946. <label className="flex items-center gap-3 cursor-pointer">
  947. <input
  948. type="checkbox"
  949. checked={portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT}
  950. onChange={e => setPortal({...portal, externalLinkEnabled: e.target.checked})}
  951. className="w-5 h-5 text-blue-600 rounded"
  952. />
  953. <div>
  954. <span className="block text-sm font-semibold text-gray-900">Enable &ldquo;External Link&rdquo; type in the dropdown menu.</span>
  955. <span className="block text-xs text-gray-600">Existing cards of this type will still remain visible and clickable, even if you disable the &ldquo;External Link&rdquo; type.</span>
  956. </div>
  957. </label>
  958. </div>
  959. </div>
  960. </div>
  961. <div className="mt-10 pt-6 border-t border-gray-200">
  962. <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Backup &amp; Restore</h3>
  963. <p className="text-xs text-gray-500 mb-4">
  964. The backup contains cards, portal configuration, media (images, videos, PDFs), and uploaded fonts. Restoring overwrites the current state. Clicking the &ldquo;Save backup (ZIP)&rdquo; button saves the Cards structure as <code>interceptor-backup-YYYYMMDD-hhmmss.zip</code>.
  965. </p>
  966. <div className="flex flex-wrap gap-3">
  967. <button
  968. type="button"
  969. onClick={handleBackupDownload}
  970. className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm"
  971. >
  972. ⬇ Save backup (ZIP)
  973. </button>
  974. <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' : ''}`}>
  975. <input
  976. type="file"
  977. accept=".zip,application/zip,application/x-zip-compressed"
  978. onChange={handleRestoreUpload}
  979. disabled={restoring}
  980. hidden
  981. />
  982. {restoring ? 'Restoring…' : '⤴ Restore from ZIP'}
  983. </label>
  984. </div>
  985. </div>
  986. <div className="mt-8 pt-6 border-t border-gray-200">
  987. <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3>
  988. <p className="text-xs text-gray-500 mb-2">
  989. &ldquo;Factory&rdquo; state restorable with one click. The preset (<code>factory/preset.zip</code>) is prepared on the development machine and distributed to MajorNet machines.
  990. </p>
  991. <p className="text-xs text-gray-700 mb-4">
  992. Current preset: {factoryPreset === null ? '…'
  993. : factoryPreset.exists
  994. ? <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>
  995. : <span className="text-gray-400 italic">no preset configured</span>}
  996. </p>
  997. <div className="flex flex-wrap gap-3">
  998. <button
  999. type="button"
  1000. onClick={handleFactoryReset}
  1001. disabled={factoryResetting || !factoryPreset?.exists}
  1002. title={factoryPreset?.exists ? undefined : 'No preset configured'}
  1003. 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"
  1004. >
  1005. {factoryResetting ? 'Reset in progress…' : 'Factory Reset'}
  1006. </button>
  1007. {FACTORY_PRESET_SAVE_ENABLED && (
  1008. <button
  1009. type="button"
  1010. onClick={handleSaveFactoryPreset}
  1011. disabled={savingPreset}
  1012. title="Developer function: save the current state as a new factory preset"
  1013. className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
  1014. >
  1015. {savingPreset ? 'Saving…' : 'Save as Factory Preset (dev)'}
  1016. </button>
  1017. )}
  1018. </div>
  1019. </div>
  1020. <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
  1021. <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">
  1022. {savingPortal ? 'Saving...' : 'Save Portal Settings'}
  1023. </button>
  1024. </div>
  1025. </div>
  1026. )}
  1027. </div>
  1028. </div>
  1029. {/* MODAL FOR EDITING/CREATING CARDS */}
  1030. {isEditing && (
  1031. <div
  1032. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
  1033. onClick={() => setIsEditing(null)} // Click outside to close
  1034. >
  1035. <div
  1036. 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"
  1037. onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
  1038. >
  1039. <button
  1040. onClick={() => setIsEditing(null)}
  1041. 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"
  1042. >
  1043. </button>
  1044. <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
  1045. {isEditing.id ? 'Edit Card' : 'Create New Card'}
  1046. </h3>
  1047. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1048. <div className="space-y-5">
  1049. <div>
  1050. <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
  1051. <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" />
  1052. <CharCounter value={isEditing.title} limit={CARD_LIMITS.title} />
  1053. </div>
  1054. <div>
  1055. <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
  1056. <StyledSelect<CardType>
  1057. value={(isEditing.cardType || 'INFO_PAGE') as CardType}
  1058. onChange={(v) => setIsEditing({ ...isEditing, cardType: v })}
  1059. options={[
  1060. { value: 'INFO_PAGE', label: 'Info Page' },
  1061. { value: 'IMAGE_GALLERY', label: 'Image Gallery' },
  1062. { value: 'BOOK', label: 'Flip-Book' },
  1063. { value: 'FULLSCREEN_LOCK', label: 'Fullscreen Lock (kiosk)' },
  1064. ...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
  1065. ]}
  1066. />
  1067. </div>
  1068. {isEditing.cardType === 'FULLSCREEN_LOCK' && (
  1069. <div className="bg-red-50 border border-red-200 rounded-lg p-4">
  1070. <p className="text-sm font-semibold text-red-800">⚠ Kiosk Lock Mode</p>
  1071. <p className="text-xs text-red-700 mt-1">
  1072. This card will take full control of the public portal. All other cards will be hidden until you remove this one.
  1073. Upload an image or video as &quot;Full-screen content&quot; in the section on the right.
  1074. </p>
  1075. </div>
  1076. )}
  1077. {isEditing.cardType !== 'FULLSCREEN_LOCK' && (isEditing.cardType === 'EXTERNAL_LINK' ? (
  1078. <>
  1079. <div>
  1080. <label className="block text-sm font-semibold text-gray-800 mb-1">URL <span className="text-red-600">*</span></label>
  1081. <input
  1082. type="url"
  1083. maxLength={CARD_LIMITS.actionUrl}
  1084. value={isEditing.actionUrl || ''}
  1085. onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
  1086. className={inputClasses}
  1087. placeholder="https://example.com/page"
  1088. />
  1089. <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} />
  1090. </div>
  1091. <div>
  1092. <label className="block text-sm font-semibold text-gray-800 mb-1">Link text</label>
  1093. <input
  1094. type="text"
  1095. maxLength={CARD_LIMITS.shortDescription}
  1096. value={isEditing.shortDescription || ''}
  1097. onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
  1098. className={inputClasses}
  1099. placeholder="e.g. Visit the official site"
  1100. />
  1101. <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
  1102. <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>
  1103. </div>
  1104. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
  1105. <label className="flex items-start gap-3 cursor-pointer">
  1106. <input
  1107. type="checkbox"
  1108. checked={!!isEditing.redirectOnClick}
  1109. onChange={e => setIsEditing({ ...isEditing, redirectOnClick: e.target.checked })}
  1110. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  1111. />
  1112. <div>
  1113. <span className="block text-sm font-semibold text-gray-900">Redirect on click</span>
  1114. <span className="block text-xs text-gray-600">Cliccando la card sul portale pubblico, il browser apre direttamente l&rsquo;URL senza mostrare il modale.</span>
  1115. </div>
  1116. </label>
  1117. </div>
  1118. </>
  1119. ) : (
  1120. <div>
  1121. <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
  1122. <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..." />
  1123. <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
  1124. </div>
  1125. ))}
  1126. {isEditing.cardType !== 'BOOK' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
  1127. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3">
  1128. <label className="flex items-start gap-3 cursor-pointer">
  1129. <input
  1130. type="checkbox"
  1131. checked={!!isEditing.autoFullscreen}
  1132. onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
  1133. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  1134. />
  1135. <div>
  1136. <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
  1137. <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
  1138. </div>
  1139. </label>
  1140. <label className="flex items-start gap-3 cursor-pointer">
  1141. <input
  1142. type="checkbox"
  1143. checked={!!isEditing.skipPreview}
  1144. onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
  1145. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  1146. />
  1147. <div>
  1148. <span className="block text-sm font-semibold text-gray-900">Cover not in the gallery</span>
  1149. <span className="block text-xs text-gray-600">The cover stays as the card thumbnail only. Combine with &ldquo;Auto fullscreen&rdquo; to jump straight into the gallery items.</span>
  1150. </div>
  1151. </label>
  1152. </div>
  1153. )}
  1154. </div>
  1155. <div className="space-y-5">
  1156. {/* Cover Image — per FULLSCREEN_LOCK è il contenuto kiosk a tutto schermo e accetta anche video */}
  1157. <div>
  1158. <label className="block text-sm font-semibold text-gray-800 mb-1">
  1159. {isEditing.cardType === 'FULLSCREEN_LOCK'
  1160. ? <>Full-screen content <span className="text-gray-400 font-normal text-xs">(image or video)</span></>
  1161. : <>Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span></>}
  1162. </label>
  1163. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  1164. <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">
  1165. <input
  1166. type="file"
  1167. accept={isEditing.cardType === 'FULLSCREEN_LOCK' ? 'image/*,video/mp4,video/webm,.mp4,.webm,.mov,.m4v' : 'image/*'}
  1168. onChange={e => handleUpload(e, 'imageUrl')}
  1169. hidden
  1170. />
  1171. {isEditing.cardType === 'FULLSCREEN_LOCK' ? 'Choose image or video…' : 'Choose image…'}
  1172. </label>
  1173. {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
  1174. </div>
  1175. {isEditing.imageUrl && (
  1176. <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
  1177. {isVideoUrl(isEditing.imageUrl) ? (
  1178. <video src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" muted playsInline />
  1179. ) : (
  1180. <img src={withBasePath(isEditing.imageUrl)} className="w-full h-32 object-cover" alt="Cover preview" />
  1181. )}
  1182. <a
  1183. href={withBasePath(isEditing.imageUrl)}
  1184. download={extractFileName(isEditing.imageUrl)}
  1185. 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"
  1186. title="Download"
  1187. aria-label="Download cover"
  1188. >⬇</a>
  1189. <button
  1190. onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
  1191. 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"
  1192. title="Remove cover image"
  1193. >✕</button>
  1194. </div>
  1195. )}
  1196. </div>
  1197. {/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) e FULLSCREEN_LOCK (solo contenuto kiosk) */}
  1198. {isEditing.cardType !== 'INFO_PAGE' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
  1199. <div>
  1200. <label className="block text-sm font-semibold text-gray-800 mb-1">
  1201. Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become images)</span>
  1202. </label>
  1203. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  1204. <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">
  1205. <input
  1206. type="file"
  1207. 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"
  1208. multiple
  1209. onChange={handleUploadExtraMedia}
  1210. hidden
  1211. />
  1212. Choose files…
  1213. </label>
  1214. {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
  1215. {pdfProgress && (
  1216. <p className="mt-2 text-sm text-purple-600 font-medium">
  1217. Processing &ldquo;{pdfProgress.name}&rdquo;: page {pdfProgress.page} of {pdfProgress.total}
  1218. </p>
  1219. )}
  1220. </div>
  1221. {(isEditing.extraMedia || []).length > 0 && (
  1222. <div className="mt-3 space-y-2">
  1223. {(isEditing.extraMedia || []).map((item, i) => {
  1224. const video = isVideoUrl(item.url);
  1225. const tc = transcodeJobs[item.url];
  1226. const isTranscoding = !!tc && (tc.status === 'queued' || tc.status === 'running');
  1227. return (
  1228. <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
  1229. <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
  1230. {video ? (
  1231. <>
  1232. <video src={withBasePath(item.url)} className="w-full h-full object-cover" muted preload="metadata" />
  1233. <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
  1234. </>
  1235. ) : (
  1236. <img src={withBasePath(item.url)} className="w-full h-full object-cover" alt="" />
  1237. )}
  1238. {isTranscoding && (
  1239. <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">
  1240. <span>Transcoding</span>
  1241. <span>{Math.round((tc.progress || 0) * 100)}%</span>
  1242. </div>
  1243. )}
  1244. <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
  1245. </div>
  1246. <div className="flex-1 min-w-0">
  1247. <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
  1248. {video ? 'Video' : 'Image'}
  1249. </div>
  1250. {video && (
  1251. <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
  1252. <label className="flex items-center gap-2 cursor-pointer">
  1253. <input
  1254. type="checkbox"
  1255. checked={!!item.autoplay}
  1256. onChange={() => toggleAutoplay(i)}
  1257. className="w-4 h-4 text-blue-600 rounded"
  1258. />
  1259. <span className="text-sm text-gray-700">Autoplay</span>
  1260. </label>
  1261. <label className="flex items-center gap-2 cursor-pointer">
  1262. <input
  1263. type="checkbox"
  1264. checked={!!item.muted}
  1265. onChange={() => toggleMuted(i)}
  1266. className="w-4 h-4 text-blue-600 rounded"
  1267. />
  1268. <span className="text-sm text-gray-700">Muted</span>
  1269. </label>
  1270. </div>
  1271. )}
  1272. </div>
  1273. <button
  1274. onClick={() => moveExtraMedia(i, 'up')}
  1275. 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"
  1276. title="Move up"
  1277. aria-label="Move up"
  1278. disabled={i === 0}
  1279. >↑</button>
  1280. <button
  1281. onClick={() => moveExtraMedia(i, 'down')}
  1282. 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"
  1283. title="Move down"
  1284. aria-label="Move down"
  1285. disabled={i === (isEditing.extraMedia || []).length - 1}
  1286. >↓</button>
  1287. <a
  1288. href={withBasePath(item.url)}
  1289. download={extractFileName(item.url)}
  1290. 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"
  1291. title="Download"
  1292. aria-label="Download"
  1293. >⬇</a>
  1294. <button
  1295. onClick={() => removeExtraMedia(i)}
  1296. className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
  1297. title="Remove"
  1298. >✕</button>
  1299. </div>
  1300. );
  1301. })}
  1302. </div>
  1303. )}
  1304. </div>
  1305. )}
  1306. </div>
  1307. </div>
  1308. <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
  1309. <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
  1310. Cancel
  1311. </button>
  1312. <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">
  1313. Save Card
  1314. </button>
  1315. </div>
  1316. </div>
  1317. </div>
  1318. )}
  1319. {/* CUSTOM CONFIRM DIALOG */}
  1320. {confirmDialog && (
  1321. <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">
  1322. <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
  1323. <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
  1324. <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
  1325. <div className="flex justify-end gap-3">
  1326. <button
  1327. onClick={() => setConfirmDialog(null)}
  1328. className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
  1329. >
  1330. Cancel
  1331. </button>
  1332. <button
  1333. onClick={confirmDialog.onConfirm}
  1334. className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
  1335. >
  1336. Delete
  1337. </button>
  1338. </div>
  1339. </div>
  1340. </div>
  1341. )}
  1342. {/* CUSTOM TOAST NOTIFICATION */}
  1343. {toast && (
  1344. <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 ${
  1345. toast.type === 'error' ? 'bg-red-700' : 'bg-gray-900'
  1346. }`}>
  1347. <div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-sm shrink-0 mt-0.5 ${
  1348. toast.type === 'error' ? 'bg-white text-red-700' : 'bg-green-500 text-gray-900'
  1349. }`}>
  1350. {toast.type === 'error' ? '!' : '✓'}
  1351. </div>
  1352. <span className="font-medium leading-snug">{toast.message}</span>
  1353. </div>
  1354. )}
  1355. </div>
  1356. );
  1357. }