your-ai-workflow-firebase-os 1.2.20 → 1.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { motion, AnimatePresence } from 'framer-motion';
3
- import { Bot, Plus, X, Loader2, Trash2, FileText, Download, Check, Edit2, Info } from 'lucide-react';
4
+ import { Bot, Plus, X, Loader2, Trash2, FileText, Download, Check, Edit2, Info, Link, Image, Video, Archive, Code, FileIcon } from 'lucide-react';
4
5
  import * as LucideIcons from 'lucide-react';
5
6
  import { DashboardNav } from '../components/DashboardNav';
6
7
  import { Button } from '../components/Button';
7
8
  import { useAuth } from '../lib/AuthContext';
8
9
  import { useTheme } from '../lib/ThemeContext';
9
10
  import { db, storage } from '../lib/firebase';
10
- import { collection, addDoc, getDocs, getDoc, query, orderBy, where, serverTimestamp, deleteDoc, updateDoc, doc, onSnapshot, limit } from 'firebase/firestore';
11
+ import { collection, addDoc, getDocs, getDoc, query, orderBy, where, serverTimestamp, deleteDoc, updateDoc, doc, onSnapshot, limit, deleteField } from 'firebase/firestore';
11
12
  import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage';
12
13
  import { ConfirmModal } from '../components/ConfirmModal';
13
14
 
