Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

909 řádky
47 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 } from '@/lib/config';
  5. function StyledSelect<T extends string>({
  6. value,
  7. onChange,
  8. options,
  9. }: {
  10. value: T;
  11. onChange: (v: T) => void;
  12. options: { value: T; label: string }[];
  13. }) {
  14. const [open, setOpen] = useState(false);
  15. const ref = useRef<HTMLDivElement>(null);
  16. useEffect(() => {
  17. if (!open) return;
  18. const onClick = (e: MouseEvent) => {
  19. if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
  20. };
  21. const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
  22. document.addEventListener('mousedown', onClick);
  23. document.addEventListener('keydown', onKey);
  24. return () => {
  25. document.removeEventListener('mousedown', onClick);
  26. document.removeEventListener('keydown', onKey);
  27. };
  28. }, [open]);
  29. const current = options.find(o => o.value === value);
  30. // Fallback: se il value non matcha nessuna opzione (es. tipo disattivato dalla flag), mostra il valore raw prettificato
  31. const displayLabel = current?.label
  32. ?? (typeof value === 'string' && value.length > 0
  33. ? value.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
  34. : '');
  35. 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";
  36. return (
  37. <div ref={ref} className="relative">
  38. <button
  39. type="button"
  40. onClick={() => setOpen(o => !o)}
  41. className={`${inputBase} text-left flex items-center justify-between cursor-pointer`}
  42. >
  43. <span className={displayLabel ? '' : 'text-gray-400'}>{displayLabel || 'Seleziona…'}</span>
  44. <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
  45. </button>
  46. {open && (
  47. <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">
  48. {options.map(o => (
  49. <button
  50. key={o.value}
  51. type="button"
  52. onClick={() => { onChange(o.value); setOpen(false); }}
  53. 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'}`}
  54. >
  55. {o.label}
  56. </button>
  57. ))}
  58. </div>
  59. )}
  60. </div>
  61. );
  62. }
  63. 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';
  64. // Sottoinsieme di formati video davvero riproducibili dai browser moderni
  65. const PLAYBACK_SUPPORTED_VIDEO = 'mp4|m4v|webm|mov|qt|ogv|ogg';
  66. const PLAYBACK_SUPPORTED_LABEL = 'MP4, M4V, WebM, MOV, OGV';
  67. const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
  68. const isPdfFile = (file: File) =>
  69. file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
  70. const isVideoFile = (file: File) =>
  71. file.type.startsWith('video/') || new RegExp(`\\.(${VIDEO_EXTENSIONS})$`, 'i').test(file.name);
  72. const isPlayableVideoFile = (file: File) =>
  73. new RegExp(`\\.(${PLAYBACK_SUPPORTED_VIDEO})$`, 'i').test(file.name);
  74. const extractFileName = (url: string): string => {
  75. const match = url.match(/[?&]name=([^&]+)/);
  76. if (match) return decodeURIComponent(match[1]);
  77. const seg = url.split('/').pop() || 'download';
  78. return seg.split('?')[0];
  79. };
  80. async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> {
  81. const formData = new FormData();
  82. formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
  83. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  84. const data = await res.json();
  85. return data.url || null;
  86. }
  87. async function extractVideoFrame(file: File): Promise<Blob | null> {
  88. const url = URL.createObjectURL(file);
  89. try {
  90. const video = document.createElement('video');
  91. video.muted = true;
  92. video.playsInline = true;
  93. video.preload = 'metadata';
  94. video.src = url;
  95. await new Promise<void>((resolve, reject) => {
  96. video.addEventListener('loadedmetadata', () => resolve(), { once: true });
  97. video.addEventListener('error', () => reject(new Error('video load error')), { once: true });
  98. });
  99. // Seek slightly past 0 — at exactly 0 some codecs return a black frame
  100. video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10));
  101. await new Promise<void>((resolve, reject) => {
  102. video.addEventListener('seeked', () => resolve(), { once: true });
  103. video.addEventListener('error', () => reject(new Error('video seek error')), { once: true });
  104. });
  105. const canvas = document.createElement('canvas');
  106. canvas.width = video.videoWidth;
  107. canvas.height = video.videoHeight;
  108. const ctx = canvas.getContext('2d');
  109. if (!ctx) return null;
  110. ctx.drawImage(video, 0, 0);
  111. return await new Promise<Blob | null>((resolve) =>
  112. canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85)
  113. );
  114. } finally {
  115. URL.revokeObjectURL(url);
  116. }
  117. }
  118. async function pdfToImageItems(
  119. file: File,
  120. onProgress: (page: number, total: number) => void
  121. ): Promise<MediaItem[]> {
  122. const pdfjs = await import('pdfjs-dist');
  123. // Worker file is copied to /public via the postinstall script
  124. pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
  125. const arrayBuffer = await file.arrayBuffer();
  126. const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
  127. const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  128. const items: MediaItem[] = [];
  129. for (let i = 1; i <= pdf.numPages; i++) {
  130. onProgress(i, pdf.numPages);
  131. const page = await pdf.getPage(i);
  132. const viewport = page.getViewport({ scale: 1.5 });
  133. const canvas = document.createElement('canvas');
  134. canvas.width = viewport.width;
  135. canvas.height = viewport.height;
  136. const ctx = canvas.getContext('2d');
  137. if (!ctx) continue;
  138. await page.render({ canvasContext: ctx, viewport }).promise;
  139. const blob: Blob = await new Promise((resolve, reject) => {
  140. canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
  141. });
  142. const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
  143. if (url) items.push({ url });
  144. }
  145. return items;
  146. }
  147. export default function AdminDashboard() {
  148. const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
  149. // Card State
  150. const [cards, setCards] = useState<Card[]>([]);
  151. const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
  152. // Portal State
  153. const [portal, setPortal] = useState<Partial<Portal>>({});
  154. const [savingPortal, setSavingPortal] = useState(false);
  155. const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
  156. // NEW UI STATES: Toast and Confirm Dialog
  157. const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
  158. const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
  159. const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null);
  160. const [availableFonts, setAvailableFonts] = useState<string[]>([]);
  161. // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config.
  162. const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT;
  163. // Helper to show auto-dismissing toast
  164. const showToast = (message: string, type: 'success' | 'error' = 'success') => {
  165. setToast({ message, type });
  166. setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000);
  167. };
  168. useEffect(() => {
  169. fetch('/api/cards').then(res => res.json()).then(setCards);
  170. fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data));
  171. fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([]));
  172. }, []);
  173. const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
  174. if (!e.target.files?.[0]) return;
  175. setUploading(prev => ({ ...prev, [field]: true }));
  176. const formData = new FormData();
  177. formData.append('file', e.target.files[0]);
  178. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  179. const data = await res.json();
  180. if (data.url) {
  181. if (isPortal) {
  182. setPortal(prev => ({ ...prev, [field]: data.url }));
  183. } else {
  184. setIsEditing(prev => ({ ...prev, [field]: data.url }));
  185. }
  186. }
  187. setUploading(prev => ({ ...prev, [field]: false }));
  188. };
  189. const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
  190. const files = e.target.files;
  191. if (!files || files.length === 0) return;
  192. setUploading(prev => ({ ...prev, extraMedia: true }));
  193. const startedWithoutCover = !isEditing?.imageUrl;
  194. let pendingCover: string | null = null;
  195. const canPromote = () => startedWithoutCover && !pendingCover;
  196. // Pre-filtro: scarta video con formati non riproducibili nei browser
  197. const rejected: string[] = [];
  198. const acceptedFiles: File[] = [];
  199. for (const file of Array.from(files)) {
  200. if (isVideoFile(file) && !isPlayableVideoFile(file)) {
  201. rejected.push(file.name);
  202. } else {
  203. acceptedFiles.push(file);
  204. }
  205. }
  206. if (rejected.length > 0) {
  207. const list = rejected.length <= 3
  208. ? rejected.join(', ')
  209. : `${rejected.slice(0, 3).join(', ')} e altri ${rejected.length - 3}`;
  210. showToast(
  211. `Formato non supportato! I formati supportati sono: ${PLAYBACK_SUPPORTED_LABEL}. File ignorati: ${list}`,
  212. 'error'
  213. );
  214. }
  215. if (acceptedFiles.length === 0) {
  216. setUploading(prev => ({ ...prev, extraMedia: false }));
  217. e.target.value = '';
  218. return;
  219. }
  220. const uploaded: MediaItem[] = [];
  221. for (const file of acceptedFiles) {
  222. try {
  223. if (isPdfFile(file)) {
  224. const items = await pdfToImageItems(file, (page, total) =>
  225. setPdfProgress({ name: file.name, page, total })
  226. );
  227. setPdfProgress(null);
  228. if (items.length > 0 && canPromote()) {
  229. // Promote the first PDF page to cover; skip it from the gallery to avoid duplication.
  230. pendingCover = items[0].url;
  231. uploaded.push(...items.slice(1));
  232. } else {
  233. uploaded.push(...items);
  234. }
  235. } else {
  236. const formData = new FormData();
  237. formData.append('file', file);
  238. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  239. const data = await res.json();
  240. if (!data.url) continue;
  241. if (isVideoFile(file)) {
  242. // Video always goes to the gallery so users can play it.
  243. uploaded.push({ url: data.url });
  244. // If no cover yet, extract the first frame and use it as the cover.
  245. if (canPromote()) {
  246. try {
  247. const blob = await extractVideoFrame(file);
  248. if (blob) {
  249. const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  250. const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`);
  251. if (posterUrl) pendingCover = posterUrl;
  252. }
  253. } catch (err) {
  254. console.warn('Could not extract video poster for', file.name, err);
  255. }
  256. }
  257. } else {
  258. // Plain image
  259. if (canPromote()) {
  260. // Promote to cover; skip the gallery to avoid duplication.
  261. pendingCover = data.url;
  262. } else {
  263. uploaded.push({ url: data.url });
  264. }
  265. }
  266. }
  267. } catch (err) {
  268. console.error('Upload failed for', file.name, err);
  269. showToast(`Failed to process "${file.name}".`);
  270. setPdfProgress(null);
  271. }
  272. }
  273. setIsEditing(prev => ({
  274. ...prev,
  275. imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''),
  276. extraMedia: [...(prev?.extraMedia || []), ...uploaded],
  277. }));
  278. setUploading(prev => ({ ...prev, extraMedia: false }));
  279. e.target.value = '';
  280. };
  281. const removeExtraMedia = (index: number) => {
  282. setIsEditing(prev => ({
  283. ...prev,
  284. extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
  285. }));
  286. };
  287. const moveExtraMedia = (index: number, direction: 'up' | 'down') => {
  288. setIsEditing(prev => {
  289. const items = [...(prev?.extraMedia || [])];
  290. if (direction === 'up' && index > 0) {
  291. [items[index - 1], items[index]] = [items[index], items[index - 1]];
  292. } else if (direction === 'down' && index < items.length - 1) {
  293. [items[index + 1], items[index]] = [items[index], items[index + 1]];
  294. } else {
  295. return prev;
  296. }
  297. return { ...prev, extraMedia: items };
  298. });
  299. };
  300. const toggleAutoplay = (index: number) => {
  301. setIsEditing(prev => ({
  302. ...prev,
  303. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  304. i === index ? { ...m, autoplay: !m.autoplay } : m
  305. ),
  306. }));
  307. };
  308. const toggleMuted = (index: number) => {
  309. setIsEditing(prev => ({
  310. ...prev,
  311. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  312. i === index ? { ...m, muted: !m.muted } : m
  313. ),
  314. }));
  315. };
  316. const handleSaveCard = async () => {
  317. if (!isEditing) return;
  318. const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
  319. const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
  320. await fetch('/api/cards', {
  321. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
  322. });
  323. setCards(prev => {
  324. const exists = prev.find(c => c.id === newCard.id);
  325. return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
  326. });
  327. setIsEditing(null);
  328. };
  329. const handleDeleteCard = (id: string) => {
  330. // Replace window.confirm with our custom dialog
  331. setConfirmDialog({
  332. message: 'Are you sure you want to delete this card? This action cannot be undone.',
  333. onConfirm: async () => {
  334. await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
  335. setCards(prev => prev.filter(c => c.id !== id));
  336. setConfirmDialog(null);
  337. showToast('Card successfully deleted.');
  338. }
  339. });
  340. };
  341. const moveCard = async (index: number, direction: 'up' | 'down') => {
  342. const newCards = [...cards];
  343. if (direction === 'up' && index > 0) {
  344. [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
  345. } else if (direction === 'down' && index < newCards.length - 1) {
  346. [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
  347. } else {
  348. return; // Do nothing if trying to move out of bounds
  349. }
  350. // Recalculate displayOrder for the whole array
  351. const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
  352. // Optimistically update the UI
  353. setCards(updatedCards);
  354. // Persist the new order to the backend
  355. await fetch('/api/cards', {
  356. method: 'PUT',
  357. headers: { 'Content-Type': 'application/json' },
  358. body: JSON.stringify(updatedCards)
  359. });
  360. };
  361. const handleSavePortal = async () => {
  362. setSavingPortal(true);
  363. await fetch('/api/portals', {
  364. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
  365. });
  366. setSavingPortal(false);
  367. showToast('Portal settings saved successfully!'); // Replaced window.alert
  368. };
  369. // Shared Input Classes for high contrast
  370. 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";
  371. return (
  372. <div className="min-h-screen bg-gray-50 font-sans pb-12">
  373. {/* Top Header */}
  374. <div className="bg-blue-900 text-white shadow-md py-6 px-4">
  375. <div className="max-w-5xl mx-auto flex justify-between items-center">
  376. <div>
  377. <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
  378. <p className="text-sm text-blue-200">Local Administration</p>
  379. </div>
  380. <a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
  381. View Live Portal ↗
  382. </a>
  383. </div>
  384. </div>
  385. <div className="max-w-5xl mx-auto mt-8 px-4">
  386. {/* Tab Navigation */}
  387. <div className="flex space-x-2 mb-6">
  388. <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'}`}>
  389. Manage Cards
  390. </button>
  391. <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'}`}>
  392. Portal Settings
  393. </button>
  394. </div>
  395. <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
  396. {/* TAB: CARDS */}
  397. {activeTab === 'cards' && (
  398. <div className="p-6 md:p-8">
  399. <div className="flex justify-between items-center mb-8 border-b pb-4">
  400. <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
  401. <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">
  402. + Add New Card
  403. </button>
  404. </div>
  405. <div className="space-y-3 mb-8">
  406. {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
  407. {cards.map((card, idx) => (
  408. // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
  409. <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">
  410. <div className="flex items-center gap-4">
  411. {(() => {
  412. const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
  413. if (!previewUrl) {
  414. 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>;
  415. }
  416. return isVideoUrl(previewUrl)
  417. ? <video src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
  418. : <img src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
  419. })()}
  420. <div>
  421. <span className="font-semibold text-gray-800 block">{card.title}</span>
  422. <span className="text-xs text-gray-500 uppercase tracking-wider">{card.cardType}</span>
  423. </div>
  424. </div>
  425. {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
  426. <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
  427. <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
  428. <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
  429. <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
  430. <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
  431. <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
  432. </div>
  433. </div>
  434. ))}
  435. </div>
  436. </div>
  437. )}
  438. {/* TAB: SETTINGS */}
  439. {activeTab === 'settings' && (
  440. <div className="p-6 md:p-8">
  441. <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
  442. <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
  443. <div className="space-y-6">
  444. <div>
  445. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
  446. <input type="text" value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
  447. </div>
  448. <div>
  449. <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
  450. <textarea value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} />
  451. </div>
  452. <div className="flex gap-8">
  453. <div>
  454. <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
  455. <div className="flex items-center gap-4">
  456. <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" />
  457. <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
  458. </div>
  459. </div>
  460. {/* NEW: Max Columns Setting updated for 3 */}
  461. <div className="flex-1">
  462. <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
  463. <input
  464. type="range"
  465. min="3"
  466. max="8"
  467. value={portal.maxGridColumns || 5}
  468. onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
  469. className="w-full mt-3 accent-blue-600"
  470. />
  471. <div className="flex justify-between text-xs text-gray-400 mt-1">
  472. <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
  473. </div>
  474. </div>
  475. </div>
  476. <div>
  477. <label className="block text-sm font-semibold text-gray-700 mb-1">Font del portale</label>
  478. <StyledSelect<string>
  479. value={portal.fontFamily ?? ''}
  480. onChange={(v) => setPortal({ ...portal, fontFamily: v })}
  481. options={[
  482. { value: '', label: 'Sistema (Arial)' },
  483. ...availableFonts.map(f => ({ value: f, label: f.replace(/\.(woff2?|ttf|otf)$/i, '') })),
  484. ]}
  485. />
  486. </div>
  487. </div>
  488. <div className="space-y-6">
  489. {/* Logo Upload with Remove Button */}
  490. <div>
  491. <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
  492. <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" />
  493. {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  494. {portal.logoUrl && (
  495. <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
  496. <img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
  497. <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>
  498. <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>
  499. </div>
  500. )}
  501. </div>
  502. {/* Hero Upload with Remove Button */}
  503. <div>
  504. <label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label>
  505. <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" />
  506. {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  507. {portal.heroImageUrl && (
  508. <div className="mt-2 relative rounded shadow border inline-block w-full">
  509. <img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
  510. <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>
  511. <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>
  512. </div>
  513. )}
  514. </div>
  515. <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3">
  516. <label className="flex items-center gap-3 cursor-pointer">
  517. <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
  518. <div>
  519. <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
  520. <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>
  521. </div>
  522. </label>
  523. <label className="flex items-center gap-3 cursor-pointer">
  524. <input
  525. type="checkbox"
  526. checked={portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT}
  527. onChange={e => setPortal({...portal, externalLinkEnabled: e.target.checked})}
  528. className="w-5 h-5 text-blue-600 rounded"
  529. />
  530. <div>
  531. <span className="block text-sm font-semibold text-gray-900">Abilita &ldquo;External Link&rdquo;</span>
  532. <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>
  533. </div>
  534. </label>
  535. </div>
  536. </div>
  537. </div>
  538. <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
  539. <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">
  540. {savingPortal ? 'Saving...' : 'Save Portal Settings'}
  541. </button>
  542. </div>
  543. </div>
  544. )}
  545. </div>
  546. </div>
  547. {/* MODAL FOR EDITING/CREATING CARDS */}
  548. {isEditing && (
  549. <div
  550. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
  551. onClick={() => setIsEditing(null)} // Click outside to close
  552. >
  553. <div
  554. 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"
  555. onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
  556. >
  557. <button
  558. onClick={() => setIsEditing(null)}
  559. 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"
  560. >
  561. </button>
  562. <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
  563. {isEditing.id ? 'Edit Card' : 'Create New Card'}
  564. </h3>
  565. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  566. <div className="space-y-5">
  567. <div>
  568. <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
  569. <input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" />
  570. </div>
  571. <div>
  572. <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
  573. <StyledSelect<CardType>
  574. value={(isEditing.cardType || 'INFO_PAGE') as CardType}
  575. onChange={(v) => setIsEditing({ ...isEditing, cardType: v })}
  576. options={[
  577. { value: 'INFO_PAGE', label: 'Info Page' },
  578. { value: 'IMAGE_GALLERY', label: 'Image Gallery' },
  579. ...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
  580. ]}
  581. />
  582. </div>
  583. {isEditing.cardType === 'EXTERNAL_LINK' ? (
  584. <>
  585. <div>
  586. <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label>
  587. <input
  588. type="url"
  589. value={isEditing.actionUrl || ''}
  590. onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
  591. className={inputClasses}
  592. placeholder="https://esempio.it/pagina"
  593. />
  594. </div>
  595. <div>
  596. <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label>
  597. <input
  598. type="text"
  599. value={isEditing.shortDescription || ''}
  600. onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
  601. className={inputClasses}
  602. placeholder="es. Visita il sito ufficiale"
  603. />
  604. <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>
  605. </div>
  606. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
  607. <label className="flex items-start gap-3 cursor-pointer">
  608. <input
  609. type="checkbox"
  610. checked={!!isEditing.redirectOnClick}
  611. onChange={e => setIsEditing({ ...isEditing, redirectOnClick: e.target.checked })}
  612. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  613. />
  614. <div>
  615. <span className="block text-sm font-semibold text-gray-900">Redirect on click</span>
  616. <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>
  617. </div>
  618. </label>
  619. </div>
  620. </>
  621. ) : (
  622. <div>
  623. <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
  624. <textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." />
  625. </div>
  626. )}
  627. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3">
  628. <label className="flex items-start gap-3 cursor-pointer">
  629. <input
  630. type="checkbox"
  631. checked={!!isEditing.autoFullscreen}
  632. onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
  633. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  634. />
  635. <div>
  636. <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
  637. <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
  638. </div>
  639. </label>
  640. <label className="flex items-start gap-3 cursor-pointer">
  641. <input
  642. type="checkbox"
  643. checked={!!isEditing.skipPreview}
  644. onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
  645. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  646. />
  647. <div>
  648. <span className="block text-sm font-semibold text-gray-900">Don&rsquo;t show the cover as a slide in the gallery.</span>
  649. <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>
  650. </div>
  651. </label>
  652. </div>
  653. </div>
  654. <div className="space-y-5">
  655. {/* Cover Image */}
  656. <div>
  657. <label className="block text-sm font-semibold text-gray-800 mb-1">
  658. Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span>
  659. </label>
  660. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  661. <input type="file" accept="image/*" onChange={e => handleUpload(e, 'imageUrl')} 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" />
  662. {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
  663. </div>
  664. {isEditing.imageUrl && (
  665. <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
  666. <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
  667. <a
  668. href={isEditing.imageUrl}
  669. download={extractFileName(isEditing.imageUrl)}
  670. 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"
  671. title="Scarica"
  672. aria-label="Scarica cover"
  673. >⬇</a>
  674. <button
  675. onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
  676. 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"
  677. title="Remove cover image"
  678. >✕</button>
  679. </div>
  680. )}
  681. </div>
  682. {/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) */}
  683. {isEditing.cardType !== 'INFO_PAGE' && (
  684. <div>
  685. <label className="block text-sm font-semibold text-gray-800 mb-1">
  686. Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become images)</span>
  687. </label>
  688. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  689. <input
  690. type="file"
  691. 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"
  692. multiple
  693. onChange={handleUploadExtraMedia}
  694. 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"
  695. />
  696. {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
  697. {pdfProgress && (
  698. <p className="mt-2 text-sm text-purple-600 font-medium">
  699. Processing &ldquo;{pdfProgress.name}&rdquo;: page {pdfProgress.page} of {pdfProgress.total}
  700. </p>
  701. )}
  702. </div>
  703. {(isEditing.extraMedia || []).length > 0 && (
  704. <div className="mt-3 space-y-2">
  705. {(isEditing.extraMedia || []).map((item, i) => {
  706. const video = isVideoUrl(item.url);
  707. return (
  708. <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
  709. <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
  710. {video ? (
  711. <>
  712. <video src={item.url} className="w-full h-full object-cover" muted preload="metadata" />
  713. <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
  714. </>
  715. ) : (
  716. <img src={item.url} className="w-full h-full object-cover" alt="" />
  717. )}
  718. <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
  719. </div>
  720. <div className="flex-1 min-w-0">
  721. <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
  722. {video ? 'Video' : 'Image'}
  723. </div>
  724. {video && (
  725. <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
  726. <label className="flex items-center gap-2 cursor-pointer">
  727. <input
  728. type="checkbox"
  729. checked={!!item.autoplay}
  730. onChange={() => toggleAutoplay(i)}
  731. className="w-4 h-4 text-blue-600 rounded"
  732. />
  733. <span className="text-sm text-gray-700">Autoplay</span>
  734. </label>
  735. <label className="flex items-center gap-2 cursor-pointer">
  736. <input
  737. type="checkbox"
  738. checked={!!item.muted}
  739. onChange={() => toggleMuted(i)}
  740. className="w-4 h-4 text-blue-600 rounded"
  741. />
  742. <span className="text-sm text-gray-700">Muted</span>
  743. </label>
  744. </div>
  745. )}
  746. </div>
  747. <button
  748. onClick={() => moveExtraMedia(i, 'up')}
  749. className="bg-gray-200 hover:bg-gray-300 text-gray-700 w-8 h-8 rounded-full text-sm font-bold shrink-0 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
  750. title="Sposta su"
  751. aria-label="Sposta su"
  752. disabled={i === 0}
  753. >↑</button>
  754. <button
  755. onClick={() => moveExtraMedia(i, 'down')}
  756. className="bg-gray-200 hover:bg-gray-300 text-gray-700 w-8 h-8 rounded-full text-sm font-bold shrink-0 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
  757. title="Sposta giù"
  758. aria-label="Sposta giù"
  759. disabled={i === (isEditing.extraMedia || []).length - 1}
  760. >↓</button>
  761. <a
  762. href={item.url}
  763. download={extractFileName(item.url)}
  764. 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"
  765. title="Scarica"
  766. aria-label="Scarica"
  767. >⬇</a>
  768. <button
  769. onClick={() => removeExtraMedia(i)}
  770. className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
  771. title="Remove"
  772. >✕</button>
  773. </div>
  774. );
  775. })}
  776. </div>
  777. )}
  778. </div>
  779. )}
  780. </div>
  781. </div>
  782. <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
  783. <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
  784. Cancel
  785. </button>
  786. <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">
  787. Save Card
  788. </button>
  789. </div>
  790. </div>
  791. </div>
  792. )}
  793. {/* CUSTOM CONFIRM DIALOG */}
  794. {confirmDialog && (
  795. <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">
  796. <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
  797. <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
  798. <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
  799. <div className="flex justify-end gap-3">
  800. <button
  801. onClick={() => setConfirmDialog(null)}
  802. className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
  803. >
  804. Cancel
  805. </button>
  806. <button
  807. onClick={confirmDialog.onConfirm}
  808. className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
  809. >
  810. Delete
  811. </button>
  812. </div>
  813. </div>
  814. </div>
  815. )}
  816. {/* CUSTOM TOAST NOTIFICATION */}
  817. {toast && (
  818. <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 ${
  819. toast.type === 'error' ? 'bg-red-700' : 'bg-gray-900'
  820. }`}>
  821. <div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-sm shrink-0 mt-0.5 ${
  822. toast.type === 'error' ? 'bg-white text-red-700' : 'bg-green-500 text-gray-900'
  823. }`}>
  824. {toast.type === 'error' ? '!' : '✓'}
  825. </div>
  826. <span className="font-medium leading-snug">{toast.message}</span>
  827. </div>
  828. )}
  829. </div>
  830. );
  831. }