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.
- package/dist/{ContactPopup-B666UTn5.cjs → ContactPopup-5mXFBqTs.cjs} +1 -1
- package/dist/{ContactPopup-QbqZ8VJs.js → ContactPopup-D0Fb80uS.js} +1 -1
- package/dist/renderer.cjs.js +1 -1
- package/dist/renderer.es.js +1 -1
- package/dist/your-ai-workflow-firebase-os.cjs.js +35 -35
- package/dist/your-ai-workflow-firebase-os.css +1 -1
- package/dist/your-ai-workflow-firebase-os.es.js +4029 -2588
- package/package.json +1 -1
- package/src/pages/PagesAdmin.tsx +143 -25
- package/src/pages/StorageAdmin.tsx +8 -8
- package/src/pages/ThemeAdmin.tsx +15 -0
- package/src/templates/AdminPageTemplate.tsx +500 -111
- package/src/templates/PrivatePageTemplate.tsx +440 -59
- package/src/templates/SharedPageTemplate.tsx +432 -38
- package/src/templates/TemplateBoard.tsx +17 -8
- package/src/templates/TemplateCalendar.tsx +9 -4
- package/src/templates/TemplateTable.tsx +10 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } 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, query, where, serverTimestamp, deleteDoc, updateDoc, doc, limit } from 'firebase/firestore';
|
|
11
|
+
import { collection, addDoc, getDocs, query, where, serverTimestamp, deleteDoc, updateDoc, doc, limit, deleteField } from 'firebase/firestore';
|
|
11
12
|
import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage';
|
|
12
13
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
13
14
|
|
|
@@ -38,10 +39,22 @@ export function PrivatePageTemplate({ config }: { config: any }) {
|
|
|
38
39
|
const [copied, setCopied] = useState(false);
|
|
39
40
|
const [uploading, setUploading] = useState(false);
|
|
40
41
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
42
|
+
const [_previewFile, _setPreviewFile] = useState<any | null>(null);
|
|
43
|
+
const setPreviewFile = (f: any) => {
|
|
44
|
+
_setPreviewFile(f);
|
|
45
|
+
if (f) {
|
|
46
|
+
const t = f.fileType || '';
|
|
47
|
+
const canPreview = t.startsWith('image/') || t.startsWith('video/') || t === 'application/pdf';
|
|
48
|
+
setPreviewLoading(canPreview);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const previewFile = _previewFile;
|
|
52
|
+
const [previewLoading, setPreviewLoading] = useState(true);
|
|
41
53
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
42
54
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
43
55
|
const [editName, setEditName] = useState('');
|
|
44
56
|
const [editNote, setEditNote] = useState('');
|
|
57
|
+
const [editFields, setEditFields] = useState<{key: string; value: string}[]>([]);
|
|
45
58
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
46
59
|
const [availableForms, setAvailableForms] = useState<string[]>([]);
|
|
47
60
|
|
|
@@ -235,31 +248,152 @@ export function PrivatePageTemplate({ config }: { config: any }) {
|
|
|
235
248
|
};
|
|
236
249
|
|
|
237
250
|
// ── Edit record (inline) ──────────────────────────────────────────────────
|
|
251
|
+
// Inline rename state for file records (Drive-style)
|
|
252
|
+
const [renamingFileId, setRenamingFileId] = useState<string | null>(null);
|
|
253
|
+
const [renameFileName, setRenameFileName] = useState('');
|
|
254
|
+
const [isRenamingFile, setIsRenamingFile] = useState(false);
|
|
255
|
+
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
|
256
|
+
const originalFieldKeysRef = useRef<string[]>([]);
|
|
257
|
+
|
|
238
258
|
const startEdit = (record: SavedRecord) => {
|
|
259
|
+
// For file records, use inline rename (Drive-style)
|
|
260
|
+
if (record.recordType === 'file') {
|
|
261
|
+
setRenamingFileId(record.id);
|
|
262
|
+
setRenameFileName(record.name);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
239
265
|
setEditingId(record.id);
|
|
240
266
|
setEditName(record.name);
|
|
241
267
|
setEditNote(record.note || '');
|
|
268
|
+
// Load any extra custom fields (anything beyond the reserved keys)
|
|
269
|
+
const reserved = ['name','note','uid','recordType','fileName','fileType','fileSize','downloadURL','createdAt'];
|
|
270
|
+
const extras = Object.entries(record as any)
|
|
271
|
+
.filter(([k]) => !reserved.includes(k) && k !== 'id')
|
|
272
|
+
.map(([key, value]) => ({ key, value: String(value ?? '') }));
|
|
273
|
+
setEditFields(extras);
|
|
274
|
+
originalFieldKeysRef.current = extras.map(f => f.key);
|
|
242
275
|
};
|
|
243
276
|
|
|
244
|
-
const
|
|
245
|
-
if (!
|
|
246
|
-
const original = records.find(r => r.id ===
|
|
247
|
-
if (original && original.name ===
|
|
248
|
-
|
|
277
|
+
const handleRenameFile = async () => {
|
|
278
|
+
if (!renamingFileId || !renameFileName.trim()) { setRenamingFileId(null); return; }
|
|
279
|
+
const original = records.find(r => r.id === renamingFileId);
|
|
280
|
+
if (original && original.name === renameFileName.trim()) { setRenamingFileId(null); return; }
|
|
281
|
+
setIsRenamingFile(true);
|
|
282
|
+
try {
|
|
283
|
+
await updateDoc(doc(db, recordsCollection, renamingFileId), {
|
|
284
|
+
name: renameFileName.trim(),
|
|
285
|
+
});
|
|
286
|
+
setRecords(prev => prev.map(r =>
|
|
287
|
+
r.id === renamingFileId ? { ...r, name: renameFileName.trim() } : r
|
|
288
|
+
));
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.error('Error renaming file:', e);
|
|
249
291
|
}
|
|
292
|
+
setIsRenamingFile(false);
|
|
293
|
+
setRenamingFileId(null);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const handleSaveEdit = async () => {
|
|
297
|
+
await handleSilentSave();
|
|
298
|
+
setEditingId(null);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Silent save: persist to Firestore without closing modal, show tiny loader
|
|
302
|
+
const handleSilentSave = async () => {
|
|
303
|
+
if (!editingId || !editName.trim()) return;
|
|
304
|
+
setSaving(true);
|
|
305
|
+
const extraData: Record<string, any> = {};
|
|
306
|
+
editFields.forEach(({ key, value }) => {
|
|
307
|
+
if (key.trim()) extraData[key.trim()] = value;
|
|
308
|
+
});
|
|
309
|
+
// Detect removed fields and mark them for deletion
|
|
310
|
+
const currentKeys = editFields.map(f => f.key.trim()).filter(Boolean);
|
|
311
|
+
const removedKeys = originalFieldKeysRef.current.filter(k => !currentKeys.includes(k));
|
|
312
|
+
removedKeys.forEach(k => { extraData[k] = deleteField(); });
|
|
250
313
|
try {
|
|
251
314
|
await updateDoc(doc(db, recordsCollection, editingId), {
|
|
252
315
|
name: editName.trim(),
|
|
253
316
|
note: editNote.trim(),
|
|
317
|
+
...extraData,
|
|
254
318
|
});
|
|
255
|
-
|
|
319
|
+
// Update local records: remove deleted keys, add new ones
|
|
320
|
+
setRecords(prev => prev.map(r => {
|
|
321
|
+
if (r.id !== editingId) return r;
|
|
322
|
+
const updated = { ...r, name: editName.trim(), note: editNote.trim() };
|
|
323
|
+
removedKeys.forEach(k => { delete (updated as any)[k]; });
|
|
324
|
+
editFields.forEach(({ key, value }) => { if (key.trim()) (updated as any)[key.trim()] = value; });
|
|
325
|
+
return updated;
|
|
326
|
+
}));
|
|
327
|
+
originalFieldKeysRef.current = currentKeys;
|
|
256
328
|
} catch (e) {
|
|
257
|
-
console.error('Error
|
|
329
|
+
console.error('Error saving record:', e);
|
|
258
330
|
}
|
|
259
|
-
|
|
331
|
+
setSaving(false);
|
|
260
332
|
};
|
|
261
333
|
|
|
262
334
|
// ── Utils ─────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
// ── File type icon renderer ───────────────────────────────────────────────
|
|
337
|
+
const renderFileIcon = (type?: string, url?: string) => {
|
|
338
|
+
if (!type) return <FileText className="w-4 h-4" />;
|
|
339
|
+
if (type.startsWith('image/')) {
|
|
340
|
+
if (url) return <div className="w-9 h-9 rounded-xl bg-cover bg-center shrink-0" style={{ backgroundImage: `url(${url})` }} />;
|
|
341
|
+
return <Image className="w-4 h-4" />;
|
|
342
|
+
}
|
|
343
|
+
if (type.startsWith('video/') || type.startsWith('audio/')) return <Video className="w-4 h-4" />;
|
|
344
|
+
if (type.includes('pdf') || type.includes('word') || type.includes('text')) return <FileText className="w-4 h-4" />;
|
|
345
|
+
if (type.includes('zip') || type.includes('rar') || type.includes('archive')) return <Archive className="w-4 h-4" />;
|
|
346
|
+
if (type.includes('html') || type.includes('csv') || type.includes('json')) return <Code className="w-4 h-4" />;
|
|
347
|
+
return <FileIcon className="w-4 h-4" />;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const renderFileIconBg = (type?: string) => {
|
|
351
|
+
if (!type) return 'bg-foreground/5 text-foreground/40';
|
|
352
|
+
if (type.startsWith('image/')) return 'bg-emerald-500/10 text-emerald-500';
|
|
353
|
+
if (type.startsWith('video/') || type.startsWith('audio/')) return 'bg-purple-500/10 text-purple-500';
|
|
354
|
+
if (type.includes('pdf') || type.includes('word') || type.includes('text')) return 'bg-blue-500/10 text-blue-500';
|
|
355
|
+
if (type.includes('zip') || type.includes('rar') || type.includes('archive')) return 'bg-yellow-500/10 text-yellow-500';
|
|
356
|
+
if (type.includes('html') || type.includes('csv') || type.includes('json')) return 'bg-emerald-500/10 text-emerald-500';
|
|
357
|
+
return 'bg-foreground/5 text-foreground/40';
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const formatExtension = (type?: string) => {
|
|
361
|
+
if (!type) return 'FILE';
|
|
362
|
+
const mimeMap: Record<string, string> = {
|
|
363
|
+
'application/pdf': 'PDF',
|
|
364
|
+
'application/zip': 'ZIP',
|
|
365
|
+
'application/x-zip-compressed': 'ZIP',
|
|
366
|
+
'application/x-rar-compressed': 'RAR',
|
|
367
|
+
'application/x-7z-compressed': '7Z',
|
|
368
|
+
'application/json': 'JSON',
|
|
369
|
+
'application/xml': 'XML',
|
|
370
|
+
'application/javascript': 'JS',
|
|
371
|
+
'application/typescript': 'TS',
|
|
372
|
+
'application/msword': 'DOC',
|
|
373
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
|
|
374
|
+
'application/vnd.ms-excel': 'XLS',
|
|
375
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
|
|
376
|
+
'application/vnd.ms-powerpoint': 'PPT',
|
|
377
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
|
|
378
|
+
'application/rtf': 'RTF',
|
|
379
|
+
'application/x-tar': 'TAR',
|
|
380
|
+
'application/gzip': 'GZ',
|
|
381
|
+
'text/plain': 'TXT',
|
|
382
|
+
'text/html': 'HTML',
|
|
383
|
+
'text/css': 'CSS',
|
|
384
|
+
'text/csv': 'CSV',
|
|
385
|
+
'text/markdown': 'MD',
|
|
386
|
+
};
|
|
387
|
+
const lower = type.toLowerCase();
|
|
388
|
+
if (mimeMap[lower]) return mimeMap[lower];
|
|
389
|
+
// image/png → PNG, video/mp4 → MP4, audio/mpeg → MPEG
|
|
390
|
+
const parts = lower.split('/');
|
|
391
|
+
const sub = parts[parts.length - 1];
|
|
392
|
+
// Strip x- prefix and vnd. noise
|
|
393
|
+
const clean = sub.replace(/^x-/, '').replace(/^vnd\..*\./, '');
|
|
394
|
+
return clean.toUpperCase().slice(0, 10);
|
|
395
|
+
};
|
|
396
|
+
|
|
263
397
|
const formatSize = (bytes: number) => {
|
|
264
398
|
if (!bytes) return '0 B';
|
|
265
399
|
const k = 1024;
|
|
@@ -443,75 +577,79 @@ export function PrivatePageTemplate({ config }: { config: any }) {
|
|
|
443
577
|
animate={{ opacity: 1, y: 0 }}
|
|
444
578
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
445
579
|
transition={{ delay: i * 0.03, duration: 0.25 }}
|
|
446
|
-
|
|
580
|
+
onClick={() => startEdit(record)}
|
|
581
|
+
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'
|
|
447
582
|
}`}
|
|
448
583
|
>
|
|
449
584
|
{/* Top-right action icons — download + edit + delete */}
|
|
450
|
-
{
|
|
451
|
-
<div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all">
|
|
585
|
+
{true && (
|
|
586
|
+
<div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all z-10">
|
|
452
587
|
{record.recordType === 'file' && record.downloadURL && (
|
|
453
|
-
|
|
454
|
-
<
|
|
455
|
-
|
|
588
|
+
<>
|
|
589
|
+
<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">
|
|
590
|
+
{copiedLink === record.id ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Link className="w-3.5 h-3.5" />}
|
|
591
|
+
</button>
|
|
592
|
+
<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">
|
|
593
|
+
<Download className="w-3.5 h-3.5" />
|
|
594
|
+
</a>
|
|
595
|
+
</>
|
|
456
596
|
)}
|
|
457
|
-
<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">
|
|
597
|
+
<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">
|
|
458
598
|
<Edit2 className="w-3.5 h-3.5" />
|
|
459
599
|
</button>
|
|
460
|
-
<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">
|
|
600
|
+
<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">
|
|
461
601
|
<Trash2 className="w-3.5 h-3.5" />
|
|
462
602
|
</button>
|
|
463
603
|
</div>
|
|
464
604
|
)}
|
|
465
605
|
|
|
466
606
|
<div className="flex items-start gap-3 mb-3">
|
|
467
|
-
<div
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
{record.recordType === 'file' ? (
|
|
472
|
-
<FileText className="w-4 h-4" />
|
|
473
|
-
) : (
|
|
474
|
-
<div className="w-2 h-2 rounded-full bg-current" />
|
|
475
|
-
)}
|
|
607
|
+
<div
|
|
608
|
+
onClick={(e) => { e.stopPropagation(); if (record.recordType === 'file') setPreviewFile(record); }}
|
|
609
|
+
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'}`}
|
|
610
|
+
>
|
|
611
|
+
{record.recordType === 'file' ? renderFileIcon(record.fileType, record.downloadURL) : <div className="w-2 h-2 rounded-full bg-current" />}
|
|
476
612
|
</div>
|
|
477
|
-
<div className="flex flex-col min-w-0 flex-1">
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
613
|
+
<div className="flex flex-col min-w-0 flex-1 relative">
|
|
614
|
+
{renamingFileId === record.id ? (
|
|
615
|
+
<input
|
|
616
|
+
autoFocus
|
|
617
|
+
type="text"
|
|
618
|
+
value={renameFileName}
|
|
619
|
+
onChange={e => setRenameFileName(e.target.value)}
|
|
620
|
+
onBlur={() => handleRenameFile()}
|
|
621
|
+
onKeyDown={e => { if (e.key === 'Enter') handleRenameFile(); if (e.key === 'Escape') setRenamingFileId(null); }}
|
|
622
|
+
onClick={e => e.stopPropagation()}
|
|
623
|
+
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"
|
|
624
|
+
/>
|
|
625
|
+
) : (
|
|
626
|
+
<span className="text-[14px] font-bold text-foreground truncate pr-12">
|
|
627
|
+
{record.name}
|
|
628
|
+
</span>
|
|
629
|
+
)}
|
|
630
|
+
{record.recordType === 'file' && <div className="absolute inset-0 z-0 cursor-pointer" onClick={() => setPreviewFile(record)} />}
|
|
492
631
|
<span className="text-[11px] text-foreground/35 font-bold uppercase tracking-wider mt-0.5">
|
|
493
632
|
{formatDate(record.createdAt)}
|
|
494
633
|
</span>
|
|
495
634
|
</div>
|
|
496
635
|
</div>
|
|
497
636
|
|
|
498
|
-
{
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
onBlur={handleSaveEdit}
|
|
503
|
-
onKeyDown={e => { if (e.key === 'Escape') setEditingId(null); }}
|
|
504
|
-
placeholder="Note (optional)"
|
|
505
|
-
rows={2}
|
|
506
|
-
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"
|
|
507
|
-
/>
|
|
508
|
-
) : (
|
|
509
|
-
record.note && (
|
|
510
|
-
<p className="text-[13px] text-foreground/55 font-medium leading-relaxed line-clamp-3 mb-1">
|
|
511
|
-
{record.note}
|
|
512
|
-
</p>
|
|
513
|
-
)
|
|
637
|
+
{record.note && (
|
|
638
|
+
<p className="text-[13px] text-foreground/55 font-medium leading-relaxed line-clamp-3 mb-1">
|
|
639
|
+
{record.note}
|
|
640
|
+
</p>
|
|
514
641
|
)}
|
|
642
|
+
{/* Extra custom fields display */}
|
|
643
|
+
{Object.entries(record as any)
|
|
644
|
+
.filter(([k]) => !['name','note','uid','recordType','fileName','fileType','fileSize','downloadURL','createdAt','creatorName','creatorEmail','creatorAvatar'].includes(k) && k !== 'id')
|
|
645
|
+
.slice(0, 3)
|
|
646
|
+
.map(([k, v]) => (
|
|
647
|
+
<div key={k} className="flex items-center gap-1.5 mt-1">
|
|
648
|
+
<span className="text-[10px] font-bold text-foreground/30 uppercase tracking-wider shrink-0">{k}:</span>
|
|
649
|
+
<span className="text-[11px] font-semibold text-foreground/60 truncate">{String(v)}</span>
|
|
650
|
+
</div>
|
|
651
|
+
))
|
|
652
|
+
}
|
|
515
653
|
|
|
516
654
|
|
|
517
655
|
</motion.div>
|
|
@@ -600,13 +738,256 @@ export function PrivatePageTemplate({ config }: { config: any }) {
|
|
|
600
738
|
)}
|
|
601
739
|
</AnimatePresence>
|
|
602
740
|
|
|
741
|
+
{/* ── File Preview Modal (Drive-style) ───────────────────────────────────── */}
|
|
742
|
+
{typeof window !== 'undefined' && createPortal(
|
|
743
|
+
<AnimatePresence>
|
|
744
|
+
{previewFile && (
|
|
745
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 md:p-12">
|
|
746
|
+
<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" />
|
|
747
|
+
<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">
|
|
748
|
+
<X className="w-5 h-5" />
|
|
749
|
+
</button>
|
|
750
|
+
<motion.div
|
|
751
|
+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
752
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
753
|
+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
754
|
+
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)]"
|
|
755
|
+
>
|
|
756
|
+
{/* File Header */}
|
|
757
|
+
<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">
|
|
758
|
+
<div className="flex flex-col min-w-0 pr-4">
|
|
759
|
+
<span className="text-xl font-extrabold text-foreground truncate">{previewFile.fileName || previewFile.name}</span>
|
|
760
|
+
<span className="text-[12px] text-foreground/70 font-bold uppercase tracking-wider mt-1">
|
|
761
|
+
{formatExtension(previewFile.fileType)} • {formatSize(previewFile.fileSize || 0)}
|
|
762
|
+
</span>
|
|
763
|
+
</div>
|
|
764
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
765
|
+
<button
|
|
766
|
+
onClick={() => { navigator.clipboard.writeText(previewFile.downloadURL!); setCopiedLink('preview'); setTimeout(() => setCopiedLink(null), 2000); }}
|
|
767
|
+
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"
|
|
768
|
+
title="Copy Link"
|
|
769
|
+
>
|
|
770
|
+
{copiedLink === 'preview' ? <Check className="w-4 h-4 text-emerald-500" /> : <Link className="w-4 h-4" />}
|
|
771
|
+
</button>
|
|
772
|
+
<button
|
|
773
|
+
onClick={() => { setPreviewFile(null); setRenamingFileId(previewFile.id); setRenameFileName(previewFile.name); }}
|
|
774
|
+
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"
|
|
775
|
+
title="Rename"
|
|
776
|
+
>
|
|
777
|
+
<Edit2 className="w-4 h-4" />
|
|
778
|
+
</button>
|
|
779
|
+
<button
|
|
780
|
+
onClick={() => { setPreviewFile(null); setDeleteConfirm(previewFile.id); }}
|
|
781
|
+
className="p-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 transition-colors rounded-xl shadow-sm cursor-pointer"
|
|
782
|
+
title="Delete"
|
|
783
|
+
>
|
|
784
|
+
<Trash2 className="w-4 h-4" />
|
|
785
|
+
</button>
|
|
786
|
+
<a
|
|
787
|
+
href={previewFile.downloadURL}
|
|
788
|
+
download
|
|
789
|
+
target="_blank"
|
|
790
|
+
rel="noreferrer"
|
|
791
|
+
className="p-2.5 btn-primary transition-colors rounded-xl shadow-lg ml-1"
|
|
792
|
+
title="Download"
|
|
793
|
+
>
|
|
794
|
+
<Download className="w-4 h-4" />
|
|
795
|
+
</a>
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* Preview Content */}
|
|
800
|
+
<div className="flex-1 w-full relative bg-foreground/[0.02] min-h-[50vh] md:min-h-[600px]" onClick={() => setPreviewFile(null)}>
|
|
801
|
+
<div className="absolute inset-x-4 inset-y-8 md:inset-x-12 md:inset-y-12 flex items-center justify-center">
|
|
802
|
+
{previewLoading && (
|
|
803
|
+
<div className="absolute inset-0 flex items-center justify-center rounded-lg z-50 pointer-events-none">
|
|
804
|
+
<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">
|
|
805
|
+
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
)}
|
|
809
|
+
{previewFile.fileType?.startsWith('image/') ? (
|
|
810
|
+
<img
|
|
811
|
+
onLoad={() => setPreviewLoading(false)}
|
|
812
|
+
onError={() => setPreviewLoading(false)}
|
|
813
|
+
src={previewFile.downloadURL}
|
|
814
|
+
alt={previewFile.fileName || previewFile.name}
|
|
815
|
+
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'}`}
|
|
816
|
+
onClick={e => e.stopPropagation()}
|
|
817
|
+
/>
|
|
818
|
+
) : previewFile.fileType?.startsWith('video/') ? (
|
|
819
|
+
<video
|
|
820
|
+
onLoadedData={() => setPreviewLoading(false)}
|
|
821
|
+
onError={() => setPreviewLoading(false)}
|
|
822
|
+
src={previewFile.downloadURL}
|
|
823
|
+
controls
|
|
824
|
+
autoPlay
|
|
825
|
+
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'}`}
|
|
826
|
+
onClick={e => e.stopPropagation()}
|
|
827
|
+
/>
|
|
828
|
+
) : previewFile.fileType === 'application/pdf' ? (
|
|
829
|
+
<iframe
|
|
830
|
+
onLoad={() => setPreviewLoading(false)}
|
|
831
|
+
src={previewFile.downloadURL}
|
|
832
|
+
className={`w-full h-full border-none rounded-lg relative z-10 transition-opacity duration-500 ${previewLoading ? 'opacity-0' : 'opacity-100'}`}
|
|
833
|
+
style={{ backgroundColor: 'white' }}
|
|
834
|
+
title="PDF Preview"
|
|
835
|
+
onClick={e => e.stopPropagation()}
|
|
836
|
+
/>
|
|
837
|
+
) : (
|
|
838
|
+
<div
|
|
839
|
+
className="w-full h-full flex flex-col items-center justify-center text-foreground/40 text-center relative z-10"
|
|
840
|
+
onClick={e => { e.stopPropagation(); setPreviewLoading(false); }}
|
|
841
|
+
>
|
|
842
|
+
<FileText className="w-24 h-24 mb-6 opacity-40 drop-shadow-sm" />
|
|
843
|
+
<span className="text-[20px] font-extrabold text-foreground">Preview Unavailable</span>
|
|
844
|
+
<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>
|
|
845
|
+
</div>
|
|
846
|
+
)}
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
</motion.div>
|
|
850
|
+
</div>
|
|
851
|
+
)}
|
|
852
|
+
</AnimatePresence>,
|
|
853
|
+
document.body
|
|
854
|
+
)}
|
|
855
|
+
|
|
856
|
+
{/* ── Edit Record Modal ──────────────────────────────────────────────── */}
|
|
857
|
+
<AnimatePresence>
|
|
858
|
+
{editingId && (
|
|
859
|
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
|
860
|
+
<motion.div
|
|
861
|
+
onClick={() => handleSaveEdit()}
|
|
862
|
+
initial={{ opacity: 0 }}
|
|
863
|
+
animate={{ opacity: 1 }}
|
|
864
|
+
exit={{ opacity: 0 }}
|
|
865
|
+
className="absolute inset-0 bg-background/60 backdrop-blur-sm cursor-pointer"
|
|
866
|
+
/>
|
|
867
|
+
<motion.div
|
|
868
|
+
initial={{ opacity: 0, scale: 0.97, y: 6 }}
|
|
869
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
870
|
+
exit={{ opacity: 0, scale: 0.97, y: 6 }}
|
|
871
|
+
transition={{ duration: 0.2 }}
|
|
872
|
+
className="w-full max-w-[440px] border-[var(--panel-border)] border rounded-2xl relative z-10 glass-panel shadow-2xl bg-background overflow-hidden"
|
|
873
|
+
>
|
|
874
|
+
<div className="p-5 flex flex-col gap-3 max-h-[80vh] overflow-y-auto">
|
|
875
|
+
{/* Header */}
|
|
876
|
+
<div className="flex items-center justify-between mb-2 mt-2 px-1">
|
|
877
|
+
<div className="flex items-center gap-2">
|
|
878
|
+
<span className="text-[14px] font-bold text-foreground">Edit Record</span>
|
|
879
|
+
{saving && <Loader2 className="w-3 h-3 animate-spin text-foreground/40" />}
|
|
880
|
+
</div>
|
|
881
|
+
<button
|
|
882
|
+
type="button"
|
|
883
|
+
onClick={() => handleSaveEdit()}
|
|
884
|
+
className="text-foreground/30 hover:text-foreground/60 transition-colors cursor-pointer"
|
|
885
|
+
>
|
|
886
|
+
<X className="w-4 h-4" />
|
|
887
|
+
</button>
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<div className="flex flex-col gap-0 max-h-[70vh] overflow-y-auto no-scrollbar px-1">
|
|
891
|
+
{/* Name field */}
|
|
892
|
+
<div className="flex flex-col relative group/field">
|
|
893
|
+
<input
|
|
894
|
+
readOnly
|
|
895
|
+
value="NAME"
|
|
896
|
+
className="no-glow bg-transparent text-[10px] font-bold text-foreground/30 uppercase tracking-[0.15em] outline-none pt-3 pb-0.5"
|
|
897
|
+
/>
|
|
898
|
+
<input
|
|
899
|
+
autoFocus
|
|
900
|
+
required
|
|
901
|
+
value={editName}
|
|
902
|
+
onChange={e => setEditName(e.target.value)}
|
|
903
|
+
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
|
904
|
+
onBlur={() => handleSilentSave()}
|
|
905
|
+
placeholder="Name *"
|
|
906
|
+
className="no-glow bg-transparent text-[13px] font-medium text-foreground outline-none placeholder:text-foreground/15 pb-1 caret-foreground/50"
|
|
907
|
+
/>
|
|
908
|
+
<div className="border-b border-dashed border-[var(--panel-border)]/60" />
|
|
909
|
+
</div>
|
|
910
|
+
|
|
911
|
+
{/* Note field */}
|
|
912
|
+
<div className="flex flex-col relative group/field">
|
|
913
|
+
<input
|
|
914
|
+
readOnly
|
|
915
|
+
value="NOTE"
|
|
916
|
+
className="no-glow bg-transparent text-[10px] font-bold text-foreground/30 uppercase tracking-[0.15em] outline-none pt-3 pb-0.5"
|
|
917
|
+
/>
|
|
918
|
+
<textarea
|
|
919
|
+
ref={el => { if (el) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } }}
|
|
920
|
+
value={editNote}
|
|
921
|
+
onChange={e => { setEditNote(e.target.value); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; }}
|
|
922
|
+
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); (e.target as HTMLTextAreaElement).blur(); } }}
|
|
923
|
+
onBlur={() => handleSilentSave()}
|
|
924
|
+
placeholder="Add a note..."
|
|
925
|
+
rows={1}
|
|
926
|
+
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"
|
|
927
|
+
/>
|
|
928
|
+
<div className="border-b border-dashed border-[var(--panel-border)]/60" />
|
|
929
|
+
</div>
|
|
930
|
+
|
|
931
|
+
{/* Custom Fields */}
|
|
932
|
+
{editFields.map((field, idx) => (
|
|
933
|
+
<div key={idx} className="flex flex-col relative group/field">
|
|
934
|
+
<input
|
|
935
|
+
placeholder="FIELD NAME"
|
|
936
|
+
value={field.key}
|
|
937
|
+
onChange={e => {
|
|
938
|
+
const next = [...editFields];
|
|
939
|
+
next[idx].key = e.target.value;
|
|
940
|
+
setEditFields(next);
|
|
941
|
+
}}
|
|
942
|
+
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
|
943
|
+
onBlur={() => handleSilentSave()}
|
|
944
|
+
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"
|
|
945
|
+
/>
|
|
946
|
+
<input
|
|
947
|
+
placeholder="Field value..."
|
|
948
|
+
value={field.value}
|
|
949
|
+
onChange={e => {
|
|
950
|
+
const next = [...editFields];
|
|
951
|
+
next[idx].value = e.target.value;
|
|
952
|
+
setEditFields(next);
|
|
953
|
+
}}
|
|
954
|
+
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
|
955
|
+
onBlur={() => handleSilentSave()}
|
|
956
|
+
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"
|
|
957
|
+
/>
|
|
958
|
+
<div className="border-b border-dashed border-[var(--panel-border)]/60" />
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
onClick={() => setEditFields(editFields.filter((_, i) => i !== idx))}
|
|
962
|
+
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"
|
|
963
|
+
>
|
|
964
|
+
<X className="w-3 h-3" />
|
|
965
|
+
</button>
|
|
966
|
+
</div>
|
|
967
|
+
))}
|
|
968
|
+
|
|
969
|
+
{/* Add Field */}
|
|
970
|
+
<button
|
|
971
|
+
type="button"
|
|
972
|
+
onClick={() => setEditFields([...editFields, { key: '', value: '' }])}
|
|
973
|
+
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"
|
|
974
|
+
>
|
|
975
|
+
<Plus className="w-3 h-3" /> Add Field
|
|
976
|
+
</button>
|
|
977
|
+
</div>
|
|
978
|
+
</div>
|
|
979
|
+
</motion.div>
|
|
980
|
+
</div>
|
|
981
|
+
)}
|
|
982
|
+
</AnimatePresence>
|
|
983
|
+
|
|
603
984
|
{/* ── Delete Confirmation ────────────────────────────────────────────── */}
|
|
604
985
|
<ConfirmModal
|
|
605
986
|
isOpen={!!deleteConfirm}
|
|
606
987
|
onCancel={() => setDeleteConfirm(null)}
|
|
607
988
|
onConfirm={handleDelete}
|
|
608
989
|
title="Delete Record"
|
|
609
|
-
message=
|
|
990
|
+
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.</>}
|
|
610
991
|
confirmText="Delete"
|
|
611
992
|
isProcessing={isDeleting}
|
|
612
993
|
/>
|