@@ -42,11 +43,25 @@ export function AdminPageTemplate({ config }: { config: any }) {
42
43
  const [copied, setCopied] = useState(false);
43
44
  const [uploading, setUploading] = useState(false);
44
45
  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
46
+ const [_previewFile, _setPreviewFile] = useState<any | null>(null);
47
+ const setPreviewFile = (f: any) => {
48
+ _setPreviewFile(f);
49
+ if (f) {
50
+ const t = f.fileType || '';
51
+ const canPreview = t.startsWith('image/') || t.startsWith('video/') || t === 'application/pdf';
52
+ setPreviewLoading(canPreview);
53
+ }
54
+ };
55
+ const previewFile = _previewFile;
56
+ const [previewLoading, setPreviewLoading] = useState(true);
45
57
  const [isDeleting, setIsDeleting] = useState(false);
46
58
  const [editingId, setEditingId] = useState<string | null>(null);
47
59
  const [editName, setEditName] = useState('');
48
60
  const [editNote, setEditNote] = useState('');
61
+ const [editFields, setEditFields] = useState<{ key: string; value: string }[]>([]);
49
62
  const fileInputRef = useRef<HTMLInputElement>(null);
63
+ const [copiedLink, setCopiedLink] = useState<string | null>(null);
64
+ const originalFieldKeysRef = useRef<string[]>([]);
50
65
 
51
66
 
52
67
  const parsedTabName = (config.tabName || config.pageId || 'admin_page')
@@ -69,7 +84,7 @@ export function AdminPageTemplate({ config }: { config: any }) {
69
84
  // ── Real-time listener for admin records (shared between admins) ─────────
70
85
  useEffect(() => {
71
86
  if (!user) return;
72
-
87
+
73
88
  // 1. Instant Cache Hydration
74
89
  try {
75
90
  const cached = localStorage.getItem(`fbos_records_${recordsCollection}`);
@@ -79,7 +94,7 @@ export function AdminPageTemplate({ config }: { config: any }) {
79
94
  } else {
80
95
  setLoading(true);
81
96
  }
82
- } catch(e) {}
97
+ } catch (e) { }
83
98
 
84
99
  // Helper to serialize records for cache
85
100
  const processItems = (snapDocs: any[]) => {
@@ -212,7 +227,7 @@ export function AdminPageTemplate({ config }: { config: any }) {
212
227
  const fileName = record.downloadURL.split('admin_files%2F')[1]?.split('?')[0];
213
228
  if (fileName) {
214
229
  const fileRef = ref(storage, `admin_files/${decodeURIComponent(fileName)}`);
215
- await deleteObject(fileRef).catch(() => {});
230
+ await deleteObject(fileRef).catch(() => { });
216
231
  }
217
232
  // Delete from admin_files Firestore collection
218
233
  const filesSnap = await getDocs(query(
@@ -236,30 +251,169 @@ export function AdminPageTemplate({ config }: { config: any }) {
236
251
  };
237
252
 
238
253
  // ── Edit record (inline) ──────────────────────────────────────────────────
254
+ // Inline rename state for file records (Drive-style)
255
+ const [renamingFileId, setRenamingFileId] = useState<string | null>(null);
256
+ const [renameFileName, setRenameFileName] = useState('');
257
+ const [isRenamingFile, setIsRenamingFile] = useState(false);
258
+
239
259
  const startEdit = (record: SavedRecord) => {
260
+ // For file records, use inline rename (Drive-style)
261
+ if (record.recordType === 'file') {
262
+ setRenamingFileId(record.id);
263
+ setRenameFileName(record.name);
264
+ return;
265
+ }
240
266
  setEditingId(record.id);
241
267
  setEditName(record.name);
242
268
  setEditNote(record.note || '');
269
+ // Load any extra custom fields (anything beyond the reserved keys)
270
+ const reserved = ['name', 'note', 'uid', 'recordType', 'fileName', 'fileType', 'fileSize', 'downloadURL', 'createdAt', 'creatorName', 'creatorEmail', 'creatorAvatar'];
271
+ const extras = Object.entries(record as any)
272
+ .filter(([k]) => !reserved.includes(k) && k !== 'id')
273
+ .map(([key, value]) => ({ key, value: String(value ?? '') }));
274
+ setEditFields(extras);
275
+ originalFieldKeysRef.current = extras.map(f => f.key);
276
+ };
277
+
278
+ const handleRenameFile = async () => {
279
+ if (!renamingFileId || !renameFileName.trim()) { setRenamingFileId(null); return; }
280
+ const original = records.find(r => r.id === renamingFileId);
281
+ if (original && original.name === renameFileName.trim()) { setRenamingFileId(null); return; }
282
+ setIsRenamingFile(true);
283
+ try {
284
+ await updateDoc(doc(db, recordsCollection, renamingFileId), {
285
+ name: renameFileName.trim(),
286
+ });
287
+ setRecords(prev => prev.map(r =>
288
+ r.id === renamingFileId ? { ...r, name: renameFileName.trim() } : r
289
+ ));
290
+ } catch (e) {
291
+ console.error('Error renaming file:', e);
292
+ }
293
+ setIsRenamingFile(false);
294
+ setRenamingFileId(null);
243
295
  };
244
296
 
245
297
  const handleSaveEdit = async () => {
246
298
  if (!editingId || !editName.trim()) { setEditingId(null); return; }
247
- const original = records.find(r => r.id === editingId);
248
- if (original && original.name === editName.trim() && (original.note || '') === editNote.trim()) {
249
- setEditingId(null); return;
250
- }
299
+ setSaving(true);
300
+ const extraData: Record<string, any> = {};
301
+ editFields.forEach(({ key, value }) => {
302
+ if (key.trim()) extraData[key.trim()] = value;
303
+ });
304
+ const currentKeys = editFields.map(f => f.key.trim()).filter(Boolean);
305
+ const removedKeys = originalFieldKeysRef.current.filter(k => !currentKeys.includes(k));
306
+ removedKeys.forEach(k => { extraData[k] = deleteField(); });
251
307
  try {
252
308
  await updateDoc(doc(db, recordsCollection, editingId), {
253
309
  name: editName.trim(),
254
310
  note: editNote.trim(),
311
+ ...extraData,
255
312
  });
256
- // onSnapshot will update the records list automatically
313
+ setRecords(prev => prev.map(r => {
314
+ if (r.id !== editingId) return r;
315
+ const updated = { ...r, name: editName.trim(), note: editNote.trim() };
316
+ removedKeys.forEach(k => { delete (updated as any)[k]; });
317
+ editFields.forEach(({ key, value }) => { if (key.trim()) (updated as any)[key.trim()] = value; });
318
+ return updated;
319
+ }));
320
+ originalFieldKeysRef.current = currentKeys;
257
321
  } catch (e) {
258
- console.error('Error updating admin record:', e);
322
+ console.error('Error updating record:', e);
259
323
  }
324
+ setSaving(false);
260
325
  setEditingId(null);
261
326
  };
262
327
 
328
+ // Silent save: persist to Firestore without closing modal or showing loader
329
+ const handleSilentSave = async () => {
330
+ if (!editingId || !editName.trim()) return;
331
+ const extraData: Record<string, any> = {};
332
+ editFields.forEach(({ key, value }) => {
333
+ if (key.trim()) extraData[key.trim()] = value;
334
+ });
335
+ const currentKeys = editFields.map(f => f.key.trim()).filter(Boolean);
336
+ const removedKeys = originalFieldKeysRef.current.filter(k => !currentKeys.includes(k));
337
+ removedKeys.forEach(k => { extraData[k] = deleteField(); });
338
+ try {
339
+ await updateDoc(doc(db, recordsCollection, editingId), {
340
+ name: editName.trim(),
341
+ note: editNote.trim(),
342
+ ...extraData,
343
+ });
344
+ setRecords(prev => prev.map(r => {
345
+ if (r.id !== editingId) return r;
346
+ const updated = { ...r, name: editName.trim(), note: editNote.trim() };
347
+ removedKeys.forEach(k => { delete (updated as any)[k]; });
348
+ editFields.forEach(({ key, value }) => { if (key.trim()) (updated as any)[key.trim()] = value; });
349
+ return updated;
350
+ }));
351
+ originalFieldKeysRef.current = currentKeys;
352
+ } catch (e) {
353
+ console.error('Error silently saving record:', e);
354
+ }
355
+ };
356
+
357
+
358
+ // ── File type icon renderer ───────────────────────────────────────────────
359
+ const renderFileIcon = (type?: string, url?: string) => {
360
+ if (!type) return <FileText className="w-4 h-4" />;
361
+ if (type.startsWith('image/')) {
362
+ if (url) return <div className="w-9 h-9 rounded-xl bg-cover bg-center shrink-0" style={{ backgroundImage: `url(${url})` }} />;
363
+ return <Image className="w-4 h-4" />;
364
+ }
365
+ if (type.startsWith('video/') || type.startsWith('audio/')) return <Video className="w-4 h-4" />;
366
+ if (type.includes('pdf') || type.includes('word') || type.includes('text')) return <FileText className="w-4 h-4" />;
367
+ if (type.includes('zip') || type.includes('rar') || type.includes('archive')) return <Archive className="w-4 h-4" />;
368
+ if (type.includes('html') || type.includes('csv') || type.includes('json')) return <Code className="w-4 h-4" />;
369
+ return <FileIcon className="w-4 h-4" />;
370
+ };
371
+
372
+ const renderFileIconBg = (type?: string) => {
373
+ if (!type) return 'bg-foreground/5 text-foreground/40';
374
+ if (type.startsWith('image/')) return 'bg-emerald-500/10 text-emerald-500';
375
+ if (type.startsWith('video/') || type.startsWith('audio/')) return 'bg-purple-500/10 text-purple-500';
376
+ if (type.includes('pdf') || type.includes('word') || type.includes('text')) return 'bg-blue-500/10 text-blue-500';
377
+ if (type.includes('zip') || type.includes('rar') || type.includes('archive')) return 'bg-yellow-500/10 text-yellow-500';
378
+ if (type.includes('html') || type.includes('csv') || type.includes('json')) return 'bg-emerald-500/10 text-emerald-500';
379
+ return 'bg-foreground/5 text-foreground/40';
380
+ };
381
+
382
+ const formatExtension = (type?: string) => {
383
+ if (!type) return 'FILE';
384
+ const mimeMap: Record<string, string> = {
385
+ 'application/pdf': 'PDF',
386
+ 'application/zip': 'ZIP',
387
+ 'application/x-zip-compressed': 'ZIP',
388
+ 'application/x-rar-compressed': 'RAR',
389
+ 'application/x-7z-compressed': '7Z',
390
+ 'application/json': 'JSON',
391
+ 'application/xml': 'XML',
392
+ 'application/javascript': 'JS',
393
+ 'application/typescript': 'TS',
394
+ 'application/msword': 'DOC',
395
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
396
+ 'application/vnd.ms-excel': 'XLS',
397
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
398
+ 'application/vnd.ms-powerpoint': 'PPT',
399
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
400
+ 'application/rtf': 'RTF',
401
+ 'application/x-tar': 'TAR',
402
+ 'application/gzip': 'GZ',
403
+ 'text/plain': 'TXT',
404
+ 'text/html': 'HTML',
405
+ 'text/css': 'CSS',
406
+ 'text/csv': 'CSV',
407
+ 'text/markdown': 'MD',
408
+ };
409
+ const lower = type.toLowerCase();
410
+ if (mimeMap[lower]) return mimeMap[lower];
411
+ const parts = lower.split('/');
412
+ const sub = parts[parts.length - 1];
413
+ const clean = sub.replace(/^x-/, '').replace(/^vnd\..*\./, '');
414
+ return clean.toUpperCase().slice(0, 10);
415
+ };
416
+
263
417
  const formatSize = (bytes: number) => {
264
418
  if (!bytes) return '0 B';
265
419
  const k = 1024;
@@ -357,7 +511,7 @@ export function AdminPageTemplate({ config }: { config: any }) {
357
511
  {config.tabTitle || config.tabName || 'Dashboard'}
358
512
  </h2>
359
513
  <p className="text-[13px] font-medium text-foreground/50 mt-1">
360
- {loading ? 'Loading…' : `${records.length} ${records.length === 1 ? 'item' : 'items'} · Shared between admins`}
514
+ {loading ? 'Loading…' : `${records.length} ${records.length === 1 ? 'item' : 'items'}`}
361
515
  </p>
362
516
  </div>
363
517
 
@@ -369,11 +523,10 @@ export function AdminPageTemplate({ config }: { config: any }) {
369
523
  whileTap={{ scale: 0.95 }}
370
524
  onClick={handleCopyPrompt}
371
525
  title={copied ? 'Copied!' : 'Copy Developer Prompt'}
372
- className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-300 cursor-pointer ${
373
- copied
374
- ? 'bg-emerald-500/10 text-emerald-500'
375
- : 'btn-secondary'
376
- }`}
526
+ className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-300 cursor-pointer ${copied
527
+ ? 'bg-emerald-500/10 text-emerald-500'
528
+ : 'btn-secondary'
529
+ }`}
377
530
  >
378
531
  {copied ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
379
532
  </motion.button>
@@ -394,9 +547,8 @@ export function AdminPageTemplate({ config }: { config: any }) {
394
547
  onClick={() => fileInputRef.current?.click()}
395
548
  disabled={uploading}
396
549
  title={config.buttonStorageText || 'Upload File'}
397
- className={`h-10 flex items-center justify-center gap-2 rounded-xl transition-all duration-300 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${
398
- config.buttonStorageText ? 'px-4' : 'w-10'
399
- } ${isPrimaryStorage ? 'btn-primary' : 'btn-secondary'}`}
550
+ className={`h-10 flex items-center justify-center gap-2 rounded-xl transition-all duration-300 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${config.buttonStorageText ? 'px-4' : 'w-10'
551
+ } ${isPrimaryStorage ? 'btn-primary' : 'btn-secondary'}`}
400
552
  >
401
553
  {uploading ? (
402
554
  <Loader2 className="w-4 h-4 animate-spin" />
@@ -417,9 +569,8 @@ export function AdminPageTemplate({ config }: { config: any }) {
417
569
  whileTap={{ scale: 0.95 }}
418
570
  onClick={() => setShowAddForm(true)}
419
571
  title="Add New"
420
- className={`h-10 flex items-center justify-center gap-2 rounded-xl transition-all duration-300 cursor-pointer ${
421
- config.buttonText && config.buttonText !== '+' ? 'px-5' : 'w-10'
422
- } ${isPrimaryBtn ? 'btn-primary' : 'btn-secondary'}`}
572
+ className={`h-10 flex items-center justify-center gap-2 rounded-xl transition-all duration-300 cursor-pointer ${config.buttonText && config.buttonText !== '+' ? 'px-5' : 'w-10'
573
+ } ${isPrimaryBtn ? 'btn-primary' : 'btn-secondary'}`}
423
574
  >
424
575
  {config.buttonText === '+' || !config.buttonText ? (
425
576
  <Plus className="w-4 h-4" />
@@ -465,99 +616,94 @@ export function AdminPageTemplate({ config }: { config: any }) {
465
616
  const isEditing = editingId === record.id;
466
617
  const owned = isOwner(record);
467
618
  return (
468
- <motion.div
469
- key={record.id}
470
- initial={false}
471
- animate={{ opacity: 1, y: 0 }}
472
- exit={{ opacity: 0, scale: 0.95 }}
473
- className={`group flex flex-col bg-background border rounded-2xl p-5 transition-all relative ${
474
- isEditing ? 'border-accent/40 shadow-lg ring-1 ring-accent/20' : 'border-[var(--panel-border)] hover:border-accent/30 hover:shadow-lg'
475
- }`}
476
- >
477
- {/* Top-right action icons — download + edit + delete */}
478
- {!isEditing && (
479
- <div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all">
480
- {record.recordType === 'file' && record.downloadURL && (
481
- <a
482
- href={record.downloadURL}
483
- target="_blank"
484
- rel="noreferrer"
485
- className="p-1.5 rounded-lg text-foreground/25 hover:text-blue-500 hover:bg-blue-500/10 transition-all"
486
- title="Download"
487
- >
488
- <Download className="w-3.5 h-3.5" />
489
- </a>
490
- )}
491
- {owned && (
492
- <>
493
- <button onClick={() => startEdit(record)} className="p-1.5 rounded-lg text-foreground/25 hover:text-accent hover:bg-accent/10 transition-all cursor-pointer" title="Edit">
494
- <Edit2 className="w-3.5 h-3.5" />
495
- </button>
496
- <button onClick={() => setDeleteConfirm(record.id)} className="p-1.5 rounded-lg text-foreground/25 hover:text-red-500 hover:bg-red-500/10 transition-all cursor-pointer" title="Delete">
497
- <Trash2 className="w-3.5 h-3.5" />
498
- </button>
499
- </>
500
- )}
501
- </div>
502
- )}
503
-
504
- <div className="flex items-start gap-3 mb-2">
505
- <div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${
506
- record.recordType === 'file'
507
- ? 'bg-blue-500/10 text-blue-500'
508
- : 'bg-accent/10 text-accent'
509
- }`}>
510
- {record.recordType === 'file' ? (
511
- <FileText className="w-4 h-4" />
512
- ) : (
513
- <div className="w-2 h-2 rounded-full bg-current" />
514
- )}
515
- </div>
516
- <div className="flex flex-col min-w-0 flex-1">
517
- {isEditing ? (
518
- <input
519
- autoFocus
520
- value={editName}
521
- onChange={e => setEditName(e.target.value)}
522
- onBlur={handleSaveEdit}
523
- onKeyDown={e => { if (e.key === 'Enter') handleSaveEdit(); if (e.key === 'Escape') setEditingId(null); }}
524
- className="text-[14px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full"
525
- />
526
- ) : (
527
- <span className="text-[14px] font-bold text-foreground truncate pr-12">
528
- {record.name}
619
+ <motion.div
620
+ key={record.id}
621
+ initial={false}
622
+ animate={{ opacity: 1, y: 0 }}
623
+ exit={{ opacity: 0, scale: 0.95 }}
624
+ onClick={() => startEdit(record)}
625
+ className={`group flex flex-col bg-background border rounded-2xl p-5 transition-all relative cursor-pointer ${isEditing ? 'border-accent/40 shadow-lg ring-1 ring-accent/20' : 'border-[var(--panel-border)] hover:border-accent/30 hover:shadow-lg'
626
+ }`}
627
+ >
628
+ {/* Top-right action icons — download + edit + delete */}
629
+ {true && (
630
+ <div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all z-10">
631
+ {record.recordType === 'file' && record.downloadURL && (
632
+ <>
633
+ <button onClick={(e) => { e.stopPropagation(); navigator.clipboard.writeText(record.downloadURL!); setCopiedLink(record.id); setTimeout(() => setCopiedLink(null), 2000); }} className="p-1.5 rounded-lg text-foreground/25 hover:text-emerald-500 hover:bg-emerald-500/10 transition-all cursor-pointer" title="Copy Link">
634
+ {copiedLink === record.id ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Link className="w-3.5 h-3.5" />}
635
+ </button>
636
+ <a href={record.downloadURL} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="p-1.5 rounded-lg text-foreground/25 hover:text-blue-500 hover:bg-blue-500/10 transition-all" title="Download">
637
+ <Download className="w-3.5 h-3.5" />
638
+ </a>
639
+ </>
640
+ )}
641
+ {owned && (
642
+ <>
643
+ <button onClick={(e) => { e.stopPropagation(); startEdit(record); }} className="p-1.5 rounded-lg text-foreground/25 hover:text-accent hover:bg-accent/10 transition-all cursor-pointer" title="Edit">
644
+ <Edit2 className="w-3.5 h-3.5" />
645
+ </button>
646
+ <button onClick={(e) => { e.stopPropagation(); setDeleteConfirm(record.id); }} className="p-1.5 rounded-lg text-foreground/25 hover:text-red-500 hover:bg-red-500/10 transition-all cursor-pointer" title="Delete">
647
+ <Trash2 className="w-3.5 h-3.5" />
648
+ </button>
649
+ </>
650
+ )}
651
+ </div>
652
+ )}
653
+
654
+ <div className="flex items-start gap-3 mb-2">
655
+ <div
656
+ onClick={(e) => { e.stopPropagation(); if (record.recordType === 'file') setPreviewFile(record); }}
657
+ className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${record.recordType === 'file' ? `cursor-pointer hover:scale-105 transition-transform ${renderFileIconBg(record.fileType)}` : 'bg-accent/10 text-accent'}`}
658
+ >
659
+ {record.recordType === 'file' ? renderFileIcon(record.fileType, record.downloadURL) : <div className="w-2 h-2 rounded-full bg-current" />}
660
+ </div>
661
+ <div className="flex flex-col min-w-0 flex-1 relative">
662
+ {renamingFileId === record.id ? (
663
+ <input
664
+ autoFocus
665
+ type="text"
666
+ value={renameFileName}
667
+ onChange={e => setRenameFileName(e.target.value)}
668
+ onBlur={() => handleRenameFile()}
669
+ onKeyDown={e => { if (e.key === 'Enter') handleRenameFile(); if (e.key === 'Escape') setRenamingFileId(null); }}
670
+ onClick={e => e.stopPropagation()}
671
+ className="no-glow text-[14px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full pr-12"
672
+ />
673
+ ) : (
674
+ <span className="text-[14px] font-bold text-foreground truncate pr-12">
675
+ {record.name}
676
+ </span>
677
+ )}
678
+ {record.recordType === 'file' && <div className="absolute inset-0 z-0 cursor-pointer" onClick={() => setPreviewFile(record)} />}
679
+ <span className="text-[11px] text-foreground/35 font-bold uppercase tracking-wider mt-0.5">
680
+ {formatDate(record.createdAt)}
529
681
  </span>
530
- )}
531
- <span className="text-[11px] text-foreground/35 font-bold uppercase tracking-wider mt-0.5">
532
- {formatDate(record.createdAt)}
533
- </span>
682
+ </div>
534
683
  </div>
535
- </div>
536
684
 
537
- {isEditing ? (
538
- <textarea
539
- value={editNote}
540
- onChange={e => setEditNote(e.target.value)}
541
- onBlur={handleSaveEdit}
542
- onKeyDown={e => { if (e.key === 'Escape') setEditingId(null); }}
543
- placeholder="Note (optional)"
544
- rows={2}
545
- className="text-[13px] text-foreground/70 font-medium bg-transparent border-b border-accent/20 outline-none resize-none w-full mb-1 placeholder:text-foreground/30"
546
- />
547
- ) : (
548
- record.note && (
685
+ {record.note && (
549
686
  <p className="text-[13px] text-foreground/55 font-medium leading-relaxed line-clamp-3 mb-1">
550
687
  {record.note}
551
688
  </p>
552
- )
553
- )}
554
-
689
+ )}
690
+ {/* Extra custom fields display */}
691
+ {Object.entries(record as any)
692
+ .filter(([k]) => !['name','note','uid','recordType','fileName','fileType','fileSize','downloadURL','createdAt','creatorName','creatorEmail','creatorAvatar'].includes(k) && k !== 'id')
693
+ .slice(0, 3)
694
+ .map(([k, v]) => (
695
+ <div key={k} className="flex items-center gap-1.5 mt-1">
696
+ <span className="text-[10px] font-bold text-foreground/30 uppercase tracking-wider shrink-0">{k}:</span>
697
+ <span className="text-[11px] font-semibold text-foreground/60 truncate">{String(v)}</span>
698
+ </div>
699
+ ))
700
+ }
555
701
 
556
702
 
557
- {/* Creator profile badge */}
558
- <CreatorBadge record={record} />
559
- </motion.div>
560
- );
703
+ {/* Creator profile badge */}
704
+ <CreatorBadge record={record} />
705
+ </motion.div>
706
+ );
561
707
  })}
562
708
  </AnimatePresence>
563
709
  </div>
@@ -569,8 +715,8 @@ export function AdminPageTemplate({ config }: { config: any }) {
569
715
  <div className="flex items-start gap-2 text-[11px] text-foreground/30 font-medium leading-relaxed">
570
716
  <Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
571
717
  <span>
572
- Shared between all admins · Edit and delete available on your own items only ·
573
- Records: <code className="text-foreground/40 font-mono text-[10px]">{recordsCollection}</code> ·
718
+ Shared between all admins · Edit and delete available on your own items only ·
719
+ Records: <code className="text-foreground/40 font-mono text-[10px]">{recordsCollection}</code> ·
574
720
  Files: <code className="text-foreground/40 font-mono text-[10px]">admin_files</code> → Drive / Admin
575
721
  </span>
576
722
  </div>
@@ -642,13 +788,256 @@ export function AdminPageTemplate({ config }: { config: any }) {
642
788
  )}
643
789
  </AnimatePresence>
644
790
 
791
+ {/* ── File Preview Modal (Drive-style) ───────────────────────────────────── */}
792
+ {typeof window !== 'undefined' && createPortal(
793
+ <AnimatePresence>
794
+ {previewFile && (
795
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 md:p-12">
796
+ <motion.div onClick={() => setPreviewFile(null)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-background/95 backdrop-blur-xl cursor-pointer" />
797
+ <button onClick={() => setPreviewFile(null)} className="absolute top-6 right-6 z-[160] w-12 h-12 bg-foreground/10 hover:bg-foreground/20 text-foreground rounded-full flex items-center justify-center backdrop-blur-md transition-all cursor-pointer outline-none">
798
+ <X className="w-5 h-5" />
799
+ </button>
800
+ <motion.div
801
+ initial={{ opacity: 0, scale: 0.95, y: 10 }}
802
+ animate={{ opacity: 1, scale: 1, y: 0 }}
803
+ exit={{ opacity: 0, scale: 0.95, y: 10 }}
804
+ className="relative z-10 w-full max-w-6xl max-h-[85vh] flex flex-col pointer-events-auto shadow-2xl rounded-3xl overflow-hidden glass-panel border border-[var(--panel-border)]"
805
+ >
806
+ {/* File Header */}
807
+ <div className="flex items-center justify-between p-6 bg-background/80 backdrop-blur-md border-b border-[var(--panel-border)] shrink-0 shadow-sm z-20">
808
+ <div className="flex flex-col min-w-0 pr-4">
809
+ <span className="text-xl font-extrabold text-foreground truncate">{previewFile.fileName || previewFile.name}</span>
810
+ <span className="text-[12px] text-foreground/70 font-bold uppercase tracking-wider mt-1">
811
+ {formatExtension(previewFile.fileType)} • {formatSize(previewFile.fileSize || 0)}
812
+ </span>
813
+ </div>
814
+ <div className="flex items-center gap-2 shrink-0">
815
+ <button
816
+ onClick={() => { navigator.clipboard.writeText(previewFile.downloadURL!); setCopiedLink('preview'); setTimeout(() => setCopiedLink(null), 2000); }}
817
+ className="p-2.5 bg-foreground/5 hover:bg-foreground/10 text-foreground/70 transition-colors rounded-xl shadow-sm hover:text-emerald-500 cursor-pointer"
818
+ title="Copy Link"
819
+ >
820
+ {copiedLink === 'preview' ? <Check className="w-4 h-4 text-emerald-500" /> : <Link className="w-4 h-4" />}
821
+ </button>
822
+ <button
823
+ onClick={() => { setPreviewFile(null); setRenamingFileId(previewFile.id); setRenameFileName(previewFile.name); }}
824
+ className="p-2.5 bg-foreground/5 hover:bg-foreground/10 text-foreground/70 hover:text-accent transition-colors rounded-xl shadow-sm cursor-pointer"
825
+ title="Rename"
826
+ >
827
+ <Edit2 className="w-4 h-4" />
828
+ </button>
829
+ <button
830
+ onClick={() => { setPreviewFile(null); setDeleteConfirm(previewFile.id); }}
831
+ className="p-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 transition-colors rounded-xl shadow-sm cursor-pointer"
832
+ title="Delete"
833
+ >
834
+ <Trash2 className="w-4 h-4" />
835
+ </button>
836
+ <a
837
+ href={previewFile.downloadURL}
838
+ download
839
+ target="_blank"
840
+ rel="noreferrer"
841
+ className="p-2.5 btn-primary transition-colors rounded-xl shadow-lg ml-1"
842
+ title="Download"
843
+ >
844
+ <Download className="w-4 h-4" />
845
+ </a>
846
+ </div>
847
+ </div>
848
+
849
+ {/* Preview Content */}
850
+ <div className="flex-1 w-full relative bg-foreground/[0.02] min-h-[50vh] md:min-h-[600px]" onClick={() => setPreviewFile(null)}>
851
+ <div className="absolute inset-x-4 inset-y-8 md:inset-x-12 md:inset-y-12 flex items-center justify-center">
852
+ {previewLoading && (
853
+ <div className="absolute inset-0 flex items-center justify-center rounded-lg z-50 pointer-events-none">
854
+ <div className="w-12 h-12 rounded-full bg-background/80 backdrop-blur-md shadow-xl flex items-center justify-center border border-[var(--panel-border)]/50">
855
+ <Loader2 className="w-5 h-5 text-accent animate-spin" />
856
+ </div>
857
+ </div>
858
+ )}
859
+ {previewFile.fileType?.startsWith('image/') ? (
860
+ <img
861
+ onLoad={() => setPreviewLoading(false)}
862
+ onError={() => setPreviewLoading(false)}
863
+ src={previewFile.downloadURL}
864
+ alt={previewFile.fileName || previewFile.name}
865
+ className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-auto h-auto max-w-full max-h-full object-contain drop-shadow-[0_0_40px_rgba(0,0,0,0.5)] rounded-lg pointer-events-auto transition-opacity duration-500 ${previewLoading ? 'opacity-0' : 'opacity-100'}`}
866
+ onClick={e => e.stopPropagation()}
867
+ />
868
+ ) : previewFile.fileType?.startsWith('video/') ? (
869
+ <video
870
+ onLoadedData={() => setPreviewLoading(false)}
871
+ onError={() => setPreviewLoading(false)}
872
+ src={previewFile.downloadURL}
873
+ controls
874
+ autoPlay
875
+ className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-auto h-auto max-w-full max-h-full outline-none bg-black/40 rounded-lg pointer-events-auto drop-shadow-[0_0_40px_rgba(0,0,0,0.5)] transition-opacity duration-500 ${previewLoading ? 'opacity-0' : 'opacity-100'}`}
876
+ onClick={e => e.stopPropagation()}
877
+ />
878
+ ) : previewFile.fileType === 'application/pdf' ? (
879
+ <iframe
880
+ onLoad={() => setPreviewLoading(false)}
881
+ src={previewFile.downloadURL}
882
+ className={`w-full h-full border-none rounded-lg relative z-10 transition-opacity duration-500 ${previewLoading ? 'opacity-0' : 'opacity-100'}`}
883
+ style={{ backgroundColor: 'white' }}
884
+ title="PDF Preview"
885
+ onClick={e => e.stopPropagation()}
886
+ />
887
+ ) : (
888
+ <div
889
+ className="w-full h-full flex flex-col items-center justify-center text-foreground/40 text-center relative z-10"
890
+ onClick={e => { e.stopPropagation(); setPreviewLoading(false); }}
891
+ >
892
+ <FileText className="w-24 h-24 mb-6 opacity-40 drop-shadow-sm" />
893
+ <span className="text-[20px] font-extrabold text-foreground">Preview Unavailable</span>
894
+ <span className="text-[14px] mt-2 text-foreground/60 font-medium max-w-xs">This file format cannot be previewed. Please download it instead.</span>
895
+ </div>
896
+ )}
897
+ </div>
898
+ </div>
899
+ </motion.div>
900
+ </div>
901
+ )}
902
+ </AnimatePresence>,
903
+ document.body
904
+ )}
905
+
906
+ {/* ── Edit Record Modal ──────────────────────────────────────────────── */}
907
+ <AnimatePresence>
908
+ {editingId && (
909
+ <div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
910
+ <motion.div
911
+ onClick={() => handleSaveEdit()}
912
+ initial={{ opacity: 0 }}
913
+ animate={{ opacity: 1 }}
914
+ exit={{ opacity: 0 }}
915
+ className="absolute inset-0 bg-background/60 backdrop-blur-sm cursor-pointer"
916
+ />
917
+ <motion.div
918
+ initial={{ opacity: 0, scale: 0.97, y: 6 }}
919
+ animate={{ opacity: 1, scale: 1, y: 0 }}
920
+ exit={{ opacity: 0, scale: 0.97, y: 6 }}
921
+ transition={{ duration: 0.2 }}
922
+ className="w-full max-w-[440px] border-[var(--panel-border)] border rounded-2xl relative z-10 glass-panel shadow-2xl bg-background overflow-hidden"
923
+ >
924
+ <div className="p-5 flex flex-col gap-3 max-h-[80vh] overflow-y-auto">
925
+ {/* Header */}
926
+ <div className="flex items-center justify-between mb-2 mt-2 px-1">
927
+ <div className="flex items-center gap-2">
928
+ <span className="text-[14px] font-bold text-foreground">Edit Record</span>
929
+ {saving && <Loader2 className="w-3 h-3 animate-spin text-foreground/40" />}
930
+ </div>
931
+ <button
932
+ type="button"
933
+ onClick={() => handleSaveEdit()}
934
+ className="text-foreground/30 hover:text-foreground/60 transition-colors cursor-pointer"
935
+ >
936
+ <X className="w-4 h-4" />
937
+ </button>
938
+ </div>
939
+
940
+ <div className="flex flex-col gap-0 max-h-[70vh] overflow-y-auto no-scrollbar px-1">
941
+ {/* Name field */}
942
+ <div className="flex flex-col relative group/field">
943
+ <input
944
+ readOnly
945
+ value="NAME"
946
+ className="no-glow bg-transparent text-[10px] font-bold text-foreground/30 uppercase tracking-[0.15em] outline-none pt-3 pb-0.5"
947
+ />
948
+ <input
949
+ autoFocus
950
+ required
951
+ value={editName}
952
+ onChange={e => setEditName(e.target.value)}
953
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
954
+ onBlur={() => handleSilentSave()}
955
+ placeholder="Name *"
956
+ className="no-glow bg-transparent text-[13px] font-medium text-foreground outline-none placeholder:text-foreground/15 pb-1 caret-foreground/50"
957
+ />
958
+ <div className="border-b border-dashed border-[var(--panel-border)]/60" />
959
+ </div>
960
+
961
+ {/* Note field */}
962
+ <div className="flex flex-col relative group/field">
963
+ <input
964
+ readOnly
965
+ value="NOTE"
966
+ className="no-glow bg-transparent text-[10px] font-bold text-foreground/30 uppercase tracking-[0.15em] outline-none pt-3 pb-0.5"
967
+ />
968
+ <textarea
969
+ ref={el => { if (el) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } }}
970
+ value={editNote}
971
+ onChange={e => { setEditNote(e.target.value); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; }}
972
+ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); (e.target as HTMLTextAreaElement).blur(); } }}
973
+ onBlur={() => handleSilentSave()}
974
+ placeholder="Add a note..."
975
+ rows={1}
976
+ className="no-glow bg-transparent text-[13px] font-medium text-foreground outline-none placeholder:text-foreground/15 pb-1 caret-foreground/50 resize-none overflow-hidden"
977
+ />
978
+ <div className="border-b border-dashed border-[var(--panel-border)]/60" />
979
+ </div>
980
+
981
+ {/* Custom Fields */}
982
+ {editFields.map((field, idx) => (
983
+ <div key={idx} className="flex flex-col relative group/field">
984
+ <input
985
+ placeholder="FIELD NAME"
986
+ value={field.key}
987
+ onChange={e => {
988
+ const next = [...editFields];
989
+ next[idx].key = e.target.value;
990
+ setEditFields(next);
991
+ }}
992
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
993
+ onBlur={() => handleSilentSave()}
994
+ className="no-glow bg-transparent text-[10px] font-bold text-foreground/30 uppercase tracking-[0.15em] outline-none placeholder:text-foreground/15 pt-3 pb-0.5 caret-foreground/40"
995
+ />
996
+ <input
997
+ placeholder="Field value..."
998
+ value={field.value}
999
+ onChange={e => {
1000
+ const next = [...editFields];
1001
+ next[idx].value = e.target.value;
1002
+ setEditFields(next);
1003
+ }}
1004
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
1005
+ onBlur={() => handleSilentSave()}
1006
+ className="no-glow bg-transparent text-[13px] font-medium text-foreground outline-none placeholder:text-foreground/15 pt-1.5 pb-1 caret-foreground/50"
1007
+ />
1008
+ <div className="border-b border-dashed border-[var(--panel-border)]/60" />
1009
+ <button
1010
+ type="button"
1011
+ onClick={() => setEditFields(editFields.filter((_, i) => i !== idx))}
1012
+ className="absolute top-3 right-0 p-1 text-foreground/0 group-hover/field:text-foreground/20 hover:!text-red-400 transition-colors cursor-pointer"
1013
+ >
1014
+ <X className="w-3 h-3" />
1015
+ </button>
1016
+ </div>
1017
+ ))}
1018
+
1019
+ {/* Add Field */}
1020
+ <button
1021
+ type="button"
1022
+ onClick={() => setEditFields([...editFields, { key: '', value: '' }])}
1023
+ className="flex items-center gap-1.5 text-[11px] font-medium text-foreground/25 hover:text-foreground/40 transition-colors cursor-pointer mt-4 mb-4"
1024
+ >
1025
+ <Plus className="w-3 h-3" /> Add Field
1026
+ </button>
1027
+ </div>
1028
+ </div>
1029
+ </motion.div>
1030
+ </div>
1031
+ )}
1032
+ </AnimatePresence>
1033
+
645
1034
  {/* ── Delete Confirmation ────────────────────────────────────────────── */}
646
1035
  <ConfirmModal
647
1036
  isOpen={!!deleteConfirm}
648
1037
  onCancel={() => setDeleteConfirm(null)}
649
1038
  onConfirm={handleDelete}
650
1039
  title="Delete Record"
651
- message="Are you sure you want to delete this record? This action cannot be undone."
1040
+ message={<>Are you sure you want to delete <strong className="text-foreground block my-0.5 text-left overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{records.find(r => r.id === deleteConfirm)?.name}</strong> This action cannot be undone.</>}
652
1041
  confirmText="Delete"
653
1042
  isProcessing={isDeleting}
654
1043
  />