Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 

772 рядки
38 KiB

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