firebase-os 1.1.3 → 1.1.5

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 +2 -17
  3. package/dist/firebase-os.es.js +63 -72
  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 +89 -10
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +80 -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 +227 -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 +401 -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 +372 -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,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
+ }