Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

1276 linhas
64 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_RESET_ENABLED } from '@/lib/config';
  5. import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
  6. type CharCounterProps = { value: string | undefined; limit: number };
  7. function CharCounter({ value, limit }: CharCounterProps) {
  8. const len = (value ?? '').length;
  9. if (len < limit * 0.8) return null;
  10. const overflow = len > limit;
  11. return (
  12. <p className={`text-xs mt-1 text-right ${overflow ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
  13. {len} / {limit}
  14. </p>
  15. );
  16. }
  17. function stripTags(html: string): string {
  18. if (typeof window === 'undefined' || !html) return '';
  19. return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
  20. }
  21. type RichTextMiniProps = {
  22. value: string;
  23. onChange: (html: string) => void;
  24. limit: number;
  25. className?: string;
  26. };
  27. function RichTextMini({ value, onChange, limit, className }: RichTextMiniProps) {
  28. const ref = useRef<HTMLDivElement>(null);
  29. // Sync iniziale soltanto. Aggiornare innerHTML durante l'editing perderebbe la
  30. // posizione del cursore, quindi confidiamo che onInput tenga value e DOM allineati.
  31. useEffect(() => {
  32. if (ref.current && ref.current.innerHTML !== value) {
  33. ref.current.innerHTML = value || '';
  34. }
  35. // eslint-disable-next-line react-hooks/exhaustive-deps
  36. }, []);
  37. const exec = (cmd: 'bold' | 'italic') => {
  38. ref.current?.focus();
  39. document.execCommand(cmd);
  40. onChange(ref.current?.innerHTML || '');
  41. };
  42. return (
  43. <div>
  44. <div className="flex gap-1 mb-1">
  45. <button
  46. type="button"
  47. onClick={() => exec('bold')}
  48. className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
  49. title="Grassetto"
  50. >B</button>
  51. <button
  52. type="button"
  53. onClick={() => exec('italic')}
  54. className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
  55. title="Corsivo"
  56. >I</button>
  57. </div>
  58. <div
  59. ref={ref}
  60. contentEditable
  61. suppressContentEditableWarning
  62. onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
  63. 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'}
  64. />
  65. <CharCounter value={stripTags(value)} limit={limit} />
  66. </div>
  67. );
  68. }
  69. function StyledSelect<T extends string>({
  70. value,
  71. onChange,
  72. options,
  73. }: {
  74. value: T;
  75. onChange: (v: T) => void;
  76. options: { value: T; label: string; style?: React.CSSProperties }[];
  77. }) {
  78. const [open, setOpen] = useState(false);
  79. const ref = useRef<HTMLDivElement>(null);
  80. useEffect(() => {
  81. if (!open) return;
  82. const onClick = (e: MouseEvent) => {
  83. if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
  84. };
  85. const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
  86. document.addEventListener('mousedown', onClick);
  87. document.addEventListener('keydown', onKey);
  88. return () => {
  89. document.removeEventListener('mousedown', onClick);
  90. document.removeEventListener('keydown', onKey);
  91. };
  92. }, [open]);
  93. const current = options.find(o => o.value === value);
  94. // Fallback: se il value non matcha nessuna opzione (es. tipo disattivato dalla flag), mostra il valore raw prettificato
  95. const displayLabel = current?.label
  96. ?? (typeof value === 'string' && value.length > 0
  97. ? value.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
  98. : '');
  99. 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";
  100. return (
  101. <div ref={ref} className="relative">
  102. <button
  103. type="button"
  104. onClick={() => setOpen(o => !o)}
  105. className={`${inputBase} text-left flex items-center justify-between cursor-pointer`}
  106. >
  107. <span className={displayLabel ? '' : 'text-gray-400'} style={current?.style}>{displayLabel || 'Seleziona…'}</span>
  108. <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
  109. </button>
  110. {open && (
  111. <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">
  112. {options.map(o => (
  113. <button
  114. key={o.value}
  115. type="button"
  116. onClick={() => { onChange(o.value); setOpen(false); }}
  117. 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'}`}
  118. style={o.style}
  119. >
  120. {o.label}
  121. </button>
  122. ))}
  123. </div>
  124. )}
  125. </div>
  126. );
  127. }
  128. 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';
  129. // Sottoinsieme di formati video davvero riproducibili dai browser moderni
  130. const PLAYBACK_SUPPORTED_VIDEO = 'mp4|m4v|webm|mov|qt|ogv|ogg';
  131. const PLAYBACK_SUPPORTED_LABEL = 'MP4, M4V, WebM, MOV, OGV';
  132. const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
  133. const isPdfFile = (file: File) =>
  134. file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
  135. const isVideoFile = (file: File) =>
  136. file.type.startsWith('video/') || new RegExp(`\\.(${VIDEO_EXTENSIONS})$`, 'i').test(file.name);
  137. const isPlayableVideoFile = (file: File) =>
  138. new RegExp(`\\.(${PLAYBACK_SUPPORTED_VIDEO})$`, 'i').test(file.name);
  139. const previewFontFamily = (filename: string): string =>
  140. `PortalPreview-${filename.replace(/[^A-Za-z0-9]/g, '_')}`;
  141. const fontFormatFromName = (filename: string): string => {
  142. const ext = filename.match(/\.([^.]+)$/)?.[1].toLowerCase() ?? 'woff2';
  143. return ({ woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' } as Record<string, string>)[ext] ?? 'woff2';
  144. };
  145. const extractFileName = (url: string): string => {
  146. const match = url.match(/[?&]name=([^&]+)/);
  147. if (match) return decodeURIComponent(match[1]);
  148. const seg = url.split('/').pop() || 'download';
  149. return seg.split('?')[0];
  150. };
  151. async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> {
  152. const formData = new FormData();
  153. formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
  154. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  155. const data = await res.json();
  156. return data.url || null;
  157. }
  158. async function extractVideoFrame(file: File): Promise<Blob | null> {
  159. const url = URL.createObjectURL(file);
  160. try {
  161. const video = document.createElement('video');
  162. video.muted = true;
  163. video.playsInline = true;
  164. video.preload = 'metadata';
  165. video.src = url;
  166. await new Promise<void>((resolve, reject) => {
  167. video.addEventListener('loadedmetadata', () => resolve(), { once: true });
  168. video.addEventListener('error', () => reject(new Error('video load error')), { once: true });
  169. });
  170. // Seek slightly past 0 — at exactly 0 some codecs return a black frame
  171. video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10));
  172. await new Promise<void>((resolve, reject) => {
  173. video.addEventListener('seeked', () => resolve(), { once: true });
  174. video.addEventListener('error', () => reject(new Error('video seek error')), { once: true });
  175. });
  176. const canvas = document.createElement('canvas');
  177. canvas.width = video.videoWidth;
  178. canvas.height = video.videoHeight;
  179. const ctx = canvas.getContext('2d');
  180. if (!ctx) return null;
  181. ctx.drawImage(video, 0, 0);
  182. return await new Promise<Blob | null>((resolve) =>
  183. canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85)
  184. );
  185. } finally {
  186. URL.revokeObjectURL(url);
  187. }
  188. }
  189. async function pdfToImageItems(
  190. file: File,
  191. onProgress: (page: number, total: number) => void
  192. ): Promise<MediaItem[]> {
  193. const pdfjs = await import('pdfjs-dist');
  194. // Worker file is copied to /public via the postinstall script
  195. pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
  196. const arrayBuffer = await file.arrayBuffer();
  197. const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
  198. const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  199. const items: MediaItem[] = [];
  200. for (let i = 1; i <= pdf.numPages; i++) {
  201. onProgress(i, pdf.numPages);
  202. const page = await pdf.getPage(i);
  203. const viewport = page.getViewport({ scale: 1.5 });
  204. const canvas = document.createElement('canvas');
  205. canvas.width = viewport.width;
  206. canvas.height = viewport.height;
  207. const ctx = canvas.getContext('2d');
  208. if (!ctx) continue;
  209. await page.render({ canvasContext: ctx, viewport }).promise;
  210. const blob: Blob = await new Promise((resolve, reject) => {
  211. canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
  212. });
  213. const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
  214. if (url) items.push({ url });
  215. }
  216. return items;
  217. }
  218. export default function AdminDashboard() {
  219. const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
  220. // Card State
  221. const [cards, setCards] = useState<Card[]>([]);
  222. const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
  223. // Portal State
  224. const [portal, setPortal] = useState<Partial<Portal>>({});
  225. const [savingPortal, setSavingPortal] = useState(false);
  226. const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
  227. // NEW UI STATES: Toast and Confirm Dialog
  228. const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
  229. const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
  230. const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null);
  231. const [availableFonts, setAvailableFonts] = useState<string[]>([]);
  232. // Map: expected URL of the future-transcoded file → job state.
  233. // We key by URL (not jobId) so the rendering layer can look it up cheaply.
  234. const [transcodeJobs, setTranscodeJobs] = useState<Record<string, { jobId: string; status: string; progress: number }>>({});
  235. // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config.
  236. const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT;
  237. // Helper to show auto-dismissing toast
  238. const showToast = (message: string, type: 'success' | 'error' = 'success') => {
  239. setToast({ message, type });
  240. setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000);
  241. };
  242. useEffect(() => {
  243. fetch('/api/cards').then(res => res.json()).then(setCards);
  244. fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data));
  245. fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([]));
  246. }, []);
  247. // Poll pending transcode jobs every 2s. On 'done' we drop the entry from the
  248. // map; on 'failed' we additionally pull the media URL out of the editor so the
  249. // admin doesn't try to save a broken reference.
  250. useEffect(() => {
  251. const pendingEntries = Object.entries(transcodeJobs).filter(
  252. ([, j]) => j.status === 'queued' || j.status === 'running'
  253. );
  254. if (pendingEntries.length === 0) return;
  255. let cancelled = false;
  256. const tick = async () => {
  257. for (const [url, j] of pendingEntries) {
  258. if (cancelled) return;
  259. try {
  260. const res = await fetch(`/api/transcode/${j.jobId}`);
  261. if (!res.ok) continue;
  262. const data = await res.json();
  263. if (cancelled) return;
  264. if (data.status === 'done') {
  265. setTranscodeJobs(prev => {
  266. const next = { ...prev };
  267. delete next[url];
  268. return next;
  269. });
  270. } else if (data.status === 'failed' || data.status === 'cancelled') {
  271. setTranscodeJobs(prev => {
  272. const next = { ...prev };
  273. delete next[url];
  274. return next;
  275. });
  276. setIsEditing(prev => prev ? {
  277. ...prev,
  278. extraMedia: (prev.extraMedia || []).filter(m => m.url !== url),
  279. imageUrl: prev.imageUrl === url ? '' : prev.imageUrl,
  280. } : prev);
  281. const msg = data.status === 'failed'
  282. ? `Trascodifica fallita${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}`
  283. : 'Trascodifica annullata';
  284. showToast(msg, 'error');
  285. } else {
  286. setTranscodeJobs(prev => prev[url] ? ({ ...prev, [url]: { ...prev[url], status: data.status, progress: data.progress ?? 0 } }) : prev);
  287. }
  288. } catch {
  289. // ignore network glitches; will retry next tick
  290. }
  291. }
  292. };
  293. void tick();
  294. const id = window.setInterval(() => { void tick(); }, 2000);
  295. return () => { cancelled = true; window.clearInterval(id); };
  296. // eslint-disable-next-line react-hooks/exhaustive-deps
  297. }, [Object.keys(transcodeJobs).join('|')]);
  298. const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
  299. if (!e.target.files?.[0]) return;
  300. setUploading(prev => ({ ...prev, [field]: true }));
  301. const formData = new FormData();
  302. formData.append('file', e.target.files[0]);
  303. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  304. const data = await res.json();
  305. if (data.url) {
  306. if (isPortal) {
  307. setPortal(prev => ({ ...prev, [field]: data.url }));
  308. } else {
  309. setIsEditing(prev => ({ ...prev, [field]: data.url }));
  310. }
  311. }
  312. setUploading(prev => ({ ...prev, [field]: false }));
  313. };
  314. const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
  315. const files = e.target.files;
  316. if (!files || files.length === 0) return;
  317. setUploading(prev => ({ ...prev, extraMedia: true }));
  318. const startedWithoutCover = !isEditing?.imageUrl;
  319. let pendingCover: string | null = null;
  320. const canPromote = () => startedWithoutCover && !pendingCover;
  321. // Pre-filtro: scarta video con formati non riproducibili nei browser
  322. const rejected: string[] = [];
  323. const acceptedFiles: File[] = [];
  324. for (const file of Array.from(files)) {
  325. if (isVideoFile(file) && !isPlayableVideoFile(file)) {
  326. rejected.push(file.name);
  327. } else {
  328. acceptedFiles.push(file);
  329. }
  330. }
  331. if (rejected.length > 0) {
  332. const list = rejected.length <= 3
  333. ? rejected.join(', ')
  334. : `${rejected.slice(0, 3).join(', ')} e altri ${rejected.length - 3}`;
  335. showToast(
  336. `Formato non supportato! I formati supportati sono: ${PLAYBACK_SUPPORTED_LABEL}. File ignorati: ${list}`,
  337. 'error'
  338. );
  339. }
  340. if (acceptedFiles.length === 0) {
  341. setUploading(prev => ({ ...prev, extraMedia: false }));
  342. e.target.value = '';
  343. return;
  344. }
  345. const uploaded: MediaItem[] = [];
  346. for (const file of acceptedFiles) {
  347. try {
  348. if (isPdfFile(file)) {
  349. const items = await pdfToImageItems(file, (page, total) =>
  350. setPdfProgress({ name: file.name, page, total })
  351. );
  352. setPdfProgress(null);
  353. if (items.length > 0 && canPromote()) {
  354. // Promote the first PDF page to cover; skip it from the gallery to avoid duplication.
  355. pendingCover = items[0].url;
  356. uploaded.push(...items.slice(1));
  357. } else {
  358. uploaded.push(...items);
  359. }
  360. } else {
  361. const formData = new FormData();
  362. formData.append('file', file);
  363. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  364. const data = await res.json();
  365. if (!data.url) continue;
  366. if (data?.transcoding?.jobId) {
  367. const { jobId, status } = data.transcoding;
  368. setTranscodeJobs(prev => ({ ...prev, [data.url]: { jobId, status, progress: 0 } }));
  369. }
  370. if (isVideoFile(file)) {
  371. // Video always goes to the gallery so users can play it.
  372. uploaded.push({ url: data.url });
  373. // If no cover yet, extract the first frame and use it as the cover.
  374. if (canPromote()) {
  375. try {
  376. const blob = await extractVideoFrame(file);
  377. if (blob) {
  378. const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  379. const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`);
  380. if (posterUrl) pendingCover = posterUrl;
  381. }
  382. } catch (err) {
  383. console.warn('Could not extract video poster for', file.name, err);
  384. }
  385. }
  386. } else {
  387. // Plain image
  388. if (canPromote()) {
  389. // Promote to cover; skip the gallery to avoid duplication.
  390. pendingCover = data.url;
  391. } else {
  392. uploaded.push({ url: data.url });
  393. }
  394. }
  395. }
  396. } catch (err) {
  397. console.error('Upload failed for', file.name, err);
  398. showToast(`Failed to process "${file.name}".`);
  399. setPdfProgress(null);
  400. }
  401. }
  402. setIsEditing(prev => ({
  403. ...prev,
  404. imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''),
  405. extraMedia: [...(prev?.extraMedia || []), ...uploaded],
  406. }));
  407. setUploading(prev => ({ ...prev, extraMedia: false }));
  408. e.target.value = '';
  409. };
  410. const removeExtraMedia = (index: number) => {
  411. setIsEditing(prev => ({
  412. ...prev,
  413. extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
  414. }));
  415. };
  416. const moveExtraMedia = (index: number, direction: 'up' | 'down') => {
  417. setIsEditing(prev => {
  418. const items = [...(prev?.extraMedia || [])];
  419. if (direction === 'up' && index > 0) {
  420. [items[index - 1], items[index]] = [items[index], items[index - 1]];
  421. } else if (direction === 'down' && index < items.length - 1) {
  422. [items[index + 1], items[index]] = [items[index], items[index + 1]];
  423. } else {
  424. return prev;
  425. }
  426. return { ...prev, extraMedia: items };
  427. });
  428. };
  429. const toggleAutoplay = (index: number) => {
  430. setIsEditing(prev => ({
  431. ...prev,
  432. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  433. i === index ? { ...m, autoplay: !m.autoplay } : m
  434. ),
  435. }));
  436. };
  437. const toggleMuted = (index: number) => {
  438. setIsEditing(prev => ({
  439. ...prev,
  440. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  441. i === index ? { ...m, muted: !m.muted } : m
  442. ),
  443. }));
  444. };
  445. const handleSaveCard = async () => {
  446. if (!isEditing) return;
  447. const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
  448. const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
  449. const res = await fetch('/api/cards', {
  450. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
  451. });
  452. if (!res.ok) {
  453. let message = 'Errore di salvataggio';
  454. try {
  455. const body = await res.json();
  456. if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) {
  457. const first = body.errors[0];
  458. message = first.limit != null
  459. ? `${first.field}: ${first.message} (${first.actual} / ${first.limit})`
  460. : `${first.field}: ${first.message}`;
  461. } else if (body?.error) {
  462. message = body.error;
  463. }
  464. } catch {}
  465. showToast(message, 'error');
  466. return; // keep the editor open so the admin can fix
  467. }
  468. setCards(prev => {
  469. const exists = prev.find(c => c.id === newCard.id);
  470. return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
  471. });
  472. setIsEditing(null);
  473. };
  474. const handleDeleteCard = (id: string) => {
  475. // Replace window.confirm with our custom dialog
  476. setConfirmDialog({
  477. message: 'Are you sure you want to delete this card? This action cannot be undone.',
  478. onConfirm: async () => {
  479. await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
  480. setCards(prev => prev.filter(c => c.id !== id));
  481. setConfirmDialog(null);
  482. showToast('Card successfully deleted.');
  483. }
  484. });
  485. };
  486. const moveCard = async (index: number, direction: 'up' | 'down') => {
  487. const newCards = [...cards];
  488. if (direction === 'up' && index > 0) {
  489. [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
  490. } else if (direction === 'down' && index < newCards.length - 1) {
  491. [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
  492. } else {
  493. return; // Do nothing if trying to move out of bounds
  494. }
  495. // Recalculate displayOrder for the whole array
  496. const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
  497. // Optimistically update the UI
  498. setCards(updatedCards);
  499. // Persist the new order to the backend
  500. await fetch('/api/cards', {
  501. method: 'PUT',
  502. headers: { 'Content-Type': 'application/json' },
  503. body: JSON.stringify(updatedCards)
  504. });
  505. };
  506. const handleSavePortal = async () => {
  507. setSavingPortal(true);
  508. await fetch('/api/portals', {
  509. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
  510. });
  511. setSavingPortal(false);
  512. showToast('Portal settings saved successfully!'); // Replaced window.alert
  513. };
  514. const handleBackupDownload = () => {
  515. window.location.href = '/api/admin/backup';
  516. };
  517. const [restoring, setRestoring] = useState(false);
  518. const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  519. const file = e.target.files?.[0];
  520. e.target.value = '';
  521. if (!file) return;
  522. if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return;
  523. setRestoring(true);
  524. try {
  525. const fd = new FormData();
  526. fd.append('file', file);
  527. const res = await fetch('/api/admin/restore', { method: 'POST', body: fd });
  528. const data = await res.json().catch(() => ({}));
  529. if (!res.ok) {
  530. showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
  531. return;
  532. }
  533. showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
  534. setTimeout(() => window.location.reload(), 1200);
  535. } catch (err) {
  536. showToast(`Errore di rete: ${(err as Error).message}`, 'error');
  537. } finally {
  538. setRestoring(false);
  539. }
  540. };
  541. // Factory preset (developer): UI visibile solo se FACTORY_RESET_ENABLED.
  542. const [factoryPreset, setFactoryPreset] = useState<{ exists: boolean; sizeBytes?: number; modifiedAt?: string } | null>(null);
  543. const [savingPreset, setSavingPreset] = useState(false);
  544. const [factoryResetting, setFactoryResetting] = useState(false);
  545. const refreshFactoryPreset = async () => {
  546. try {
  547. const res = await fetch('/api/admin/factory-preset');
  548. if (res.ok) setFactoryPreset(await res.json());
  549. } catch { /* ignore */ }
  550. };
  551. useEffect(() => {
  552. if (FACTORY_RESET_ENABLED) void refreshFactoryPreset();
  553. }, []);
  554. const handleSaveFactoryPreset = async () => {
  555. const msg = factoryPreset?.exists
  556. ? 'Sovrascrivere il factory preset esistente con lo stato attuale?'
  557. : 'Salvare lo stato attuale come factory preset?';
  558. if (!window.confirm(msg)) return;
  559. setSavingPreset(true);
  560. try {
  561. const res = await fetch('/api/admin/factory-preset', { method: 'POST' });
  562. const data = await res.json().catch(() => ({}));
  563. if (!res.ok) {
  564. showToast(data?.error || `Errore (${res.status})`, 'error');
  565. return;
  566. }
  567. showToast('Factory preset aggiornato.');
  568. await refreshFactoryPreset();
  569. } catch (err) {
  570. showToast(`Errore di rete: ${(err as Error).message}`, 'error');
  571. } finally {
  572. setSavingPreset(false);
  573. }
  574. };
  575. const handleFactoryReset = async () => {
  576. if (!window.confirm('FACTORY RESET — tutti i dati attuali verranno sostituiti col factory preset. Continuare?')) return;
  577. setFactoryResetting(true);
  578. try {
  579. const res = await fetch('/api/admin/factory-reset', { method: 'POST' });
  580. const data = await res.json().catch(() => ({}));
  581. if (!res.ok) {
  582. showToast(data?.error || `Errore (${res.status})`, 'error');
  583. return;
  584. }
  585. showToast(`Factory reset eseguito: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
  586. setTimeout(() => window.location.reload(), 1200);
  587. } catch (err) {
  588. showToast(`Errore di rete: ${(err as Error).message}`, 'error');
  589. } finally {
  590. setFactoryResetting(false);
  591. }
  592. };
  593. // Shared Input Classes for high contrast
  594. 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";
  595. return (
  596. <div className="min-h-screen bg-gray-50 font-sans pb-12">
  597. {/* Top Header */}
  598. <div className="bg-blue-900 text-white shadow-md py-6 px-4">
  599. <div className="max-w-5xl mx-auto flex justify-between items-center">
  600. <div>
  601. <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
  602. <p className="text-sm text-blue-200">Local Administration</p>
  603. </div>
  604. <a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
  605. View Live Portal ↗
  606. </a>
  607. </div>
  608. </div>
  609. <div className="max-w-5xl mx-auto mt-8 px-4">
  610. {/* Tab Navigation */}
  611. <div className="flex space-x-2 mb-6">
  612. <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'}`}>
  613. Manage Cards
  614. </button>
  615. <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'}`}>
  616. Portal Settings
  617. </button>
  618. </div>
  619. <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
  620. {/* TAB: CARDS */}
  621. {activeTab === 'cards' && (
  622. <div className="p-6 md:p-8">
  623. <div className="flex justify-between items-center mb-8 border-b pb-4">
  624. <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
  625. <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">
  626. + Add New Card
  627. </button>
  628. </div>
  629. <div className="space-y-3 mb-8">
  630. {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
  631. {cards.map((card, idx) => (
  632. // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
  633. <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">
  634. <div className="flex items-center gap-4">
  635. {(() => {
  636. const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
  637. if (!previewUrl) {
  638. 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>;
  639. }
  640. return isVideoUrl(previewUrl)
  641. ? <video src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
  642. : <img src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
  643. })()}
  644. <div>
  645. <span className="font-semibold text-gray-800 block">{card.title}</span>
  646. <span className="text-xs text-gray-500 uppercase tracking-wider">
  647. {card.cardType}
  648. {card.extraMedia && card.extraMedia.length > 0 && (
  649. <span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span>
  650. )}
  651. {card.cardType === 'FULLSCREEN_LOCK' && (
  652. <span className="ml-2 bg-red-100 text-red-700 px-2 py-0.5 rounded font-bold text-[10px] tracking-wider">LOCK ATTIVA</span>
  653. )}
  654. </span>
  655. </div>
  656. </div>
  657. {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
  658. <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
  659. <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
  660. <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
  661. <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
  662. <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
  663. <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
  664. </div>
  665. </div>
  666. ))}
  667. </div>
  668. </div>
  669. )}
  670. {/* TAB: SETTINGS */}
  671. {activeTab === 'settings' && (
  672. <div className="p-6 md:p-8">
  673. <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
  674. <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
  675. <div className="space-y-6">
  676. <div>
  677. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
  678. <input type="text" maxLength={PORTAL_LIMITS.title} value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
  679. <CharCounter value={portal.title} limit={PORTAL_LIMITS.title} />
  680. </div>
  681. <div>
  682. <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
  683. <RichTextMini
  684. value={portal.welcomeText || ''}
  685. onChange={html => setPortal({ ...portal, welcomeText: html })}
  686. limit={PORTAL_LIMITS.welcomeText}
  687. />
  688. </div>
  689. <div className="flex gap-8">
  690. <div>
  691. <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
  692. <div className="flex items-center gap-4">
  693. <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" />
  694. <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
  695. </div>
  696. </div>
  697. {/* NEW: Max Columns Setting updated for 3 */}
  698. <div className="flex-1">
  699. <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
  700. <input
  701. type="range"
  702. min="3"
  703. max="8"
  704. value={portal.maxGridColumns || 5}
  705. onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
  706. className="w-full mt-3 accent-blue-600"
  707. />
  708. <div className="flex justify-between text-xs text-gray-400 mt-1">
  709. <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
  710. </div>
  711. </div>
  712. </div>
  713. <div>
  714. <label className="block text-sm font-semibold text-gray-700 mb-1">Font del portale</label>
  715. <style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
  716. @font-face {
  717. font-family: '${previewFontFamily(f)}';
  718. src: url('/api/fonts?name=${encodeURIComponent(f)}') format('${fontFormatFromName(f)}');
  719. font-display: swap;
  720. }`).join('') }} />
  721. <StyledSelect<string>
  722. value={portal.fontFamily ?? ''}
  723. onChange={(v) => setPortal({ ...portal, fontFamily: v })}
  724. options={[
  725. { value: '', label: 'Sistema (Arial)' },
  726. ...availableFonts.map(f => ({
  727. value: f,
  728. label: f.replace(/\.(woff2?|ttf|otf)$/i, ''),
  729. style: { fontFamily: `'${previewFontFamily(f)}', Arial, Helvetica, sans-serif` },
  730. })),
  731. ]}
  732. />
  733. </div>
  734. </div>
  735. <div className="space-y-6">
  736. {/* Logo Upload with Remove Button */}
  737. <div>
  738. <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
  739. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'logoUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
  740. {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  741. {portal.logoUrl && (
  742. <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
  743. <img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
  744. <a href={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="Scarica" aria-label="Scarica logo">⬇</a>
  745. <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>
  746. </div>
  747. )}
  748. </div>
  749. {/* Hero Upload with Remove Button */}
  750. <div>
  751. <label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label>
  752. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
  753. {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  754. {portal.heroImageUrl && (
  755. <div className="mt-2 relative rounded shadow border inline-block w-full">
  756. <img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
  757. <a href={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="Scarica" aria-label="Scarica hero">⬇</a>
  758. <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>
  759. </div>
  760. )}
  761. </div>
  762. <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
  763. <label className="flex items-center gap-3 cursor-pointer">
  764. <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
  765. <div>
  766. <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
  767. <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>
  768. </div>
  769. </label>
  770. <label className="flex items-center gap-3 cursor-pointer">
  771. <input
  772. type="checkbox"
  773. checked={portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT}
  774. onChange={e => setPortal({...portal, externalLinkEnabled: e.target.checked})}
  775. className="w-5 h-5 text-blue-600 rounded"
  776. />
  777. <div>
  778. <span className="block text-sm font-semibold text-gray-900">Abilita &ldquo;External Link&rdquo;</span>
  779. <span className="block text-xs text-gray-600">Mostra il tipo &ldquo;External Link&rdquo; nel dropdown del Card Type. Le card esistenti di quel tipo restano comunque visibili e cliccabili.</span>
  780. </div>
  781. </label>
  782. </div>
  783. </div>
  784. </div>
  785. <div className="mt-10 pt-6 border-t border-gray-200">
  786. <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Backup &amp; Restore</h3>
  787. <p className="text-xs text-gray-500 mb-4">
  788. Il backup contiene card, configurazione portale, media (immagini, video, PDF) e font caricati. Il ripristino sovrascrive lo stato attuale; la cartella precedente viene conservata come <code>data.bak-&lt;timestamp&gt;</code> per sicurezza.
  789. </p>
  790. <div className="flex flex-wrap gap-3">
  791. <button
  792. type="button"
  793. onClick={handleBackupDownload}
  794. className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm"
  795. >
  796. ⬇ Scarica backup ZIP
  797. </button>
  798. <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' : ''}`}>
  799. <input
  800. type="file"
  801. accept=".zip,application/zip,application/x-zip-compressed"
  802. onChange={handleRestoreUpload}
  803. disabled={restoring}
  804. hidden
  805. />
  806. {restoring ? 'Ripristino in corso…' : '⤴ Ripristina da ZIP…'}
  807. </label>
  808. </div>
  809. </div>
  810. {FACTORY_RESET_ENABLED && (
  811. <div className="mt-8 pt-6 border-t border-gray-200">
  812. <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset <span className="text-[10px] bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded ml-1 tracking-normal">developer</span></h3>
  813. <p className="text-xs text-gray-500 mb-2">
  814. Stato &ldquo;di fabbrica&rdquo; sempre ripristinabile con un click. Utile per preparare preset standard da distribuire alle macchine MajorNet:
  815. configura il portale come vuoi, salvalo come preset, poi copia <code>factory/preset.zip</code> sulle altre macchine.
  816. </p>
  817. <p className="text-xs text-gray-700 mb-4">
  818. Preset attuale: {factoryPreset === null ? '…'
  819. : factoryPreset.exists
  820. ? <span className="text-green-700 font-medium">presente · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('it-IT') : '?'}</span>
  821. : <span className="text-gray-400 italic">nessun preset configurato</span>}
  822. </p>
  823. <div className="flex flex-wrap gap-3">
  824. <button
  825. type="button"
  826. onClick={handleSaveFactoryPreset}
  827. disabled={savingPreset}
  828. className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
  829. >
  830. {savingPreset ? 'Salvataggio…' : '💾 Salva stato attuale come Factory Preset'}
  831. </button>
  832. <button
  833. type="button"
  834. onClick={handleFactoryReset}
  835. disabled={factoryResetting || !factoryPreset?.exists}
  836. title={factoryPreset?.exists ? undefined : 'Nessun preset configurato'}
  837. 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"
  838. >
  839. {factoryResetting ? 'Reset in corso…' : '🏭 Factory Reset'}
  840. </button>
  841. </div>
  842. </div>
  843. )}
  844. <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
  845. <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">
  846. {savingPortal ? 'Saving...' : 'Save Portal Settings'}
  847. </button>
  848. </div>
  849. </div>
  850. )}
  851. </div>
  852. </div>
  853. {/* MODAL FOR EDITING/CREATING CARDS */}
  854. {isEditing && (
  855. <div
  856. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
  857. onClick={() => setIsEditing(null)} // Click outside to close
  858. >
  859. <div
  860. 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"
  861. onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
  862. >
  863. <button
  864. onClick={() => setIsEditing(null)}
  865. 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"
  866. >
  867. </button>
  868. <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
  869. {isEditing.id ? 'Edit Card' : 'Create New Card'}
  870. </h3>
  871. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  872. <div className="space-y-5">
  873. <div>
  874. <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
  875. <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" />
  876. <CharCounter value={isEditing.title} limit={CARD_LIMITS.title} />
  877. </div>
  878. <div>
  879. <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
  880. <StyledSelect<CardType>
  881. value={(isEditing.cardType || 'INFO_PAGE') as CardType}
  882. onChange={(v) => setIsEditing({ ...isEditing, cardType: v })}
  883. options={[
  884. { value: 'INFO_PAGE', label: 'Info Page' },
  885. { value: 'IMAGE_GALLERY', label: 'Image Gallery' },
  886. { value: 'BOOK', label: 'Flip-Book' },
  887. { value: 'FULLSCREEN_LOCK', label: 'Fullscreen Lock (kiosk)' },
  888. ...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
  889. ]}
  890. />
  891. </div>
  892. {isEditing.cardType === 'FULLSCREEN_LOCK' && (
  893. <div className="bg-red-50 border border-red-200 rounded-lg p-4">
  894. <p className="text-sm font-semibold text-red-800">⚠ Modalità Kiosk Lock</p>
  895. <p className="text-xs text-red-700 mt-1">
  896. Questa card prenderà il controllo totale del portale pubblico. Tutte le altre card saranno nascoste finché non rimuovi questa.
  897. Carica un&apos;immagine o un video come &quot;Contenuto a schermo intero&quot; nella sezione a destra.
  898. </p>
  899. </div>
  900. )}
  901. {isEditing.cardType !== 'FULLSCREEN_LOCK' && (isEditing.cardType === 'EXTERNAL_LINK' ? (
  902. <>
  903. <div>
  904. <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label>
  905. <input
  906. type="url"
  907. maxLength={CARD_LIMITS.actionUrl}
  908. value={isEditing.actionUrl || ''}
  909. onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
  910. className={inputClasses}
  911. placeholder="https://esempio.it/pagina"
  912. />
  913. <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} />
  914. </div>
  915. <div>
  916. <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label>
  917. <input
  918. type="text"
  919. maxLength={CARD_LIMITS.shortDescription}
  920. value={isEditing.shortDescription || ''}
  921. onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
  922. className={inputClasses}
  923. placeholder="es. Visita il sito ufficiale"
  924. />
  925. <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
  926. <p className="text-xs text-gray-500 mt-1">Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l&rsquo;URL stessa.</p>
  927. </div>
  928. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
  929. <label className="flex items-start gap-3 cursor-pointer">
  930. <input
  931. type="checkbox"
  932. checked={!!isEditing.redirectOnClick}
  933. onChange={e => setIsEditing({ ...isEditing, redirectOnClick: e.target.checked })}
  934. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  935. />
  936. <div>
  937. <span className="block text-sm font-semibold text-gray-900">Redirect on click</span>
  938. <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>
  939. </div>
  940. </label>
  941. </div>
  942. </>
  943. ) : (
  944. <div>
  945. <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
  946. <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..." />
  947. <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
  948. </div>
  949. ))}
  950. {isEditing.cardType !== 'BOOK' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
  951. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3">
  952. <label className="flex items-start gap-3 cursor-pointer">
  953. <input
  954. type="checkbox"
  955. checked={!!isEditing.autoFullscreen}
  956. onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
  957. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  958. />
  959. <div>
  960. <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
  961. <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
  962. </div>
  963. </label>
  964. <label className="flex items-start gap-3 cursor-pointer">
  965. <input
  966. type="checkbox"
  967. checked={!!isEditing.skipPreview}
  968. onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
  969. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  970. />
  971. <div>
  972. <span className="block text-sm font-semibold text-gray-900">Cover not in the gallery</span>
  973. <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>
  974. </div>
  975. </label>
  976. </div>
  977. )}
  978. </div>
  979. <div className="space-y-5">
  980. {/* Cover Image — per FULLSCREEN_LOCK è il contenuto kiosk a tutto schermo e accetta anche video */}
  981. <div>
  982. <label className="block text-sm font-semibold text-gray-800 mb-1">
  983. {isEditing.cardType === 'FULLSCREEN_LOCK'
  984. ? <>Contenuto a schermo intero <span className="text-gray-400 font-normal text-xs">(immagine o video)</span></>
  985. : <>Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span></>}
  986. </label>
  987. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  988. <input
  989. type="file"
  990. accept={isEditing.cardType === 'FULLSCREEN_LOCK' ? 'image/*,video/mp4,video/webm,.mp4,.webm,.mov,.m4v' : 'image/*'}
  991. onChange={e => handleUpload(e, 'imageUrl')}
  992. className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer"
  993. />
  994. {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
  995. </div>
  996. {isEditing.imageUrl && (
  997. <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
  998. {isVideoUrl(isEditing.imageUrl) ? (
  999. <video src={isEditing.imageUrl} className="w-full h-32 object-cover" muted playsInline />
  1000. ) : (
  1001. <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
  1002. )}
  1003. <a
  1004. href={isEditing.imageUrl}
  1005. download={extractFileName(isEditing.imageUrl)}
  1006. 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"
  1007. title="Scarica"
  1008. aria-label="Scarica cover"
  1009. >⬇</a>
  1010. <button
  1011. onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
  1012. 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"
  1013. title="Remove cover image"
  1014. >✕</button>
  1015. </div>
  1016. )}
  1017. </div>
  1018. {/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) e FULLSCREEN_LOCK (solo contenuto kiosk) */}
  1019. {isEditing.cardType !== 'INFO_PAGE' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
  1020. <div>
  1021. <label className="block text-sm font-semibold text-gray-800 mb-1">
  1022. Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become images)</span>
  1023. </label>
  1024. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  1025. <input
  1026. type="file"
  1027. 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"
  1028. multiple
  1029. onChange={handleUploadExtraMedia}
  1030. className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100 cursor-pointer"
  1031. />
  1032. {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
  1033. {pdfProgress && (
  1034. <p className="mt-2 text-sm text-purple-600 font-medium">
  1035. Processing &ldquo;{pdfProgress.name}&rdquo;: page {pdfProgress.page} of {pdfProgress.total}
  1036. </p>
  1037. )}
  1038. </div>
  1039. {(isEditing.extraMedia || []).length > 0 && (
  1040. <div className="mt-3 space-y-2">
  1041. {(isEditing.extraMedia || []).map((item, i) => {
  1042. const video = isVideoUrl(item.url);
  1043. const tc = transcodeJobs[item.url];
  1044. const isTranscoding = !!tc && (tc.status === 'queued' || tc.status === 'running');
  1045. return (
  1046. <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
  1047. <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
  1048. {video ? (
  1049. <>
  1050. <video src={item.url} className="w-full h-full object-cover" muted preload="metadata" />
  1051. <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
  1052. </>
  1053. ) : (
  1054. <img src={item.url} className="w-full h-full object-cover" alt="" />
  1055. )}
  1056. {isTranscoding && (
  1057. <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">
  1058. <span>Transcoding</span>
  1059. <span>{Math.round((tc.progress || 0) * 100)}%</span>
  1060. </div>
  1061. )}
  1062. <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
  1063. </div>
  1064. <div className="flex-1 min-w-0">
  1065. <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
  1066. {video ? 'Video' : 'Image'}
  1067. </div>
  1068. {video && (
  1069. <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
  1070. <label className="flex items-center gap-2 cursor-pointer">
  1071. <input
  1072. type="checkbox"
  1073. checked={!!item.autoplay}
  1074. onChange={() => toggleAutoplay(i)}
  1075. className="w-4 h-4 text-blue-600 rounded"
  1076. />
  1077. <span className="text-sm text-gray-700">Autoplay</span>
  1078. </label>
  1079. <label className="flex items-center gap-2 cursor-pointer">
  1080. <input
  1081. type="checkbox"
  1082. checked={!!item.muted}
  1083. onChange={() => toggleMuted(i)}
  1084. className="w-4 h-4 text-blue-600 rounded"
  1085. />
  1086. <span className="text-sm text-gray-700">Muted</span>
  1087. </label>
  1088. </div>
  1089. )}
  1090. </div>
  1091. <button
  1092. onClick={() => moveExtraMedia(i, 'up')}
  1093. 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"
  1094. title="Sposta su"
  1095. aria-label="Sposta su"
  1096. disabled={i === 0}
  1097. >↑</button>
  1098. <button
  1099. onClick={() => moveExtraMedia(i, 'down')}
  1100. 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"
  1101. title="Sposta giù"
  1102. aria-label="Sposta giù"
  1103. disabled={i === (isEditing.extraMedia || []).length - 1}
  1104. >↓</button>
  1105. <a
  1106. href={item.url}
  1107. download={extractFileName(item.url)}
  1108. 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"
  1109. title="Scarica"
  1110. aria-label="Scarica"
  1111. >⬇</a>
  1112. <button
  1113. onClick={() => removeExtraMedia(i)}
  1114. className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
  1115. title="Remove"
  1116. >✕</button>
  1117. </div>
  1118. );
  1119. })}
  1120. </div>
  1121. )}
  1122. </div>
  1123. )}
  1124. </div>
  1125. </div>
  1126. <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
  1127. <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
  1128. Cancel
  1129. </button>
  1130. <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">
  1131. Save Card
  1132. </button>
  1133. </div>
  1134. </div>
  1135. </div>
  1136. )}
  1137. {/* CUSTOM CONFIRM DIALOG */}
  1138. {confirmDialog && (
  1139. <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">
  1140. <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
  1141. <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
  1142. <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
  1143. <div className="flex justify-end gap-3">
  1144. <button
  1145. onClick={() => setConfirmDialog(null)}
  1146. className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
  1147. >
  1148. Cancel
  1149. </button>
  1150. <button
  1151. onClick={confirmDialog.onConfirm}
  1152. className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
  1153. >
  1154. Delete
  1155. </button>
  1156. </div>
  1157. </div>
  1158. </div>
  1159. )}
  1160. {/* CUSTOM TOAST NOTIFICATION */}
  1161. {toast && (
  1162. <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 ${
  1163. toast.type === 'error' ? 'bg-red-700' : 'bg-gray-900'
  1164. }`}>
  1165. <div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-sm shrink-0 mt-0.5 ${
  1166. toast.type === 'error' ? 'bg-white text-red-700' : 'bg-green-500 text-gray-900'
  1167. }`}>
  1168. {toast.type === 'error' ? '!' : '✓'}
  1169. </div>
  1170. <span className="font-medium leading-snug">{toast.message}</span>
  1171. </div>
  1172. )}
  1173. </div>
  1174. );
  1175. }