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