firebase-os 1.1.4 → 1.1.6
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/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +5 -20
- package/dist/firebase-os.es.js +95 -90
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +86 -15
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +81 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +233 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +407 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +378 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- package/src/templates/TemplateTable.tsx +675 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { ArrowLeft, Bot } from 'lucide-react';
|
|
4
|
+
import { ContactPopup } from '../components/ContactPopup';
|
|
5
|
+
import { db } from '../lib/firebase';
|
|
6
|
+
import { collection, getDocs } from 'firebase/firestore';
|
|
7
|
+
import { publicPagePrompt } from '../prompts';
|
|
8
|
+
|
|
9
|
+
export function PublicPageTemplate({ config }: { config: any }) {
|
|
10
|
+
const [copied, setCopied] = useState(false);
|
|
11
|
+
const [showForm, setShowForm] = useState<string | null>(null);
|
|
12
|
+
const [availableForms, setAvailableForms] = useState<string[]>([]);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
document.title = config.pageName || config.pageTitle || 'Firebase OS';
|
|
16
|
+
|
|
17
|
+
// Fetch available forms
|
|
18
|
+
getDocs(collection(db, 'sys_forms')).then(snap => {
|
|
19
|
+
setAvailableForms(snap.docs.filter(d => d.data().formType === 'public').map(doc => doc.id));
|
|
20
|
+
});
|
|
21
|
+
}, [config.pageName, config.pageTitle]);
|
|
22
|
+
|
|
23
|
+
const promptText = publicPagePrompt(config, availableForms);
|
|
24
|
+
|
|
25
|
+
const handleCopyPrompt = () => {
|
|
26
|
+
navigator.clipboard.writeText(promptText);
|
|
27
|
+
setCopied(true);
|
|
28
|
+
setTimeout(() => setCopied(false), 2000);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12 relative min-h-screen">
|
|
33
|
+
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-4">
|
|
34
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
35
|
+
{config.pageTitle || config.pageName || 'New Page'}
|
|
36
|
+
</h1>
|
|
37
|
+
{config.route && (
|
|
38
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
39
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
40
|
+
{config.route}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</motion.div>
|
|
44
|
+
|
|
45
|
+
<div className="flex justify-between items-center mb-6 pt-2 border-b border-[var(--panel-border)] pb-6 relative z-20 overflow-x-auto no-scrollbar gap-4">
|
|
46
|
+
<button onClick={() => window.history.back()} className="px-5 py-2 border border-[var(--panel-border)] rounded-xl text-[13px] font-bold hover:bg-foreground/5 transition-all flex items-center gap-2 cursor-pointer shadow-sm text-foreground/70 whitespace-nowrap shrink-0">
|
|
47
|
+
<ArrowLeft className="w-4 h-4" /> Go Back
|
|
48
|
+
</button>
|
|
49
|
+
<div className="flex gap-2 shrink-0">
|
|
50
|
+
{config.copyPrompt && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={handleCopyPrompt}
|
|
53
|
+
title="Copy Developer Prompt"
|
|
54
|
+
className="px-6 py-2.5 rounded-xl border border-[var(--panel-border)] bg-background flex items-center gap-2 hover:bg-foreground/5 hover:text-accent transition-all cursor-pointer text-[14px] font-bold tracking-wide shadow-sm whitespace-nowrap"
|
|
55
|
+
>
|
|
56
|
+
<Bot className="w-4 h-4" />
|
|
57
|
+
<span className="hidden sm:inline">{copied ? 'Copied!' : 'Copy Prompt'}</span>
|
|
58
|
+
</button>
|
|
59
|
+
)}
|
|
60
|
+
{config.showButton && (
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => {
|
|
63
|
+
if (config.action === 'redirect') window.location.href = config.url || '/';
|
|
64
|
+
else if (config.action === 'form') setShowForm(config.form || 'contact');
|
|
65
|
+
}}
|
|
66
|
+
className="px-6 py-2.5 rounded-xl font-bold tracking-wide transition-all duration-300 flex items-center justify-center btn-primary shadow-xl hover:-translate-y-1 active:scale-95 text-[14px] whitespace-nowrap"
|
|
67
|
+
>
|
|
68
|
+
{config.buttonText || 'Action Button'}
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<motion.div
|
|
75
|
+
initial={{ opacity: 0, y: 20 }}
|
|
76
|
+
animate={{ opacity: 1, y: 0 }}
|
|
77
|
+
className="glass-panel border border-[var(--panel-border)] rounded-3xl shadow-2xl relative flex flex-col flex-1 bg-background/50 h-full min-h-[400px]"
|
|
78
|
+
>
|
|
79
|
+
<div className="flex-1 flex flex-col items-center justify-center opacity-40 glass-panel border border-[var(--panel-border)]/50 border-dashed m-4 md:m-8 rounded-2xl p-8 py-20 text-center">
|
|
80
|
+
<Bot className="w-8 h-8 md:w-12 md:h-12 mb-4 text-foreground/80 opacity-50" />
|
|
81
|
+
<p className="text-[14px] font-bold mb-2">Click copy prompt at the top to start adding changes</p>
|
|
82
|
+
<div className="mt-8 text-left bg-background p-6 rounded-2xl text-[12px] font-mono whitespace-pre-wrap max-w-2xl border border-[var(--panel-border)]/50 text-foreground/60 shadow-inner">
|
|
83
|
+
{promptText}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</motion.div>
|
|
87
|
+
<AnimatePresence>
|
|
88
|
+
{showForm && <ContactPopup onClose={() => setShowForm(null)} formId={showForm} />}
|
|
89
|
+
</AnimatePresence>
|
|
90
|
+
</main>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { Bot, Plus, X, Loader2, Trash2, FileText, Download, Check, Edit2, Info } from 'lucide-react';
|
|
4
|
+
import * as IconoirIcons from 'iconoir-react';
|
|
5
|
+
import { DashboardNav } from '../components/DashboardNav';
|
|
6
|
+
import { Button } from '../components/Button';
|
|
7
|
+
import { useAuth } from '../lib/AuthContext';
|
|
8
|
+
import { db, storage } from '../lib/firebase';
|
|
9
|
+
import { collection, addDoc, getDocs, query, orderBy, where, serverTimestamp, deleteDoc, updateDoc, doc, onSnapshot } from 'firebase/firestore';
|
|
10
|
+
import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage';
|
|
11
|
+
import { ConfirmModal } from '../components/ConfirmModal';
|
|
12
|
+
|
|
13
|
+
interface SavedRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
note: string;
|
|
17
|
+
uid: string;
|
|
18
|
+
createdAt: any;
|
|
19
|
+
recordType?: 'record' | 'file';
|
|
20
|
+
fileName?: string;
|
|
21
|
+
fileType?: string;
|
|
22
|
+
fileSize?: number;
|
|
23
|
+
downloadURL?: string;
|
|
24
|
+
creatorName?: string;
|
|
25
|
+
creatorEmail?: string;
|
|
26
|
+
creatorAvatar?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
import { sharedCrudPrompt } from '../prompts';
|
|
30
|
+
|
|
31
|
+
export function SharedPageTemplate({ config }: { config: any }) {
|
|
32
|
+
const { user } = useAuth();
|
|
33
|
+
const [records, setRecords] = useState<SavedRecord[]>([]);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const [showAddForm, setShowAddForm] = useState(false);
|
|
36
|
+
const [newName, setNewName] = useState('');
|
|
37
|
+
const [newNote, setNewNote] = useState('');
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
const [copied, setCopied] = useState(false);
|
|
40
|
+
const [uploading, setUploading] = useState(false);
|
|
41
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
42
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
43
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
44
|
+
const [editName, setEditName] = useState('');
|
|
45
|
+
const [editNote, setEditNote] = useState('');
|
|
46
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
47
|
+
|
|
48
|
+
const parsedTabName = (config.tabName || config.pageId || 'shared_page')
|
|
49
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
50
|
+
const recordsCollection = `mem_${parsedTabName}_records`;
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
document.title = config.tabTitle || config.tabName || 'Firebase OS';
|
|
54
|
+
}, [config.tabTitle, config.tabName]);
|
|
55
|
+
|
|
56
|
+
// ── Real-time listener for shared records (visible to all members) ───────
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!user) return;
|
|
59
|
+
setLoading(true);
|
|
60
|
+
|
|
61
|
+
const q = query(collection(db, recordsCollection), orderBy('createdAt', 'desc'));
|
|
62
|
+
const unsub = onSnapshot(q, (snap) => {
|
|
63
|
+
setRecords(snap.docs.map(d => ({ id: d.id, ...d.data() } as SavedRecord)));
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}, (err) => {
|
|
66
|
+
console.error('Error listening to shared records:', err);
|
|
67
|
+
// Fallback: try without orderBy (index not yet created)
|
|
68
|
+
const fallbackUnsub = onSnapshot(collection(db, recordsCollection), (snap) => {
|
|
69
|
+
const items = snap.docs.map(d => ({ id: d.id, ...d.data() } as SavedRecord));
|
|
70
|
+
items.sort((a, b) => (b.createdAt?.toMillis?.() || 0) - (a.createdAt?.toMillis?.() || 0));
|
|
71
|
+
setRecords(items);
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
return () => fallbackUnsub();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return () => unsub();
|
|
78
|
+
}, [user, recordsCollection]);
|
|
79
|
+
|
|
80
|
+
// ── Get current user's embedded profile (no user_profiles query) ──────────
|
|
81
|
+
const getEmbeddedProfile = () => {
|
|
82
|
+
return {
|
|
83
|
+
name: user!.displayName || user!.email?.split('@')[0] || 'Member',
|
|
84
|
+
email: user!.email || '',
|
|
85
|
+
avatar: user!.photoURL || '',
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── Save new record ───────────────────────────────────────────────────────
|
|
90
|
+
const handleSaveRecord = async (e: React.FormEvent) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (!user || !newName.trim()) return;
|
|
93
|
+
setSaving(true);
|
|
94
|
+
try {
|
|
95
|
+
const profile = getEmbeddedProfile();
|
|
96
|
+
await addDoc(collection(db, recordsCollection), {
|
|
97
|
+
name: newName.trim(),
|
|
98
|
+
note: newNote.trim(),
|
|
99
|
+
uid: user.uid,
|
|
100
|
+
recordType: 'record',
|
|
101
|
+
creatorName: profile.name,
|
|
102
|
+
creatorEmail: profile.email,
|
|
103
|
+
creatorAvatar: profile.avatar,
|
|
104
|
+
createdAt: serverTimestamp(),
|
|
105
|
+
});
|
|
106
|
+
// onSnapshot will update the records list automatically
|
|
107
|
+
setNewName('');
|
|
108
|
+
setNewNote('');
|
|
109
|
+
setShowAddForm(false);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error('Error saving shared record:', e);
|
|
112
|
+
}
|
|
113
|
+
setSaving(false);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── Upload file ─────────────────────────────────────────────────────────
|
|
117
|
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
118
|
+
const file = e.target.files?.[0];
|
|
119
|
+
if (!file || !user) return;
|
|
120
|
+
setUploading(true);
|
|
121
|
+
try {
|
|
122
|
+
const uniqueName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
|
123
|
+
const storagePath = `mem_files/${uniqueName}`;
|
|
124
|
+
const fileRef = ref(storage, storagePath);
|
|
125
|
+
await uploadBytes(fileRef, file, {
|
|
126
|
+
customMetadata: { ownerId: user.uid }
|
|
127
|
+
});
|
|
128
|
+
const downloadURL = await getDownloadURL(fileRef);
|
|
129
|
+
const profile = getEmbeddedProfile();
|
|
130
|
+
|
|
131
|
+
// Save to mem_files so it appears in Drive > Shared
|
|
132
|
+
await addDoc(collection(db, 'mem_files'), {
|
|
133
|
+
fileName: file.name,
|
|
134
|
+
fileType: file.type || 'unknown',
|
|
135
|
+
fileSize: file.size,
|
|
136
|
+
downloadURL,
|
|
137
|
+
uid: user.uid,
|
|
138
|
+
uploaderEmail: user.email || 'member',
|
|
139
|
+
sourceTab: parsedTabName,
|
|
140
|
+
accessPrefix: 'mem_',
|
|
141
|
+
creatorName: profile.name,
|
|
142
|
+
creatorEmail: profile.email,
|
|
143
|
+
creatorAvatar: profile.avatar,
|
|
144
|
+
createdAt: serverTimestamp(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Save reference in this tab's records collection
|
|
148
|
+
await addDoc(collection(db, recordsCollection), {
|
|
149
|
+
name: file.name,
|
|
150
|
+
note: `${file.type || 'File'} · ${formatSize(file.size)}`,
|
|
151
|
+
uid: user.uid,
|
|
152
|
+
recordType: 'file',
|
|
153
|
+
fileName: file.name,
|
|
154
|
+
fileType: file.type || 'unknown',
|
|
155
|
+
fileSize: file.size,
|
|
156
|
+
downloadURL,
|
|
157
|
+
creatorName: profile.name,
|
|
158
|
+
creatorEmail: profile.email,
|
|
159
|
+
creatorAvatar: profile.avatar,
|
|
160
|
+
createdAt: serverTimestamp(),
|
|
161
|
+
});
|
|
162
|
+
// onSnapshot will update the records list automatically
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error('Error uploading shared file:', e);
|
|
165
|
+
}
|
|
166
|
+
setUploading(false);
|
|
167
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ── Delete record ─────────────────────────────────────────────────────────
|
|
171
|
+
const handleDelete = async () => {
|
|
172
|
+
if (!deleteConfirm) return;
|
|
173
|
+
setIsDeleting(true);
|
|
174
|
+
try {
|
|
175
|
+
const record = records.find(r => r.id === deleteConfirm);
|
|
176
|
+
await deleteDoc(doc(db, recordsCollection, deleteConfirm));
|
|
177
|
+
|
|
178
|
+
// If it's a file, also delete from Storage and mem_files
|
|
179
|
+
if (record?.recordType === 'file' && record.downloadURL) {
|
|
180
|
+
try {
|
|
181
|
+
const fileName = record.downloadURL.split('mem_files%2F')[1]?.split('?')[0];
|
|
182
|
+
if (fileName) {
|
|
183
|
+
const fileRef = ref(storage, `mem_files/${decodeURIComponent(fileName)}`);
|
|
184
|
+
await deleteObject(fileRef).catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
const filesSnap = await getDocs(query(
|
|
187
|
+
collection(db, 'mem_files'),
|
|
188
|
+
where('downloadURL', '==', record.downloadURL)
|
|
189
|
+
));
|
|
190
|
+
for (const d of filesSnap.docs) {
|
|
191
|
+
await deleteDoc(d.ref);
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error('Error cleaning up shared file:', e);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// onSnapshot will update the records list automatically
|
|
199
|
+
setDeleteConfirm(null);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
console.error('Error deleting shared record:', e);
|
|
202
|
+
}
|
|
203
|
+
setIsDeleting(false);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// ── Edit record (inline) ──────────────────────────────────────────────────
|
|
207
|
+
const startEdit = (record: SavedRecord) => {
|
|
208
|
+
setEditingId(record.id);
|
|
209
|
+
setEditName(record.name);
|
|
210
|
+
setEditNote(record.note || '');
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const handleSaveEdit = async () => {
|
|
214
|
+
if (!editingId || !editName.trim()) { setEditingId(null); return; }
|
|
215
|
+
const original = records.find(r => r.id === editingId);
|
|
216
|
+
if (original && original.name === editName.trim() && (original.note || '') === editNote.trim()) {
|
|
217
|
+
setEditingId(null); return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await updateDoc(doc(db, recordsCollection, editingId), {
|
|
221
|
+
name: editName.trim(),
|
|
222
|
+
note: editNote.trim(),
|
|
223
|
+
});
|
|
224
|
+
// onSnapshot will update the records list automatically
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error('Error updating shared record:', e);
|
|
227
|
+
}
|
|
228
|
+
setEditingId(null);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// ── Utils ─────────────────────────────────────────────────────────────────
|
|
232
|
+
const formatSize = (bytes: number) => {
|
|
233
|
+
if (!bytes) return '0 B';
|
|
234
|
+
const k = 1024;
|
|
235
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
236
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
237
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const formatDate = (ts: any) => {
|
|
241
|
+
if (!ts) return '';
|
|
242
|
+
const ms = ts.toMillis ? ts.toMillis() : ts;
|
|
243
|
+
return new Date(ms).toLocaleDateString('en-US', {
|
|
244
|
+
month: 'short', day: 'numeric', year: 'numeric'
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// ── Resolve storage icon from iconoir ─────────────────────────────────────
|
|
249
|
+
const getStorageIcon = () => {
|
|
250
|
+
const iconName = config.buttonStorageIcon || 'upload';
|
|
251
|
+
const cleanName = iconName.trim();
|
|
252
|
+
const variations = [
|
|
253
|
+
cleanName,
|
|
254
|
+
cleanName.charAt(0).toUpperCase() + cleanName.slice(1),
|
|
255
|
+
cleanName.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(''),
|
|
256
|
+
cleanName.split('_').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(''),
|
|
257
|
+
];
|
|
258
|
+
for (const v of variations) {
|
|
259
|
+
if ((IconoirIcons as any)[v]) return (IconoirIcons as any)[v];
|
|
260
|
+
}
|
|
261
|
+
return (IconoirIcons as any)['Upload'] || Plus;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const StorageIcon = getStorageIcon();
|
|
265
|
+
const isPrimaryBtn = config.buttonStyle !== 'secondary';
|
|
266
|
+
const isPrimaryStorage = config.buttonStorageStyle === 'primary';
|
|
267
|
+
const isOwner = (record: SavedRecord) => record.uid === user?.uid;
|
|
268
|
+
|
|
269
|
+
// ── Prompt ────────────────────────────────────────────────────────────────
|
|
270
|
+
const promptText = sharedCrudPrompt(config, recordsCollection);
|
|
271
|
+
|
|
272
|
+
const handleCopyPrompt = () => {
|
|
273
|
+
navigator.clipboard.writeText(promptText);
|
|
274
|
+
setCopied(true);
|
|
275
|
+
setTimeout(() => setCopied(false), 2000);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ── Creator badge (embedded in each record) ───────────────────────────────
|
|
279
|
+
const CreatorBadge = ({ record }: { record: SavedRecord }) => {
|
|
280
|
+
const name = record.creatorName || record.creatorEmail?.split('@')[0] || 'Member';
|
|
281
|
+
const email = record.creatorEmail || '';
|
|
282
|
+
const avatar = record.creatorAvatar || '';
|
|
283
|
+
const initials = name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
|
284
|
+
return (
|
|
285
|
+
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-[var(--panel-border)]/30">
|
|
286
|
+
{avatar ? (
|
|
287
|
+
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover shrink-0" />
|
|
288
|
+
) : (
|
|
289
|
+
<div className="w-5 h-5 rounded-full bg-accent/15 text-accent flex items-center justify-center text-[8px] font-extrabold shrink-0">
|
|
290
|
+
{initials}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
<div className="flex items-center gap-1 min-w-0 flex-1">
|
|
294
|
+
<span className="text-[11px] font-semibold text-foreground/40 truncate">{name}</span>
|
|
295
|
+
{email && <span className="text-[10px] text-foreground/25 truncate">· {email}</span>}
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
|
|
303
|
+
{/* ── Header ──────────────────────────────────────────────────────── */}
|
|
304
|
+
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-6 flex items-center justify-between">
|
|
305
|
+
<div>
|
|
306
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
307
|
+
{config.tabTitle || config.tabName || 'Shared Page'}
|
|
308
|
+
</h1>
|
|
309
|
+
{config.route && (
|
|
310
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
311
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
312
|
+
{config.route}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</motion.div>
|
|
317
|
+
|
|
318
|
+
<div className="flex flex-col gap-6 flex-1 pb-16">
|
|
319
|
+
<DashboardNav />
|
|
320
|
+
|
|
321
|
+
{/* ── Main Container ────────────────────────────────────────────── */}
|
|
322
|
+
<motion.div
|
|
323
|
+
initial={{ opacity: 0, y: 20 }}
|
|
324
|
+
animate={{ opacity: 1, y: 0 }}
|
|
325
|
+
transition={{ delay: 0.1 }}
|
|
326
|
+
className="flex flex-col glass-panel border border-[var(--panel-border)] rounded-3xl overflow-hidden flex-1 shadow-2xl bg-background"
|
|
327
|
+
>
|
|
328
|
+
{/* Container Header */}
|
|
329
|
+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-6 md:px-8 md:py-6 border-b border-[var(--panel-border)]/50 bg-background/50 shrink-0">
|
|
330
|
+
<div className="flex-1 min-w-0">
|
|
331
|
+
<h2 className="text-xl font-extrabold text-foreground tracking-tight truncate">
|
|
332
|
+
{config.tabTitle || config.tabName || 'Dashboard'}
|
|
333
|
+
</h2>
|
|
334
|
+
<p className="text-[13px] font-medium text-foreground/50 mt-1">
|
|
335
|
+
{loading ? 'Loading…' : `${records.length} ${records.length === 1 ? 'item' : 'items'} · Shared`}
|
|
336
|
+
</p>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
340
|
+
{/* Prompt Copy */}
|
|
341
|
+
{config.showPrompt && (
|
|
342
|
+
<motion.button
|
|
343
|
+
whileHover={{ scale: 1.05 }}
|
|
344
|
+
whileTap={{ scale: 0.95 }}
|
|
345
|
+
onClick={handleCopyPrompt}
|
|
346
|
+
title={copied ? 'Copied!' : 'Copy Developer Prompt'}
|
|
347
|
+
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-300 cursor-pointer ${copied
|
|
348
|
+
? 'bg-emerald-500/10 text-emerald-500'
|
|
349
|
+
: 'btn-secondary'
|
|
350
|
+
}`}
|
|
351
|
+
>
|
|
352
|
+
{copied ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
|
353
|
+
</motion.button>
|
|
354
|
+
)}
|
|
355
|
+
|
|
356
|
+
{/* Storage Upload */}
|
|
357
|
+
{config.storage !== false && (
|
|
358
|
+
<>
|
|
359
|
+
<input type="file" ref={fileInputRef} onChange={handleFileSelect} className="hidden" />
|
|
360
|
+
<motion.button
|
|
361
|
+
whileHover={{ scale: 1.05 }}
|
|
362
|
+
whileTap={{ scale: 0.95 }}
|
|
363
|
+
onClick={() => fileInputRef.current?.click()}
|
|
364
|
+
disabled={uploading}
|
|
365
|
+
title={config.buttonStorageText || 'Upload File'}
|
|
366
|
+
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'
|
|
367
|
+
} ${isPrimaryStorage ? 'btn-primary' : 'btn-secondary'}`}
|
|
368
|
+
>
|
|
369
|
+
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <StorageIcon className="w-4 h-4" />}
|
|
370
|
+
{config.buttonStorageText && <span className="text-[13px] font-bold">{config.buttonStorageText}</span>}
|
|
371
|
+
</motion.button>
|
|
372
|
+
</>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Add Button */}
|
|
376
|
+
{config.showButton !== false && (
|
|
377
|
+
<motion.button
|
|
378
|
+
whileHover={{ scale: 1.05 }}
|
|
379
|
+
whileTap={{ scale: 0.95 }}
|
|
380
|
+
onClick={() => setShowAddForm(true)}
|
|
381
|
+
title="Add New"
|
|
382
|
+
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'
|
|
383
|
+
} ${isPrimaryBtn ? 'btn-primary' : 'btn-secondary'}`}
|
|
384
|
+
>
|
|
385
|
+
{config.buttonText === '+' || !config.buttonText ? (
|
|
386
|
+
<Plus className="w-4 h-4" />
|
|
387
|
+
) : (
|
|
388
|
+
<span className="text-[13px] font-bold">{config.buttonText}</span>
|
|
389
|
+
)}
|
|
390
|
+
</motion.button>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{/* ── Content Area ─────────────────────────────────────────────── */}
|
|
396
|
+
<div className="p-6 md:p-8 flex-1 overflow-y-auto relative min-h-[400px]">
|
|
397
|
+
{loading && (
|
|
398
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-sm">
|
|
399
|
+
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{!loading && records.length === 0 && (
|
|
404
|
+
<motion.div
|
|
405
|
+
initial={{ opacity: 0, y: 10 }}
|
|
406
|
+
animate={{ opacity: 1, y: 0 }}
|
|
407
|
+
className="flex-1 flex flex-col items-center justify-center border border-[var(--panel-border)]/50 border-dashed rounded-2xl p-8 py-20 text-center min-h-[350px]"
|
|
408
|
+
>
|
|
409
|
+
<div className="w-16 h-16 rounded-2xl bg-accent/5 border border-accent/10 flex items-center justify-center mb-5">
|
|
410
|
+
<Plus className="w-6 h-6 text-accent/40" />
|
|
411
|
+
</div>
|
|
412
|
+
<p className="text-[15px] font-bold mb-1.5 text-foreground/50">No items yet</p>
|
|
413
|
+
<p className="text-[13px] font-medium text-foreground/35 max-w-xs leading-relaxed">
|
|
414
|
+
Use the buttons above to add records or upload files. All team members can view and contribute.
|
|
415
|
+
</p>
|
|
416
|
+
</motion.div>
|
|
417
|
+
)}
|
|
418
|
+
|
|
419
|
+
{!loading && records.length > 0 && (
|
|
420
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
421
|
+
<AnimatePresence>
|
|
422
|
+
{records.map((record) => {
|
|
423
|
+
const isEditing = editingId === record.id;
|
|
424
|
+
const owned = isOwner(record);
|
|
425
|
+
return (
|
|
426
|
+
<motion.div
|
|
427
|
+
key={record.id}
|
|
428
|
+
initial={false}
|
|
429
|
+
animate={{ opacity: 1, y: 0 }}
|
|
430
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
431
|
+
className={`group flex flex-col bg-background border rounded-2xl p-5 transition-all relative ${isEditing ? 'border-accent/40 shadow-lg ring-1 ring-accent/20' : 'border-[var(--panel-border)] hover:border-accent/30 hover:shadow-lg'
|
|
432
|
+
}`}
|
|
433
|
+
>
|
|
434
|
+
{/* Top-right action icons — download + edit/delete (owner only) */}
|
|
435
|
+
{!isEditing && (
|
|
436
|
+
<div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all">
|
|
437
|
+
{record.recordType === 'file' && record.downloadURL && (
|
|
438
|
+
<a href={record.downloadURL} target="_blank" rel="noreferrer" className="p-1.5 rounded-lg text-foreground/25 hover:text-blue-500 hover:bg-blue-500/10 transition-all" title="Download">
|
|
439
|
+
<Download className="w-3.5 h-3.5" />
|
|
440
|
+
</a>
|
|
441
|
+
)}
|
|
442
|
+
{owned && (
|
|
443
|
+
<>
|
|
444
|
+
<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">
|
|
445
|
+
<Edit2 className="w-3.5 h-3.5" />
|
|
446
|
+
</button>
|
|
447
|
+
<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">
|
|
448
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
449
|
+
</button>
|
|
450
|
+
</>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
<div className="flex items-start gap-3 mb-2">
|
|
456
|
+
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${record.recordType === 'file' ? 'bg-blue-500/10 text-blue-500' : 'bg-accent/10 text-accent'}`}>
|
|
457
|
+
{record.recordType === 'file' ? <FileText className="w-4 h-4" /> : <div className="w-2 h-2 rounded-full bg-current" />}
|
|
458
|
+
</div>
|
|
459
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
460
|
+
{isEditing ? (
|
|
461
|
+
<input autoFocus value={editName} onChange={e => setEditName(e.target.value)} onBlur={handleSaveEdit}
|
|
462
|
+
onKeyDown={e => { if (e.key === 'Enter') handleSaveEdit(); if (e.key === 'Escape') setEditingId(null); }}
|
|
463
|
+
className="text-[14px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full" />
|
|
464
|
+
) : (
|
|
465
|
+
<span className="text-[14px] font-bold text-foreground truncate pr-12">{record.name}</span>
|
|
466
|
+
)}
|
|
467
|
+
<span className="text-[11px] text-foreground/35 font-bold uppercase tracking-wider mt-0.5">
|
|
468
|
+
{formatDate(record.createdAt)}
|
|
469
|
+
</span>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{isEditing ? (
|
|
474
|
+
<textarea value={editNote} onChange={e => setEditNote(e.target.value)} onBlur={handleSaveEdit}
|
|
475
|
+
onKeyDown={e => { if (e.key === 'Escape') setEditingId(null); }}
|
|
476
|
+
placeholder="Note (optional)" rows={2}
|
|
477
|
+
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" />
|
|
478
|
+
) : (
|
|
479
|
+
record.note && (
|
|
480
|
+
<p className="text-[13px] text-foreground/55 font-medium leading-relaxed line-clamp-3 mb-1">{record.note}</p>
|
|
481
|
+
)
|
|
482
|
+
)}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
<CreatorBadge record={record} />
|
|
487
|
+
</motion.div>
|
|
488
|
+
);
|
|
489
|
+
})}
|
|
490
|
+
</AnimatePresence>
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{/* ── Settings Info ─────────────────────────────────────────────── */}
|
|
496
|
+
<div className="px-6 md:px-8 py-4 border-t border-[var(--panel-border)]/30 bg-background/30 shrink-0">
|
|
497
|
+
<div className="flex items-start gap-2 text-[11px] text-foreground/30 font-medium leading-relaxed">
|
|
498
|
+
<Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
|
499
|
+
<span>
|
|
500
|
+
Shared between all members · Edit and delete available on your own items only ·
|
|
501
|
+
Records: <code className="text-foreground/40 font-mono text-[10px]">{recordsCollection}</code> ·
|
|
502
|
+
Files: <code className="text-foreground/40 font-mono text-[10px]">mem_files</code> → Drive / Shared
|
|
503
|
+
</span>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
</motion.div>
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
{/* ── Add Record Modal ──────────────────────────────────────────────── */}
|
|
510
|
+
<AnimatePresence>
|
|
511
|
+
{showAddForm && (
|
|
512
|
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
|
513
|
+
<motion.div onClick={() => !saving && setShowAddForm(false)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-background/60 backdrop-blur-sm cursor-pointer" />
|
|
514
|
+
<motion.div initial={{ opacity: 0, scale: 0.97, y: 6 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: 6 }} transition={{ duration: 0.2 }}
|
|
515
|
+
className="w-full max-w-[360px] border-[var(--panel-border)] border rounded-2xl relative z-10 glass-panel shadow-2xl bg-background overflow-hidden">
|
|
516
|
+
<form onSubmit={handleSaveRecord} className="p-5 flex flex-col gap-3">
|
|
517
|
+
<div className="flex items-center justify-between mb-1">
|
|
518
|
+
<span className="text-[14px] font-bold text-foreground">New Record</span>
|
|
519
|
+
<button type="button" onClick={() => !saving && setShowAddForm(false)} className="text-foreground/30 hover:text-foreground/60 transition-colors cursor-pointer p-0.5">
|
|
520
|
+
<X className="w-4 h-4" />
|
|
521
|
+
</button>
|
|
522
|
+
</div>
|
|
523
|
+
<input autoFocus type="text" required value={newName} onChange={e => setNewName(e.target.value)} placeholder="Name *" disabled={saving}
|
|
524
|
+
className="w-full bg-foreground/[0.03] border border-[var(--panel-border)] px-3.5 py-2.5 text-[13px] font-semibold text-foreground outline-none focus:border-accent transition-colors placeholder:text-foreground/30"
|
|
525
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }} />
|
|
526
|
+
<textarea value={newNote} onChange={e => setNewNote(e.target.value)} placeholder="Note (optional)" disabled={saving} rows={2}
|
|
527
|
+
className="w-full bg-foreground/[0.03] border border-[var(--panel-border)] px-3.5 py-2.5 text-[13px] font-medium text-foreground outline-none focus:border-accent transition-colors placeholder:text-foreground/30 resize-none"
|
|
528
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }} />
|
|
529
|
+
<div className="flex justify-end pt-1">
|
|
530
|
+
<Button type="submit" isLoading={saving} className="px-5 py-2 text-[12px] font-bold shadow-md">
|
|
531
|
+
{saving ? 'Saving…' : 'Save'}
|
|
532
|
+
</Button>
|
|
533
|
+
</div>
|
|
534
|
+
</form>
|
|
535
|
+
</motion.div>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</AnimatePresence>
|
|
539
|
+
|
|
540
|
+
<ConfirmModal
|
|
541
|
+
isOpen={!!deleteConfirm}
|
|
542
|
+
onCancel={() => setDeleteConfirm(null)}
|
|
543
|
+
onConfirm={handleDelete}
|
|
544
|
+
title="Delete Record"
|
|
545
|
+
message="Are you sure you want to delete this record? This action cannot be undone."
|
|
546
|
+
confirmText="Delete"
|
|
547
|
+
isProcessing={isDeleting}
|
|
548
|
+
/>
|
|
549
|
+
</main>
|
|
550
|
+
);
|
|
551
|
+
}
|