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