您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 

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