Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

601 строка
31 KiB

  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { Card, Portal, MediaItem } from '@/types';
  4. const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url);
  5. const isPdfFile = (file: File) =>
  6. file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
  7. async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> {
  8. const formData = new FormData();
  9. formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
  10. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  11. const data = await res.json();
  12. return data.url || null;
  13. }
  14. async function pdfToImageItems(
  15. file: File,
  16. onProgress: (page: number, total: number) => void
  17. ): Promise<MediaItem[]> {
  18. const pdfjs = await import('pdfjs-dist');
  19. // Worker file is copied to /public via the postinstall script
  20. pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
  21. const arrayBuffer = await file.arrayBuffer();
  22. const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
  23. const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
  24. const items: MediaItem[] = [];
  25. for (let i = 1; i <= pdf.numPages; i++) {
  26. onProgress(i, pdf.numPages);
  27. const page = await pdf.getPage(i);
  28. const viewport = page.getViewport({ scale: 1.5 });
  29. const canvas = document.createElement('canvas');
  30. canvas.width = viewport.width;
  31. canvas.height = viewport.height;
  32. const ctx = canvas.getContext('2d');
  33. if (!ctx) continue;
  34. await page.render({ canvasContext: ctx, viewport }).promise;
  35. const blob: Blob = await new Promise((resolve, reject) => {
  36. canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
  37. });
  38. const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
  39. if (url) items.push({ url });
  40. }
  41. return items;
  42. }
  43. export default function AdminDashboard() {
  44. const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
  45. // Card State
  46. const [cards, setCards] = useState<Card[]>([]);
  47. const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
  48. // Portal State
  49. const [portal, setPortal] = useState<Partial<Portal>>({});
  50. const [savingPortal, setSavingPortal] = useState(false);
  51. const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
  52. // NEW UI STATES: Toast and Confirm Dialog
  53. const [toast, setToast] = useState<string | null>(null);
  54. const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
  55. const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null);
  56. // Helper to show auto-dismissing toast
  57. const showToast = (message: string) => {
  58. setToast(message);
  59. setTimeout(() => setToast(null), 3000);
  60. };
  61. useEffect(() => {
  62. fetch('/api/cards').then(res => res.json()).then(setCards);
  63. fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data));
  64. }, []);
  65. const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
  66. if (!e.target.files?.[0]) return;
  67. setUploading(prev => ({ ...prev, [field]: true }));
  68. const formData = new FormData();
  69. formData.append('file', e.target.files[0]);
  70. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  71. const data = await res.json();
  72. if (data.url) {
  73. if (isPortal) {
  74. setPortal(prev => ({ ...prev, [field]: data.url }));
  75. } else {
  76. setIsEditing(prev => ({ ...prev, [field]: data.url }));
  77. }
  78. }
  79. setUploading(prev => ({ ...prev, [field]: false }));
  80. };
  81. const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
  82. const files = e.target.files;
  83. if (!files || files.length === 0) return;
  84. setUploading(prev => ({ ...prev, extraMedia: true }));
  85. const uploaded: MediaItem[] = [];
  86. for (const file of Array.from(files)) {
  87. try {
  88. if (isPdfFile(file)) {
  89. const items = await pdfToImageItems(file, (page, total) =>
  90. setPdfProgress({ name: file.name, page, total })
  91. );
  92. uploaded.push(...items);
  93. setPdfProgress(null);
  94. } else {
  95. const formData = new FormData();
  96. formData.append('file', file);
  97. const res = await fetch('/api/upload', { method: 'POST', body: formData });
  98. const data = await res.json();
  99. if (data.url) uploaded.push({ url: data.url });
  100. }
  101. } catch (err) {
  102. console.error('Upload failed for', file.name, err);
  103. showToast(`Failed to process "${file.name}".`);
  104. setPdfProgress(null);
  105. }
  106. }
  107. setIsEditing(prev => ({
  108. ...prev,
  109. extraMedia: [...(prev?.extraMedia || []), ...uploaded],
  110. }));
  111. setUploading(prev => ({ ...prev, extraMedia: false }));
  112. e.target.value = '';
  113. };
  114. const removeExtraMedia = (index: number) => {
  115. setIsEditing(prev => ({
  116. ...prev,
  117. extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
  118. }));
  119. };
  120. const toggleAutoplay = (index: number) => {
  121. setIsEditing(prev => ({
  122. ...prev,
  123. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  124. i === index ? { ...m, autoplay: !m.autoplay } : m
  125. ),
  126. }));
  127. };
  128. const toggleMuted = (index: number) => {
  129. setIsEditing(prev => ({
  130. ...prev,
  131. extraMedia: (prev?.extraMedia || []).map((m, i) =>
  132. i === index ? { ...m, muted: !m.muted } : m
  133. ),
  134. }));
  135. };
  136. const handleSaveCard = async () => {
  137. if (!isEditing) return;
  138. const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
  139. const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
  140. await fetch('/api/cards', {
  141. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
  142. });
  143. setCards(prev => {
  144. const exists = prev.find(c => c.id === newCard.id);
  145. return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
  146. });
  147. setIsEditing(null);
  148. };
  149. const handleDeleteCard = (id: string) => {
  150. // Replace window.confirm with our custom dialog
  151. setConfirmDialog({
  152. message: 'Are you sure you want to delete this card? This action cannot be undone.',
  153. onConfirm: async () => {
  154. await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
  155. setCards(prev => prev.filter(c => c.id !== id));
  156. setConfirmDialog(null);
  157. showToast('Card successfully deleted.');
  158. }
  159. });
  160. };
  161. const moveCard = async (index: number, direction: 'up' | 'down') => {
  162. const newCards = [...cards];
  163. if (direction === 'up' && index > 0) {
  164. [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
  165. } else if (direction === 'down' && index < newCards.length - 1) {
  166. [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
  167. } else {
  168. return; // Do nothing if trying to move out of bounds
  169. }
  170. // Recalculate displayOrder for the whole array
  171. const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
  172. // Optimistically update the UI
  173. setCards(updatedCards);
  174. // Persist the new order to the backend
  175. await fetch('/api/cards', {
  176. method: 'PUT',
  177. headers: { 'Content-Type': 'application/json' },
  178. body: JSON.stringify(updatedCards)
  179. });
  180. };
  181. const handleSavePortal = async () => {
  182. setSavingPortal(true);
  183. await fetch('/api/portals', {
  184. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
  185. });
  186. setSavingPortal(false);
  187. showToast('Portal settings saved successfully!'); // Replaced window.alert
  188. };
  189. // Shared Input Classes for high contrast
  190. 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";
  191. return (
  192. <div className="min-h-screen bg-gray-50 font-sans pb-12">
  193. {/* Top Header */}
  194. <div className="bg-blue-900 text-white shadow-md py-6 px-4">
  195. <div className="max-w-5xl mx-auto flex justify-between items-center">
  196. <div>
  197. <h1 className="text-2xl font-bold">Captive Portal CMS</h1>
  198. <p className="text-sm text-blue-200">Local Administration</p>
  199. </div>
  200. <a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
  201. View Live Portal ↗
  202. </a>
  203. </div>
  204. </div>
  205. <div className="max-w-5xl mx-auto mt-8 px-4">
  206. {/* Tab Navigation */}
  207. <div className="flex space-x-2 mb-6">
  208. <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'}`}>
  209. Manage Cards
  210. </button>
  211. <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'}`}>
  212. Portal Settings
  213. </button>
  214. </div>
  215. <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
  216. {/* TAB: CARDS */}
  217. {activeTab === 'cards' && (
  218. <div className="p-6 md:p-8">
  219. <div className="flex justify-between items-center mb-8 border-b pb-4">
  220. <h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
  221. <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">
  222. + Add New Card
  223. </button>
  224. </div>
  225. <div className="space-y-3 mb-8">
  226. {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
  227. {cards.map((card, idx) => (
  228. // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
  229. <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">
  230. <div className="flex items-center gap-4">
  231. {card.imageUrl ? <img src={card.imageUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" /> : <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>}
  232. <div>
  233. <span className="font-semibold text-gray-800 block">{card.title}</span>
  234. <span className="text-xs text-gray-500 uppercase tracking-wider">{card.cardType}</span>
  235. </div>
  236. </div>
  237. {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
  238. <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
  239. <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
  240. <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
  241. <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
  242. <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
  243. <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
  244. </div>
  245. </div>
  246. ))}
  247. </div>
  248. </div>
  249. )}
  250. {/* TAB: SETTINGS */}
  251. {activeTab === 'settings' && (
  252. <div className="p-6 md:p-8">
  253. <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
  254. <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
  255. <div className="space-y-6">
  256. <div>
  257. <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label>
  258. <input type="text" value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} />
  259. </div>
  260. <div>
  261. <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
  262. <textarea value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} />
  263. </div>
  264. <div className="flex gap-8">
  265. <div>
  266. <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
  267. <div className="flex items-center gap-4">
  268. <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" />
  269. <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
  270. </div>
  271. </div>
  272. {/* NEW: Max Columns Setting updated for 3 */}
  273. <div className="flex-1">
  274. <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
  275. <input
  276. type="range"
  277. min="3"
  278. max="8"
  279. value={portal.maxGridColumns || 5}
  280. onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
  281. className="w-full mt-3 accent-blue-600"
  282. />
  283. <div className="flex justify-between text-xs text-gray-400 mt-1">
  284. <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
  285. </div>
  286. </div>
  287. </div>
  288. </div>
  289. <div className="space-y-6">
  290. {/* Logo Upload with Remove Button */}
  291. <div>
  292. <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
  293. <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" />
  294. {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  295. {portal.logoUrl && (
  296. <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
  297. <img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
  298. <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>
  299. </div>
  300. )}
  301. </div>
  302. {/* Hero Upload with Remove Button */}
  303. <div>
  304. <label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label>
  305. <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" />
  306. {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
  307. {portal.heroImageUrl && (
  308. <div className="mt-2 relative rounded shadow border inline-block w-full">
  309. <img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
  310. <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>
  311. </div>
  312. )}
  313. </div>
  314. <div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
  315. <label className="flex items-center gap-3 cursor-pointer">
  316. <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
  317. <div>
  318. <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
  319. <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>
  320. </div>
  321. </label>
  322. </div>
  323. </div>
  324. </div>
  325. <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
  326. <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">
  327. {savingPortal ? 'Saving...' : 'Save Portal Settings'}
  328. </button>
  329. </div>
  330. </div>
  331. )}
  332. </div>
  333. </div>
  334. {/* MODAL FOR EDITING/CREATING CARDS */}
  335. {isEditing && (
  336. <div
  337. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
  338. onClick={() => setIsEditing(null)} // Click outside to close
  339. >
  340. <div
  341. 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"
  342. onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
  343. >
  344. <button
  345. onClick={() => setIsEditing(null)}
  346. 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"
  347. >
  348. </button>
  349. <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
  350. {isEditing.id ? 'Edit Card' : 'Create New Card'}
  351. </h3>
  352. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  353. <div className="space-y-5">
  354. <div>
  355. <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
  356. <input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" />
  357. </div>
  358. <div>
  359. <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
  360. <select value={isEditing.cardType || 'INFO_PAGE'} onChange={e => setIsEditing({...isEditing, cardType: e.target.value as any})} className={inputClasses}>
  361. <option value="INFO_PAGE">Info Page</option>
  362. <option value="IMAGE_GALLERY">Image Gallery</option>
  363. <option value="EXTERNAL_LINK">External Link</option>
  364. </select>
  365. </div>
  366. <div>
  367. <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
  368. <textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({...isEditing, shortDescription: e.target.value})} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." />
  369. </div>
  370. <div className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3">
  371. <label className="flex items-start gap-3 cursor-pointer">
  372. <input
  373. type="checkbox"
  374. checked={!!isEditing.autoFullscreen}
  375. onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
  376. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  377. />
  378. <div>
  379. <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
  380. <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
  381. </div>
  382. </label>
  383. <label className="flex items-start gap-3 cursor-pointer">
  384. <input
  385. type="checkbox"
  386. checked={!!isEditing.skipPreview}
  387. onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
  388. className="w-5 h-5 text-blue-600 rounded mt-0.5"
  389. />
  390. <div>
  391. <span className="block text-sm font-semibold text-gray-900">Skip preview</span>
  392. <span className="block text-xs text-gray-600">Don&rsquo;t show the cover as a slide in the gallery. The cover stays as the card thumbnail only. Combine with &ldquo;Auto fullscreen&rdquo; to jump straight into the gallery items.</span>
  393. </div>
  394. </label>
  395. </div>
  396. </div>
  397. <div className="space-y-5">
  398. {/* Cover Image */}
  399. <div>
  400. <label className="block text-sm font-semibold text-gray-800 mb-1">
  401. Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span>
  402. </label>
  403. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  404. <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" />
  405. {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</p>}
  406. </div>
  407. {isEditing.imageUrl && (
  408. <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
  409. <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
  410. <button
  411. onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
  412. 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"
  413. title="Remove cover image"
  414. >✕</button>
  415. </div>
  416. )}
  417. </div>
  418. {/* Gallery Media (images + videos + PDFs) */}
  419. <div>
  420. <label className="block text-sm font-semibold text-gray-800 mb-1">
  421. Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become slides)</span>
  422. </label>
  423. <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
  424. <input
  425. type="file"
  426. accept="image/*,video/*,application/pdf,.pdf"
  427. multiple
  428. onChange={handleUploadExtraMedia}
  429. 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"
  430. />
  431. {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
  432. {pdfProgress && (
  433. <p className="mt-2 text-sm text-purple-600 font-medium">
  434. Processing &ldquo;{pdfProgress.name}&rdquo;: page {pdfProgress.page} of {pdfProgress.total}
  435. </p>
  436. )}
  437. </div>
  438. {(isEditing.extraMedia || []).length > 0 && (
  439. <div className="mt-3 space-y-2">
  440. {(isEditing.extraMedia || []).map((item, i) => {
  441. const video = isVideoUrl(item.url);
  442. return (
  443. <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
  444. <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
  445. {video ? (
  446. <>
  447. <video src={item.url} className="w-full h-full object-cover" muted preload="metadata" />
  448. <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
  449. </>
  450. ) : (
  451. <img src={item.url} className="w-full h-full object-cover" alt="" />
  452. )}
  453. <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
  454. </div>
  455. <div className="flex-1 min-w-0">
  456. <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
  457. {video ? 'Video' : 'Image'}
  458. </div>
  459. {video && (
  460. <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
  461. <label className="flex items-center gap-2 cursor-pointer">
  462. <input
  463. type="checkbox"
  464. checked={!!item.autoplay}
  465. onChange={() => toggleAutoplay(i)}
  466. className="w-4 h-4 text-blue-600 rounded"
  467. />
  468. <span className="text-sm text-gray-700">Autoplay</span>
  469. </label>
  470. <label className="flex items-center gap-2 cursor-pointer">
  471. <input
  472. type="checkbox"
  473. checked={!!item.muted}
  474. onChange={() => toggleMuted(i)}
  475. className="w-4 h-4 text-blue-600 rounded"
  476. />
  477. <span className="text-sm text-gray-700">Muted</span>
  478. </label>
  479. </div>
  480. )}
  481. </div>
  482. <button
  483. onClick={() => removeExtraMedia(i)}
  484. className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
  485. title="Remove"
  486. >✕</button>
  487. </div>
  488. );
  489. })}
  490. </div>
  491. )}
  492. </div>
  493. </div>
  494. </div>
  495. <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
  496. <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
  497. Cancel
  498. </button>
  499. <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">
  500. Save Card
  501. </button>
  502. </div>
  503. </div>
  504. </div>
  505. )}
  506. {/* CUSTOM CONFIRM DIALOG */}
  507. {confirmDialog && (
  508. <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">
  509. <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
  510. <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
  511. <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
  512. <div className="flex justify-end gap-3">
  513. <button
  514. onClick={() => setConfirmDialog(null)}
  515. className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
  516. >
  517. Cancel
  518. </button>
  519. <button
  520. onClick={confirmDialog.onConfirm}
  521. className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
  522. >
  523. Delete
  524. </button>
  525. </div>
  526. </div>
  527. </div>
  528. )}
  529. {/* CUSTOM TOAST NOTIFICATION */}
  530. {toast && (
  531. <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">
  532. <div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-gray-900 font-bold text-sm">
  533. </div>
  534. <span className="font-medium">{toast}</span>
  535. </div>
  536. )}
  537. </div>
  538. );
  539. }