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,678 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } 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, getDoc, 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
|
+
// Creator profile fields
|
|
25
|
+
creatorName?: string;
|
|
26
|
+
creatorEmail?: string;
|
|
27
|
+
creatorAvatar?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
import { adminCrudPrompt } from '../prompts';
|
|
31
|
+
|
|
32
|
+
export function AdminPageTemplate({ config }: { config: any }) {
|
|
33
|
+
const { user } = useAuth();
|
|
34
|
+
const [records, setRecords] = useState<SavedRecord[]>([]);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
const [showAddForm, setShowAddForm] = useState(false);
|
|
37
|
+
const [newName, setNewName] = useState('');
|
|
38
|
+
const [newNote, setNewNote] = useState('');
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [copied, setCopied] = useState(false);
|
|
41
|
+
const [uploading, setUploading] = useState(false);
|
|
42
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
43
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
44
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
45
|
+
const [editName, setEditName] = useState('');
|
|
46
|
+
const [editNote, setEditNote] = useState('');
|
|
47
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
const [profileCache, setProfileCache] = useState<Record<string, { name: string; email: string; avatar: string }>>({});
|
|
49
|
+
|
|
50
|
+
const parsedTabName = (config.tabName || config.pageId || 'admin_page')
|
|
51
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
52
|
+
const recordsCollection = `admin_${parsedTabName}_records`;
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
document.title = config.tabTitle || config.tabName || 'Firebase OS';
|
|
56
|
+
}, [config.tabTitle, config.tabName]);
|
|
57
|
+
|
|
58
|
+
// ── Fetch live profile for a uid (cached) ────────────────────────────────
|
|
59
|
+
const fetchProfile = useCallback(async (uid: string) => {
|
|
60
|
+
if (profileCache[uid]) return profileCache[uid];
|
|
61
|
+
try {
|
|
62
|
+
const profileDoc = await getDoc(doc(db, 'user_profiles', uid));
|
|
63
|
+
if (profileDoc.exists()) {
|
|
64
|
+
const data = profileDoc.data();
|
|
65
|
+
const profile = {
|
|
66
|
+
name: data.displayName || data.name || data.email?.split('@')[0] || 'Admin',
|
|
67
|
+
email: data.email || '',
|
|
68
|
+
avatar: data.avatar || '',
|
|
69
|
+
};
|
|
70
|
+
setProfileCache(prev => ({ ...prev, [uid]: profile }));
|
|
71
|
+
return profile;
|
|
72
|
+
}
|
|
73
|
+
} catch (e) { /* fallback */ }
|
|
74
|
+
const fallback = { name: 'Admin', email: '', avatar: '' };
|
|
75
|
+
setProfileCache(prev => ({ ...prev, [uid]: fallback }));
|
|
76
|
+
return fallback;
|
|
77
|
+
}, [profileCache]);
|
|
78
|
+
|
|
79
|
+
// ── Real-time listener for admin records (shared between admins) ─────────
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!user) return;
|
|
82
|
+
setLoading(true);
|
|
83
|
+
|
|
84
|
+
const q = query(collection(db, recordsCollection), orderBy('createdAt', 'desc'));
|
|
85
|
+
const unsub = onSnapshot(q, (snap) => {
|
|
86
|
+
const items = snap.docs.map(d => ({ id: d.id, ...d.data() } as SavedRecord));
|
|
87
|
+
setRecords(items);
|
|
88
|
+
setLoading(false);
|
|
89
|
+
|
|
90
|
+
// Pre-fetch profiles for any new creators
|
|
91
|
+
const uids = [...new Set(items.map(r => r.uid).filter(Boolean))];
|
|
92
|
+
for (const uid of uids) { fetchProfile(uid); }
|
|
93
|
+
}, (err) => {
|
|
94
|
+
console.error('Error listening to admin records:', err);
|
|
95
|
+
// Fallback: try without orderBy (index not yet created)
|
|
96
|
+
const fallbackUnsub = onSnapshot(collection(db, recordsCollection), (snap) => {
|
|
97
|
+
const items = snap.docs.map(d => ({ id: d.id, ...d.data() } as SavedRecord));
|
|
98
|
+
items.sort((a, b) => (b.createdAt?.toMillis?.() || 0) - (a.createdAt?.toMillis?.() || 0));
|
|
99
|
+
setRecords(items);
|
|
100
|
+
setLoading(false);
|
|
101
|
+
const uids = [...new Set(items.map(r => r.uid).filter(Boolean))];
|
|
102
|
+
for (const uid of uids) { fetchProfile(uid); }
|
|
103
|
+
});
|
|
104
|
+
return () => fallbackUnsub();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return () => unsub();
|
|
108
|
+
}, [user, recordsCollection]);
|
|
109
|
+
|
|
110
|
+
// ── Save new record ───────────────────────────────────────────────────────
|
|
111
|
+
const handleSaveRecord = async (e: React.FormEvent) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
if (!user || !newName.trim()) return;
|
|
114
|
+
setSaving(true);
|
|
115
|
+
try {
|
|
116
|
+
const profile = await getCreatorProfile();
|
|
117
|
+
await addDoc(collection(db, recordsCollection), {
|
|
118
|
+
name: newName.trim(),
|
|
119
|
+
note: newNote.trim(),
|
|
120
|
+
uid: user.uid,
|
|
121
|
+
recordType: 'record',
|
|
122
|
+
creatorName: profile.name,
|
|
123
|
+
creatorEmail: profile.email,
|
|
124
|
+
creatorAvatar: profile.avatar,
|
|
125
|
+
createdAt: serverTimestamp(),
|
|
126
|
+
});
|
|
127
|
+
// onSnapshot will update the records list automatically
|
|
128
|
+
setNewName('');
|
|
129
|
+
setNewNote('');
|
|
130
|
+
setShowAddForm(false);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error('Error saving admin record:', e);
|
|
133
|
+
}
|
|
134
|
+
setSaving(false);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── Upload file ───────────────────────────────────────────────────────────
|
|
138
|
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
139
|
+
const file = e.target.files?.[0];
|
|
140
|
+
if (!file || !user) return;
|
|
141
|
+
setUploading(true);
|
|
142
|
+
try {
|
|
143
|
+
const uniqueName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
|
144
|
+
const storagePath = `admin_files/${uniqueName}`;
|
|
145
|
+
const fileRef = ref(storage, storagePath);
|
|
146
|
+
await uploadBytes(fileRef, file, {
|
|
147
|
+
customMetadata: { ownerId: user.uid }
|
|
148
|
+
});
|
|
149
|
+
const downloadURL = await getDownloadURL(fileRef);
|
|
150
|
+
const profile = await getCreatorProfile();
|
|
151
|
+
|
|
152
|
+
// Save to admin_files so it appears in Drive > Admin
|
|
153
|
+
await addDoc(collection(db, 'admin_files'), {
|
|
154
|
+
fileName: file.name,
|
|
155
|
+
fileType: file.type || 'unknown',
|
|
156
|
+
fileSize: file.size,
|
|
157
|
+
downloadURL,
|
|
158
|
+
uid: user.uid,
|
|
159
|
+
uploaderEmail: user.email || 'admin',
|
|
160
|
+
sourceTab: parsedTabName,
|
|
161
|
+
accessPrefix: 'admin_',
|
|
162
|
+
creatorName: profile.name,
|
|
163
|
+
creatorEmail: profile.email,
|
|
164
|
+
creatorAvatar: profile.avatar,
|
|
165
|
+
createdAt: serverTimestamp(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Save reference in this tab's records collection
|
|
169
|
+
await addDoc(collection(db, recordsCollection), {
|
|
170
|
+
name: file.name,
|
|
171
|
+
note: `${file.type || 'File'} · ${formatSize(file.size)}`,
|
|
172
|
+
uid: user.uid,
|
|
173
|
+
recordType: 'file',
|
|
174
|
+
fileName: file.name,
|
|
175
|
+
fileType: file.type || 'unknown',
|
|
176
|
+
fileSize: file.size,
|
|
177
|
+
downloadURL,
|
|
178
|
+
creatorName: profile.name,
|
|
179
|
+
creatorEmail: profile.email,
|
|
180
|
+
creatorAvatar: profile.avatar,
|
|
181
|
+
createdAt: serverTimestamp(),
|
|
182
|
+
});
|
|
183
|
+
// onSnapshot will update the records list automatically
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error('Error uploading admin file:', e);
|
|
186
|
+
}
|
|
187
|
+
setUploading(false);
|
|
188
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ── Delete record ─────────────────────────────────────────────────────────
|
|
192
|
+
const handleDelete = async () => {
|
|
193
|
+
if (!deleteConfirm) return;
|
|
194
|
+
setIsDeleting(true);
|
|
195
|
+
try {
|
|
196
|
+
const record = records.find(r => r.id === deleteConfirm);
|
|
197
|
+
await deleteDoc(doc(db, recordsCollection, deleteConfirm));
|
|
198
|
+
|
|
199
|
+
// If it's a file, also delete from Storage and admin_files
|
|
200
|
+
if (record?.recordType === 'file' && record.downloadURL) {
|
|
201
|
+
try {
|
|
202
|
+
// Delete from Storage
|
|
203
|
+
const fileName = record.downloadURL.split('admin_files%2F')[1]?.split('?')[0];
|
|
204
|
+
if (fileName) {
|
|
205
|
+
const fileRef = ref(storage, `admin_files/${decodeURIComponent(fileName)}`);
|
|
206
|
+
await deleteObject(fileRef).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
// Delete from admin_files Firestore collection
|
|
209
|
+
const filesSnap = await getDocs(query(
|
|
210
|
+
collection(db, 'admin_files'),
|
|
211
|
+
where('downloadURL', '==', record.downloadURL)
|
|
212
|
+
));
|
|
213
|
+
for (const d of filesSnap.docs) {
|
|
214
|
+
await deleteDoc(d.ref);
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
console.error('Error cleaning up admin file:', e);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// onSnapshot will update the records list automatically
|
|
222
|
+
setDeleteConfirm(null);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.error('Error deleting admin record:', e);
|
|
225
|
+
}
|
|
226
|
+
setIsDeleting(false);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ── Edit record (inline) ──────────────────────────────────────────────────
|
|
230
|
+
const startEdit = (record: SavedRecord) => {
|
|
231
|
+
setEditingId(record.id);
|
|
232
|
+
setEditName(record.name);
|
|
233
|
+
setEditNote(record.note || '');
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const handleSaveEdit = async () => {
|
|
237
|
+
if (!editingId || !editName.trim()) { setEditingId(null); return; }
|
|
238
|
+
const original = records.find(r => r.id === editingId);
|
|
239
|
+
if (original && original.name === editName.trim() && (original.note || '') === editNote.trim()) {
|
|
240
|
+
setEditingId(null); return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await updateDoc(doc(db, recordsCollection, editingId), {
|
|
244
|
+
name: editName.trim(),
|
|
245
|
+
note: editNote.trim(),
|
|
246
|
+
});
|
|
247
|
+
// onSnapshot will update the records list automatically
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error('Error updating admin record:', e);
|
|
250
|
+
}
|
|
251
|
+
setEditingId(null);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// ── Utils ─────────────────────────────────────────────────────────────────
|
|
255
|
+
const getCreatorProfile = async (): Promise<{ name: string; email: string; avatar: string }> => {
|
|
256
|
+
try {
|
|
257
|
+
const profileDoc = await getDoc(doc(db, 'user_profiles', user!.uid));
|
|
258
|
+
if (profileDoc.exists()) {
|
|
259
|
+
const data = profileDoc.data();
|
|
260
|
+
const profile = {
|
|
261
|
+
name: data.displayName || data.name || user!.email?.split('@')[0] || 'Admin',
|
|
262
|
+
email: data.email || user!.email || '',
|
|
263
|
+
avatar: data.avatar || user!.photoURL || '',
|
|
264
|
+
};
|
|
265
|
+
// Update cache so CreatorBadge shows the latest avatar
|
|
266
|
+
setProfileCache(prev => ({ ...prev, [user!.uid]: profile }));
|
|
267
|
+
return profile;
|
|
268
|
+
}
|
|
269
|
+
} catch (e) { /* fallback below */ }
|
|
270
|
+
return {
|
|
271
|
+
name: user!.displayName || user!.email?.split('@')[0] || 'Admin',
|
|
272
|
+
email: user!.email || '',
|
|
273
|
+
avatar: user!.photoURL || '',
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const formatSize = (bytes: number) => {
|
|
278
|
+
if (!bytes) return '0 B';
|
|
279
|
+
const k = 1024;
|
|
280
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
281
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
282
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const formatDate = (ts: any) => {
|
|
286
|
+
if (!ts) return '';
|
|
287
|
+
const ms = ts.toMillis ? ts.toMillis() : ts;
|
|
288
|
+
return new Date(ms).toLocaleDateString('en-US', {
|
|
289
|
+
month: 'short', day: 'numeric', year: 'numeric'
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// ── Prompt text ───────────────────────────────────────────────────────────
|
|
294
|
+
const promptText = adminCrudPrompt(config, recordsCollection);
|
|
295
|
+
|
|
296
|
+
const handleCopyPrompt = () => {
|
|
297
|
+
navigator.clipboard.writeText(promptText);
|
|
298
|
+
setCopied(true);
|
|
299
|
+
setTimeout(() => setCopied(false), 2000);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ── Resolve storage icon from iconoir ─────────────────────────────────────
|
|
303
|
+
const getStorageIcon = () => {
|
|
304
|
+
const iconName = config.buttonStorageIcon || 'upload';
|
|
305
|
+
const cleanName = iconName.trim();
|
|
306
|
+
const variations = [
|
|
307
|
+
cleanName,
|
|
308
|
+
cleanName.charAt(0).toUpperCase() + cleanName.slice(1),
|
|
309
|
+
cleanName.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(''),
|
|
310
|
+
cleanName.split('_').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(''),
|
|
311
|
+
];
|
|
312
|
+
for (const v of variations) {
|
|
313
|
+
if ((IconoirIcons as any)[v]) return (IconoirIcons as any)[v];
|
|
314
|
+
}
|
|
315
|
+
return (IconoirIcons as any)['Upload'] || Plus;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const StorageIcon = getStorageIcon();
|
|
319
|
+
const isPrimaryBtn = config.buttonStyle !== 'secondary';
|
|
320
|
+
const isPrimaryStorage = config.buttonStorageStyle === 'primary';
|
|
321
|
+
const isOwner = (record: SavedRecord) => record.uid === user?.uid;
|
|
322
|
+
|
|
323
|
+
// ── Creator badge (uses live profile from cache) ──────────────────────────
|
|
324
|
+
const CreatorBadge = ({ record }: { record: SavedRecord }) => {
|
|
325
|
+
const liveProfile = profileCache[record.uid];
|
|
326
|
+
const name = liveProfile?.name || record.creatorName || record.creatorEmail?.split('@')[0] || 'Admin';
|
|
327
|
+
const email = liveProfile?.email || record.creatorEmail || '';
|
|
328
|
+
const avatar = liveProfile?.avatar || record.creatorAvatar || '';
|
|
329
|
+
const initials = name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
|
330
|
+
return (
|
|
331
|
+
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-[var(--panel-border)]/30">
|
|
332
|
+
{avatar ? (
|
|
333
|
+
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover shrink-0" />
|
|
334
|
+
) : (
|
|
335
|
+
<div className="w-5 h-5 rounded-full bg-accent/15 text-accent flex items-center justify-center text-[8px] font-extrabold shrink-0">
|
|
336
|
+
{initials}
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
<div className="flex items-center gap-1 min-w-0 flex-1">
|
|
340
|
+
<span className="text-[11px] font-semibold text-foreground/40 truncate">{name}</span>
|
|
341
|
+
{email && <span className="text-[10px] text-foreground/25 truncate">· {email}</span>}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
|
|
349
|
+
{/* ── Header ──────────────────────────────────────────────────────── */}
|
|
350
|
+
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-6 flex items-center justify-between">
|
|
351
|
+
<div>
|
|
352
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
353
|
+
{config.tabTitle || config.tabName || 'Admin Page'}
|
|
354
|
+
</h1>
|
|
355
|
+
{config.route && (
|
|
356
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
357
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
358
|
+
{config.route}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
</motion.div>
|
|
363
|
+
|
|
364
|
+
<div className="flex flex-col gap-6 flex-1 pb-16">
|
|
365
|
+
<DashboardNav />
|
|
366
|
+
|
|
367
|
+
{/* ── Main Container ────────────────────────────────────────────── */}
|
|
368
|
+
<motion.div
|
|
369
|
+
initial={{ opacity: 0, y: 20 }}
|
|
370
|
+
animate={{ opacity: 1, y: 0 }}
|
|
371
|
+
transition={{ delay: 0.1 }}
|
|
372
|
+
className="flex flex-col glass-panel border border-[var(--panel-border)] rounded-3xl overflow-hidden flex-1 shadow-2xl bg-background"
|
|
373
|
+
>
|
|
374
|
+
{/* Container Header */}
|
|
375
|
+
<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">
|
|
376
|
+
<div className="flex-1 min-w-0">
|
|
377
|
+
<h2 className="text-xl font-extrabold text-foreground tracking-tight truncate">
|
|
378
|
+
{config.tabTitle || config.tabName || 'Dashboard'}
|
|
379
|
+
</h2>
|
|
380
|
+
<p className="text-[13px] font-medium text-foreground/50 mt-1">
|
|
381
|
+
{loading ? 'Loading…' : `${records.length} ${records.length === 1 ? 'item' : 'items'} · Shared between admins`}
|
|
382
|
+
</p>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
386
|
+
{/* Prompt Copy */}
|
|
387
|
+
{config.showPrompt && (
|
|
388
|
+
<motion.button
|
|
389
|
+
whileHover={{ scale: 1.05 }}
|
|
390
|
+
whileTap={{ scale: 0.95 }}
|
|
391
|
+
onClick={handleCopyPrompt}
|
|
392
|
+
title={copied ? 'Copied!' : 'Copy Developer Prompt'}
|
|
393
|
+
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-300 cursor-pointer ${
|
|
394
|
+
copied
|
|
395
|
+
? 'bg-emerald-500/10 text-emerald-500'
|
|
396
|
+
: 'btn-secondary'
|
|
397
|
+
}`}
|
|
398
|
+
>
|
|
399
|
+
{copied ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
|
400
|
+
</motion.button>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Storage Upload */}
|
|
404
|
+
{config.storage !== false && (
|
|
405
|
+
<>
|
|
406
|
+
<input
|
|
407
|
+
type="file"
|
|
408
|
+
ref={fileInputRef}
|
|
409
|
+
onChange={handleFileSelect}
|
|
410
|
+
className="hidden"
|
|
411
|
+
/>
|
|
412
|
+
<motion.button
|
|
413
|
+
whileHover={{ scale: 1.05 }}
|
|
414
|
+
whileTap={{ scale: 0.95 }}
|
|
415
|
+
onClick={() => fileInputRef.current?.click()}
|
|
416
|
+
disabled={uploading}
|
|
417
|
+
title={config.buttonStorageText || 'Upload File'}
|
|
418
|
+
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 ${
|
|
419
|
+
config.buttonStorageText ? 'px-4' : 'w-10'
|
|
420
|
+
} ${isPrimaryStorage ? 'btn-primary' : 'btn-secondary'}`}
|
|
421
|
+
>
|
|
422
|
+
{uploading ? (
|
|
423
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
424
|
+
) : (
|
|
425
|
+
<StorageIcon className="w-4 h-4" />
|
|
426
|
+
)}
|
|
427
|
+
{config.buttonStorageText && (
|
|
428
|
+
<span className="text-[13px] font-bold">{config.buttonStorageText}</span>
|
|
429
|
+
)}
|
|
430
|
+
</motion.button>
|
|
431
|
+
</>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
{/* Add Button */}
|
|
435
|
+
{config.showButton !== false && (
|
|
436
|
+
<motion.button
|
|
437
|
+
whileHover={{ scale: 1.05 }}
|
|
438
|
+
whileTap={{ scale: 0.95 }}
|
|
439
|
+
onClick={() => setShowAddForm(true)}
|
|
440
|
+
title="Add New"
|
|
441
|
+
className={`h-10 flex items-center justify-center gap-2 rounded-xl transition-all duration-300 cursor-pointer ${
|
|
442
|
+
config.buttonText && config.buttonText !== '+' ? 'px-5' : 'w-10'
|
|
443
|
+
} ${isPrimaryBtn ? 'btn-primary' : 'btn-secondary'}`}
|
|
444
|
+
>
|
|
445
|
+
{config.buttonText === '+' || !config.buttonText ? (
|
|
446
|
+
<Plus className="w-4 h-4" />
|
|
447
|
+
) : (
|
|
448
|
+
<span className="text-[13px] font-bold">{config.buttonText}</span>
|
|
449
|
+
)}
|
|
450
|
+
</motion.button>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
{/* ── Content Area ─────────────────────────────────────────────── */}
|
|
456
|
+
<div className="p-6 md:p-8 flex-1 overflow-y-auto relative min-h-[400px]">
|
|
457
|
+
{/* Loading */}
|
|
458
|
+
{loading && (
|
|
459
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-sm">
|
|
460
|
+
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{/* Empty State */}
|
|
465
|
+
{!loading && records.length === 0 && (
|
|
466
|
+
<motion.div
|
|
467
|
+
initial={{ opacity: 0, y: 10 }}
|
|
468
|
+
animate={{ opacity: 1, y: 0 }}
|
|
469
|
+
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]"
|
|
470
|
+
>
|
|
471
|
+
<div className="w-16 h-16 rounded-2xl bg-accent/5 border border-accent/10 flex items-center justify-center mb-5">
|
|
472
|
+
<Plus className="w-6 h-6 text-accent/40" />
|
|
473
|
+
</div>
|
|
474
|
+
<p className="text-[15px] font-bold mb-1.5 text-foreground/50">No items yet</p>
|
|
475
|
+
<p className="text-[13px] font-medium text-foreground/35 max-w-xs leading-relaxed">
|
|
476
|
+
Use the buttons above to add records or upload files. All admins can view and contribute.
|
|
477
|
+
</p>
|
|
478
|
+
</motion.div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{/* Records Grid */}
|
|
482
|
+
{!loading && records.length > 0 && (
|
|
483
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
484
|
+
<AnimatePresence>
|
|
485
|
+
{records.map((record, i) => {
|
|
486
|
+
const isEditing = editingId === record.id;
|
|
487
|
+
const owned = isOwner(record);
|
|
488
|
+
return (
|
|
489
|
+
<motion.div
|
|
490
|
+
key={record.id}
|
|
491
|
+
initial={false}
|
|
492
|
+
animate={{ opacity: 1, y: 0 }}
|
|
493
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
494
|
+
className={`group flex flex-col bg-background border rounded-2xl p-5 transition-all relative ${
|
|
495
|
+
isEditing ? 'border-accent/40 shadow-lg ring-1 ring-accent/20' : 'border-[var(--panel-border)] hover:border-accent/30 hover:shadow-lg'
|
|
496
|
+
}`}
|
|
497
|
+
>
|
|
498
|
+
{/* Top-right action icons — download + edit + delete */}
|
|
499
|
+
{!isEditing && (
|
|
500
|
+
<div className="absolute top-3 right-3 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all">
|
|
501
|
+
{record.recordType === 'file' && record.downloadURL && (
|
|
502
|
+
<a
|
|
503
|
+
href={record.downloadURL}
|
|
504
|
+
target="_blank"
|
|
505
|
+
rel="noreferrer"
|
|
506
|
+
className="p-1.5 rounded-lg text-foreground/25 hover:text-blue-500 hover:bg-blue-500/10 transition-all"
|
|
507
|
+
title="Download"
|
|
508
|
+
>
|
|
509
|
+
<Download className="w-3.5 h-3.5" />
|
|
510
|
+
</a>
|
|
511
|
+
)}
|
|
512
|
+
{owned && (
|
|
513
|
+
<>
|
|
514
|
+
<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">
|
|
515
|
+
<Edit2 className="w-3.5 h-3.5" />
|
|
516
|
+
</button>
|
|
517
|
+
<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">
|
|
518
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
519
|
+
</button>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
<div className="flex items-start gap-3 mb-2">
|
|
526
|
+
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${
|
|
527
|
+
record.recordType === 'file'
|
|
528
|
+
? 'bg-blue-500/10 text-blue-500'
|
|
529
|
+
: 'bg-accent/10 text-accent'
|
|
530
|
+
}`}>
|
|
531
|
+
{record.recordType === 'file' ? (
|
|
532
|
+
<FileText className="w-4 h-4" />
|
|
533
|
+
) : (
|
|
534
|
+
<div className="w-2 h-2 rounded-full bg-current" />
|
|
535
|
+
)}
|
|
536
|
+
</div>
|
|
537
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
538
|
+
{isEditing ? (
|
|
539
|
+
<input
|
|
540
|
+
autoFocus
|
|
541
|
+
value={editName}
|
|
542
|
+
onChange={e => setEditName(e.target.value)}
|
|
543
|
+
onBlur={handleSaveEdit}
|
|
544
|
+
onKeyDown={e => { if (e.key === 'Enter') handleSaveEdit(); if (e.key === 'Escape') setEditingId(null); }}
|
|
545
|
+
className="text-[14px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full"
|
|
546
|
+
/>
|
|
547
|
+
) : (
|
|
548
|
+
<span className="text-[14px] font-bold text-foreground truncate pr-12">
|
|
549
|
+
{record.name}
|
|
550
|
+
</span>
|
|
551
|
+
)}
|
|
552
|
+
<span className="text-[11px] text-foreground/35 font-bold uppercase tracking-wider mt-0.5">
|
|
553
|
+
{formatDate(record.createdAt)}
|
|
554
|
+
</span>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
{isEditing ? (
|
|
559
|
+
<textarea
|
|
560
|
+
value={editNote}
|
|
561
|
+
onChange={e => setEditNote(e.target.value)}
|
|
562
|
+
onBlur={handleSaveEdit}
|
|
563
|
+
onKeyDown={e => { if (e.key === 'Escape') setEditingId(null); }}
|
|
564
|
+
placeholder="Note (optional)"
|
|
565
|
+
rows={2}
|
|
566
|
+
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"
|
|
567
|
+
/>
|
|
568
|
+
) : (
|
|
569
|
+
record.note && (
|
|
570
|
+
<p className="text-[13px] text-foreground/55 font-medium leading-relaxed line-clamp-3 mb-1">
|
|
571
|
+
{record.note}
|
|
572
|
+
</p>
|
|
573
|
+
)
|
|
574
|
+
)}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
{/* Creator profile badge */}
|
|
579
|
+
<CreatorBadge record={record} />
|
|
580
|
+
</motion.div>
|
|
581
|
+
);
|
|
582
|
+
})}
|
|
583
|
+
</AnimatePresence>
|
|
584
|
+
</div>
|
|
585
|
+
)}
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
{/* ── Settings Info ─────────────────────────────────────────────── */}
|
|
589
|
+
<div className="px-6 md:px-8 py-4 border-t border-[var(--panel-border)]/30 bg-background/30 shrink-0">
|
|
590
|
+
<div className="flex items-start gap-2 text-[11px] text-foreground/30 font-medium leading-relaxed">
|
|
591
|
+
<Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
|
592
|
+
<span>
|
|
593
|
+
Shared between all admins · Edit and delete available on your own items only ·
|
|
594
|
+
Records: <code className="text-foreground/40 font-mono text-[10px]">{recordsCollection}</code> ·
|
|
595
|
+
Files: <code className="text-foreground/40 font-mono text-[10px]">admin_files</code> → Drive / Admin
|
|
596
|
+
</span>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</motion.div>
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
{/* ── Add Record Modal ──────────────────────────────────────────────── */}
|
|
603
|
+
<AnimatePresence>
|
|
604
|
+
{showAddForm && (
|
|
605
|
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
|
606
|
+
<motion.div
|
|
607
|
+
onClick={() => !saving && setShowAddForm(false)}
|
|
608
|
+
initial={{ opacity: 0 }}
|
|
609
|
+
animate={{ opacity: 1 }}
|
|
610
|
+
exit={{ opacity: 0 }}
|
|
611
|
+
className="absolute inset-0 bg-background/60 backdrop-blur-sm cursor-pointer"
|
|
612
|
+
/>
|
|
613
|
+
<motion.div
|
|
614
|
+
initial={{ opacity: 0, scale: 0.97, y: 6 }}
|
|
615
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
616
|
+
exit={{ opacity: 0, scale: 0.97, y: 6 }}
|
|
617
|
+
transition={{ duration: 0.2 }}
|
|
618
|
+
className="w-full max-w-[360px] border-[var(--panel-border)] border rounded-2xl relative z-10 glass-panel shadow-2xl bg-background overflow-hidden"
|
|
619
|
+
>
|
|
620
|
+
<form onSubmit={handleSaveRecord} className="p-5 flex flex-col gap-3">
|
|
621
|
+
<div className="flex items-center justify-between mb-1">
|
|
622
|
+
<span className="text-[14px] font-bold text-foreground">New Record</span>
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
onClick={() => !saving && setShowAddForm(false)}
|
|
626
|
+
className="text-foreground/30 hover:text-foreground/60 transition-colors cursor-pointer p-0.5"
|
|
627
|
+
>
|
|
628
|
+
<X className="w-4 h-4" />
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
<input
|
|
632
|
+
autoFocus
|
|
633
|
+
type="text"
|
|
634
|
+
required
|
|
635
|
+
value={newName}
|
|
636
|
+
onChange={e => setNewName(e.target.value)}
|
|
637
|
+
placeholder="Name *"
|
|
638
|
+
disabled={saving}
|
|
639
|
+
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"
|
|
640
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }}
|
|
641
|
+
/>
|
|
642
|
+
<textarea
|
|
643
|
+
value={newNote}
|
|
644
|
+
onChange={e => setNewNote(e.target.value)}
|
|
645
|
+
placeholder="Note (optional)"
|
|
646
|
+
disabled={saving}
|
|
647
|
+
rows={2}
|
|
648
|
+
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"
|
|
649
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }}
|
|
650
|
+
/>
|
|
651
|
+
<div className="flex justify-end pt-1">
|
|
652
|
+
<Button
|
|
653
|
+
type="submit"
|
|
654
|
+
isLoading={saving}
|
|
655
|
+
className="px-5 py-2 text-[12px] font-bold shadow-md"
|
|
656
|
+
>
|
|
657
|
+
{saving ? 'Saving…' : 'Save'}
|
|
658
|
+
</Button>
|
|
659
|
+
</div>
|
|
660
|
+
</form>
|
|
661
|
+
</motion.div>
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
</AnimatePresence>
|
|
665
|
+
|
|
666
|
+
{/* ── Delete Confirmation ────────────────────────────────────────────── */}
|
|
667
|
+
<ConfirmModal
|
|
668
|
+
isOpen={!!deleteConfirm}
|
|
669
|
+
onCancel={() => setDeleteConfirm(null)}
|
|
670
|
+
onConfirm={handleDelete}
|
|
671
|
+
title="Delete Record"
|
|
672
|
+
message="Are you sure you want to delete this record? This action cannot be undone."
|
|
673
|
+
confirmText="Delete"
|
|
674
|
+
isProcessing={isDeleting}
|
|
675
|
+
/>
|
|
676
|
+
</main>
|
|
677
|
+
);
|
|
678
|
+
}
|