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,1022 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { DashboardNav } from '../components/DashboardNav';
4
+ import { useAuth } from '../lib/AuthContext';
5
+ import { Shield, Eye, EyeOff, Lock, Globe, Plus, Trash2, Power, ChevronUp, ChevronDown, Layers, Loader2, Save, Copy, RotateCcw, CheckCircle2 } from 'lucide-react';
6
+ import { Button } from '../components/Button';
7
+ import { db } from '../lib/firebase';
8
+ import { doc, onSnapshot, setDoc, collection, getDocs, getDoc } from 'firebase/firestore';
9
+ import { ConfirmModal } from '../components/ConfirmModal';
10
+ import { JsonNode } from './ThemeAdmin';
11
+ import { defaultHomeConfig } from '../configs/pages/home.config';
12
+ import { defaultContactConfig } from '../configs/pages/contact.config';
13
+ import { defaultSupportConfig } from '../configs/pages/support.config';
14
+ import { defaultPublicConfig } from '../configs/pages/pub.config';
15
+ import { userPageConfig as defaultPrivateConfig } from '../configs/pages/user.config';
16
+ import { adminPageConfig as defaultAdminConfig } from '../configs/pages/admin.config';
17
+ import { sharedPageConfig as defaultSharedConfig } from '../configs/pages/shared.config';
18
+
19
+ type PageType = 'shared' | 'private' | 'admin' | 'public';
20
+
21
+ interface PageItem {
22
+ id: string;
23
+ _uiKey?: string;
24
+ title: string;
25
+ path: string;
26
+ isSystem: boolean;
27
+ enabled: boolean;
28
+ type: PageType;
29
+ }
30
+
31
+ const initialPages: PageItem[] = [
32
+ // Public
33
+ { id: 'home', title: 'Home Page', path: '/', type: 'public', isSystem: true, enabled: true },
34
+ { id: 'contact', title: 'Contact', path: '/contact', type: 'public', isSystem: true, enabled: true },
35
+
36
+ // Private
37
+ { id: 'support', title: 'Request Support', path: '/support', type: 'private', isSystem: true, enabled: true },
38
+ ];
39
+
40
+ const Toggle = ({ enabled, onChange, disabled, className = '' }: { enabled: boolean, onChange: (val: boolean) => void, disabled?: boolean, className?: string }) => (
41
+ <button
42
+ disabled={disabled}
43
+ onClick={() => onChange(!enabled)}
44
+ title={enabled ? "Page Active" : "Page Hidden"}
45
+ className={`p-1.5 transition-colors rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity ${className} ${enabled ? 'text-accent hover:bg-accent/10' : 'text-foreground/40 hover:bg-foreground/5'} ${disabled ? '!opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
46
+ >
47
+ {enabled ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
48
+ </button>
49
+ );
50
+
51
+ import { useLocation } from 'react-router-dom';
52
+
53
+ export function PagesAdmin() {
54
+ const { userRole } = useAuth();
55
+ const location = useLocation();
56
+ const [pages, setPages] = useState<PageItem[]>(initialPages);
57
+ const [expandedPageId, setExpandedPageId] = useState<string | null>(null);
58
+ const [isAddingPage, setIsAddingPage] = useState(false);
59
+ const [draftConfigs, setDraftConfigs] = useState<Record<string, any>>({});
60
+ const [savingConfigMap, setSavingConfigMap] = useState<Record<string, boolean>>({});
61
+ const [resettingMap, setResettingMap] = useState<Record<string, boolean>>({});
62
+ const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
63
+ const [isDeleting, setIsDeleting] = useState(false);
64
+ const addMenuRef = useRef<HTMLDivElement>(null);
65
+
66
+ useEffect(() => {
67
+ function handleClickOutside(event: MouseEvent) {
68
+ if (addMenuRef.current && !addMenuRef.current.contains(event.target as Node)) {
69
+ setIsAddingPage(false);
70
+ }
71
+ }
72
+ document.addEventListener("mousedown", handleClickOutside);
73
+ return () => document.removeEventListener("mousedown", handleClickOutside);
74
+ }, []);
75
+
76
+ const [homeConfig, setHomeConfig] = useState<any>(null);
77
+ const [savingHome, setSavingHome] = useState(false);
78
+ const [resettingHome, setResettingHome] = useState(false);
79
+
80
+ const [contactConfig, setContactConfig] = useState<any>(null);
81
+ const [savingContact, setSavingContact] = useState(false);
82
+ const [resettingContact, setResettingContact] = useState(false);
83
+
84
+ const [supportConfig, setSupportConfig] = useState<any>(null);
85
+ const [savingSupport, setSavingSupport] = useState(false);
86
+ const [resettingSupport, setResettingSupport] = useState(false);
87
+ // ── Order-based visibility (single source of truth) ─────────────────────
88
+ const [pageHiddenIds, setPageHiddenIds] = useState<Set<string>>(new Set());
89
+ const [tabHiddenIds, setTabHiddenIds] = useState<Set<string>>(new Set());
90
+ const [pageOrderItems, setPageOrderItems] = useState<{ id: string; hidden: boolean }[]>([]);
91
+ const [tabOrderItems, setTabOrderItems] = useState<{ id: string; hidden: boolean }[]>([]);
92
+
93
+ const parseOrder = (raw: any[]): { id: string; hidden: boolean }[] =>
94
+ (raw || []).map((i: any) => typeof i === 'string' ? { id: i, hidden: false } : { id: String(i.id), hidden: Boolean(i.hidden) });
95
+
96
+ useEffect(() => {
97
+ const unsubPageOrd = onSnapshot(doc(db, 'sys_configs', 'page_order'), snap => {
98
+ const items = parseOrder(snap.exists() ? snap.data().order || [] : []);
99
+ setPageOrderItems(items);
100
+ setPageHiddenIds(new Set(items.filter(i => i.hidden).map(i => i.id)));
101
+ });
102
+ const unsubTabOrd = onSnapshot(doc(db, 'sys_configs', 'tab_order'), snap => {
103
+ const items = parseOrder(snap.exists() ? snap.data().order || [] : []);
104
+ setTabOrderItems(items);
105
+ setTabHiddenIds(new Set(items.filter(i => i.hidden).map(i => i.id)));
106
+ });
107
+ return () => { unsubPageOrd(); unsubTabOrd(); };
108
+ }, []);
109
+
110
+ useEffect(() => {
111
+ const pathId = location.pathname.split('/pages/')[1]?.split('/')[0];
112
+ if (pathId && !expandedPageId && pages.some(p => p.id === pathId)) {
113
+ setExpandedPageId(pathId);
114
+ }
115
+ }, [location.pathname, pages]);
116
+
117
+
118
+
119
+ useEffect(() => {
120
+ const mergeConfig = (def: any, dbVal: any) => {
121
+ if (!dbVal) return def;
122
+ const cleanVal = Object.fromEntries(Object.entries(dbVal).filter(([_, v]) => v !== undefined && v !== null && v !== ''));
123
+ return { ...def, ...cleanVal };
124
+ };
125
+
126
+ const unsubHome = onSnapshot(doc(db, 'sys_pages', 'home'), (docSnap) => {
127
+ setHomeConfig(docSnap.exists() ? mergeConfig(defaultHomeConfig, docSnap.data()) : defaultHomeConfig);
128
+ });
129
+ const unsubContact = onSnapshot(doc(db, 'sys_pages', 'contact'), (docSnap) => {
130
+ setContactConfig(docSnap.exists() ? mergeConfig(defaultContactConfig, docSnap.data()) : defaultContactConfig);
131
+ });
132
+ const unsubSupport = onSnapshot(doc(db, 'sys_tabs', 'support'), (docSnap) => {
133
+ setSupportConfig(docSnap.exists() ? mergeConfig(defaultSupportConfig, docSnap.data()) : defaultSupportConfig);
134
+ });
135
+ return () => { unsubHome(); unsubContact(); unsubSupport(); };
136
+ }, []);
137
+
138
+ useEffect(() => {
139
+ const fetchCustomConfigs = async () => {
140
+ try {
141
+ const [pubConfigs, pubTabs] = await Promise.all([
142
+ getDocs(collection(db, 'sys_pages')),
143
+ getDocs(collection(db, 'sys_tabs'))
144
+ ]);
145
+
146
+ const systemIds = ['home', 'contact', 'support', 'theme', 'page_order', 'tab_order'];
147
+ const loadedPages: PageItem[] = [];
148
+ const loadedConfigs: Record<string, any> = {};
149
+
150
+ pubConfigs.forEach(d => {
151
+ if (!systemIds.includes(d.id)) {
152
+ const data = d.data();
153
+ loadedPages.push({ id: d.id, title: data.pageName || data.title || d.id, path: data.route || '/' + d.id, type: 'public', isSystem: false, enabled: data.enabled !== false });
154
+ loadedConfigs[d.id] = data;
155
+ }
156
+ });
157
+
158
+ pubTabs.forEach(d => {
159
+ if (!systemIds.includes(d.id)) {
160
+ const data = d.data();
161
+ const pType = data.pageType || 'private';
162
+ loadedPages.push({ id: d.id, title: data.tabName || data.title || d.id, path: data.route || '/' + d.id, type: pType, isSystem: false, enabled: data.enabled !== false });
163
+ loadedConfigs[d.id] = data;
164
+ }
165
+ });
166
+
167
+ if (loadedPages.length > 0) {
168
+ setPages(prev => {
169
+ const base = prev.filter(p => p.isSystem || p.id.startsWith('draft_'));
170
+ const newPages = loadedPages.filter(lp => !base.some(p => p.id === lp.id));
171
+ return [...base, ...newPages];
172
+ });
173
+ setDraftConfigs(prev => ({ ...prev, ...loadedConfigs }));
174
+ }
175
+ } catch (e) {
176
+ console.error("Error loading custom pages", e);
177
+ }
178
+ };
179
+ fetchCustomConfigs();
180
+ }, []);
181
+
182
+
183
+
184
+ const [publicForms, setPublicForms] = useState<string[]>(['contact']);
185
+ const [privateForms, setPrivateForms] = useState<string[]>(['support']);
186
+
187
+ useEffect(() => {
188
+ getDocs(collection(db, 'sys_forms')).then(snap => {
189
+ const pub: string[] = [];
190
+ const priv: string[] = [];
191
+ snap.forEach(d => {
192
+ if (d.data().formType === 'private') priv.push(d.id);
193
+ else pub.push(d.id);
194
+ });
195
+ if (pub.length > 0) setPublicForms(pub);
196
+ if (priv.length > 0) setPrivateForms(priv);
197
+ }).catch(console.error);
198
+ }, []);
199
+ const [copyStateId, setCopyStateId] = useState<string | null>(null);
200
+
201
+ const handleCopyBtn = (id: string, val: any) => {
202
+ let str = JSON.stringify(val, null, 2);
203
+ navigator.clipboard.writeText(str);
204
+ setCopyStateId(id);
205
+ setTimeout(() => setCopyStateId(null), 2000);
206
+ };
207
+
208
+ const handleSaveConfig = async (id: string, val: any) => {
209
+ if (val.route && !val.route.startsWith('/')) {
210
+ val.route = '/' + val.route;
211
+ }
212
+
213
+ const targetPage = pages.find(p => p.id === id);
214
+ const pageType = targetPage?.type;
215
+ const collectionName = pageType === 'public' ? 'sys_pages' : 'sys_tabs';
216
+
217
+ if (pageType && pageType !== 'public') {
218
+ val.pageType = pageType;
219
+ }
220
+
221
+ const isSystemPage = ['home', 'contact', 'support'].includes(id);
222
+ const targetId = isSystemPage
223
+ ? id
224
+ : (val.route ? val.route.replace(/^\/+/, '').replace(/[^a-zA-Z0-9_\-]/g, '_').toLowerCase() : id);
225
+
226
+ if (id === 'home') setSavingHome(true);
227
+ else if (id === 'contact') setSavingContact(true);
228
+ else if (id === 'support') setSavingSupport(true);
229
+ else setSavingConfigMap(p => ({ ...p, [id]: true, [targetId]: true }));
230
+
231
+ try {
232
+ const isIdMutated = targetId !== id && targetId.trim() !== '' && targetId !== 'draft';
233
+
234
+ if (isIdMutated) {
235
+ try {
236
+ const { deleteDoc } = await import('firebase/firestore');
237
+ await deleteDoc(doc(db, collectionName, id));
238
+ } catch (e) { }
239
+ await setDoc(doc(db, collectionName, targetId), val);
240
+
241
+ setPages(prev => prev.map(p => p.id === id ? { ...p, id: targetId, _uiKey: p._uiKey || id, path: val.route, title: val.pageName || val.tabName || val.title || p.title } : p));
242
+ setDraftConfigs(prev => {
243
+ const next = { ...prev };
244
+ next[targetId] = val;
245
+ delete next[id];
246
+ return next;
247
+ });
248
+
249
+ if (expandedPageId === id) {
250
+ setExpandedPageId(targetId);
251
+ window.history.replaceState(null, '', `/pages/${targetId}`);
252
+ }
253
+
254
+ // Append new tab to the BOTTOM of the order list
255
+ const isPublicPage = pageType === 'public';
256
+ const orderDocId = isPublicPage ? 'page_order' : 'tab_order';
257
+ const currentOrder = isPublicPage ? pageOrderItems : tabOrderItems;
258
+ const alreadyInOrder = currentOrder.some(o => o.id === targetId);
259
+ if (!alreadyInOrder) {
260
+ const newOrder = [...currentOrder.filter(o => o.id !== id), { id: targetId, hidden: false }];
261
+ try {
262
+ await setDoc(doc(db, 'sys_configs', orderDocId), { order: newOrder });
263
+ } catch (e) {
264
+ console.error('Failed to update tab order:', e);
265
+ }
266
+ }
267
+ } else {
268
+ await setDoc(doc(db, collectionName, id), val);
269
+ if (id === 'home') setHomeConfig(val);
270
+ else if (id === 'contact') setContactConfig(val);
271
+ else if (id === 'support') setSupportConfig(val);
272
+ else {
273
+ setDraftConfigs(p => ({ ...p, [id]: val }));
274
+ setPages(prev => prev.map(p => p.id === id ? { ...p, path: val.route || p.path, title: val.pageName || val.tabName || val.title || p.title } : p));
275
+ }
276
+ }
277
+ } catch (e) {
278
+ console.error(`Failed to save ${id} config:`, e);
279
+ } finally {
280
+ setTimeout(() => {
281
+ if (id === 'home') setSavingHome(false);
282
+ else if (id === 'contact') setSavingContact(false);
283
+ else if (id === 'support') setSavingSupport(false);
284
+ else setSavingConfigMap(p => ({ ...p, [id]: false, [targetId]: false }));
285
+ }, 500);
286
+ }
287
+ };
288
+
289
+ const handleResetHome = async (e: React.MouseEvent) => {
290
+ e.stopPropagation(); setResettingHome(true);
291
+ await handleSaveConfig('home', defaultHomeConfig);
292
+ setTimeout(() => setResettingHome(false), 500);
293
+ };
294
+
295
+ const handleResetContact = async (e: React.MouseEvent) => {
296
+ e.stopPropagation(); setResettingContact(true);
297
+ await handleSaveConfig('contact', defaultContactConfig);
298
+ setTimeout(() => setResettingContact(false), 500);
299
+ };
300
+
301
+ const handleResetSupport = async (e: React.MouseEvent) => {
302
+ e.stopPropagation(); setResettingSupport(true);
303
+ await handleSaveConfig('support', defaultSupportConfig);
304
+ setTimeout(() => setResettingSupport(false), 500);
305
+ };
306
+
307
+ const handleResetPage = async (e: React.MouseEvent, pageId: string, defConfig: any) => {
308
+ e.stopPropagation(); setResettingMap(p => ({ ...p, [pageId]: true }));
309
+ await handleSaveConfig(pageId, defConfig);
310
+ setTimeout(() => setResettingMap(p => ({ ...p, [pageId]: false })), 500);
311
+ };
312
+
313
+ const startDraft = (type: PageType) => {
314
+ const newId = 'draft_' + Math.floor(Math.random() * 10000);
315
+ setPages([...pages, { id: newId, title: 'New Tab', path: '/draft', type, isSystem: false, enabled: true }]);
316
+
317
+ let defaultPayload = {};
318
+ if (type === 'public') {
319
+ defaultPayload = { ...defaultPublicConfig };
320
+ } else if (type === 'private') {
321
+ defaultPayload = { ...defaultPrivateConfig };
322
+ } else if (type === 'admin') {
323
+ defaultPayload = { ...defaultAdminConfig };
324
+ } else if (type === 'shared') {
325
+ defaultPayload = { ...defaultSharedConfig };
326
+ }
327
+ setDraftConfigs(p => ({ ...p, [newId]: defaultPayload }));
328
+ setExpandedPageId(newId);
329
+ setIsAddingPage(false);
330
+ setTimeout(() => {
331
+ document.getElementById(`page-${newId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
332
+ }, 100);
333
+ };
334
+
335
+ if (userRole !== 'admin') {
336
+ return (
337
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12 min-h-screen">
338
+ <div className="flex flex-col items-center justify-center flex-1">
339
+ <Shield className="w-12 h-12 text-red-500 mb-4" />
340
+ <h2 className="text-2xl font-bold">Access Denied</h2>
341
+ <p className="text-foreground/60">You do not have permission to view pages configuration.</p>
342
+ </div>
343
+ </main>
344
+ );
345
+ }
346
+
347
+ const togglePage = async (id: string, isSystem: boolean, pageType: PageType) => {
348
+ const isPublic = pageType === 'public';
349
+ const docId = isPublic ? 'page_order' : 'tab_order';
350
+ const currentOrderItems = isPublic ? pageOrderItems : tabOrderItems;
351
+ const currentHidden = isPublic ? pageHiddenIds : tabHiddenIds;
352
+ const nowHidden = currentHidden.has(id);
353
+
354
+ // Optimistic local update
355
+ setPages(pages.map(p => p.id === id ? { ...p, enabled: nowHidden } : p));
356
+
357
+ // Ensure item is represented in the order array
358
+ let newOrder = [...currentOrderItems];
359
+ const existingIdx = newOrder.findIndex(o => o.id === id);
360
+ if (existingIdx !== -1) {
361
+ newOrder[existingIdx] = { ...newOrder[existingIdx], hidden: !nowHidden };
362
+ } else {
363
+ newOrder.push({ id, hidden: true }); // wasn't in order → adding as hidden
364
+ }
365
+
366
+ await setDoc(doc(db, 'sys_configs', docId), { order: newOrder });
367
+ };
368
+
369
+ const deletePage = (id: string, isSystem: boolean) => {
370
+ if (isSystem) return;
371
+ setDeleteConfirmId(id);
372
+ };
373
+
374
+ const confirmDeletePage = async () => {
375
+ if (!deleteConfirmId) return;
376
+ setIsDeleting(true);
377
+ try {
378
+ const { deleteDoc, collection, getDocs, doc } = await import('firebase/firestore');
379
+ const page = pages.find(p => p.id === deleteConfirmId);
380
+ const collectionName = page?.type === 'public' ? 'sys_pages' : 'sys_tabs';
381
+ await deleteDoc(doc(db, collectionName, deleteConfirmId));
382
+
383
+ if (collectionName === 'sys_tabs' && page) {
384
+ const tabName = page.id;
385
+ try {
386
+ const collectionsClean = [
387
+ `admin_${tabName}_records`,
388
+ `mem_${tabName}_records`,
389
+ `user_${tabName}_records`
390
+ ];
391
+
392
+ for (const col of collectionsClean) {
393
+ const recordsSnap = await getDocs(collection(db, col));
394
+ const deletePromises = recordsSnap.docs.map(d => deleteDoc(d.ref));
395
+ await Promise.all(deletePromises);
396
+ }
397
+ } catch (cleanupErr) {
398
+ console.error("Error cleaning up tab records:", cleanupErr);
399
+ }
400
+ }
401
+
402
+ const orderDocId = collectionName === 'sys_pages' ? 'page_order' : 'tab_order';
403
+ const currentOrderItems = collectionName === 'sys_pages' ? pageOrderItems : tabOrderItems;
404
+ const { setDoc } = await import('firebase/firestore');
405
+ const newOrder = currentOrderItems.filter(o => o.id !== deleteConfirmId);
406
+ await setDoc(doc(db, 'sys_configs', orderDocId), { order: newOrder });
407
+
408
+ setPages(prev => prev.filter(p => p.id !== deleteConfirmId));
409
+ if (expandedPageId === deleteConfirmId) setExpandedPageId(null);
410
+ } catch (e) {
411
+ console.error("Error deleting page:", e);
412
+ }
413
+ setIsDeleting(false);
414
+ setDeleteConfirmId(null);
415
+ };
416
+
417
+ const duplicatePage = (originalPage: PageItem) => {
418
+ const newId = originalPage.id + '_' + Math.floor(Math.random() * 1000);
419
+ const newPage: PageItem = {
420
+ ...originalPage,
421
+ id: newId,
422
+ title: `${originalPage.title} (Copy)`,
423
+ path: `${originalPage.path}_copy`,
424
+ isSystem: false,
425
+ enabled: true
426
+ };
427
+
428
+ setPages(prev => [...prev, newPage]);
429
+
430
+ let sourceConfig = draftConfigs[originalPage.id];
431
+ if (!sourceConfig) {
432
+ if (originalPage.id === 'contact') sourceConfig = contactConfig || defaultContactConfig;
433
+ if (originalPage.id === 'support') sourceConfig = supportConfig || defaultSupportConfig;
434
+ }
435
+
436
+ const newConfig = { ...sourceConfig, route: newPage.path, pageName: newPage.title };
437
+ if (newPage.type !== 'public') {
438
+ newConfig.tabName = newPage.title;
439
+ newConfig.pageType = newPage.type;
440
+ }
441
+
442
+ setDraftConfigs(prev => ({ ...prev, [newId]: newConfig }));
443
+
444
+ setExpandedPageId(newId);
445
+ setTimeout(() => {
446
+ document.getElementById(`page-${newId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
447
+ }, 100);
448
+ };
449
+
450
+
451
+ const sections: { id: string; title: string; icon: any; description: string; color: string; filter: (p: PageItem) => boolean }[] = [
452
+ {
453
+ id: 'public',
454
+ title: 'Public Pages', icon: Globe, color: 'bg-amber-500',
455
+ description: "Creates external menu items. Invisible to signed-in dashboard users.",
456
+ filter: p => p.type === 'public'
457
+ },
458
+ {
459
+ id: 'private',
460
+ title: 'Private Pages', icon: Lock, color: 'bg-emerald-500',
461
+ description: "Internal dashboard tabs for regular users.",
462
+ filter: p => p.type === 'private'
463
+ },
464
+ {
465
+ id: 'shared',
466
+ title: 'Shared Pages', icon: Eye, color: 'bg-blue-500',
467
+ description: "Dashboard tabs visible to all members, editors, and admins.",
468
+ filter: p => p.type === 'shared'
469
+ },
470
+ {
471
+ id: 'admin',
472
+ title: 'Admin Pages', icon: Shield, color: 'bg-red-500',
473
+ description: "Internal dashboard tabs for admin users only.",
474
+ filter: p => p.type === 'admin'
475
+ },
476
+ ];
477
+
478
+ const [showResetAllConfirm, setShowResetAllConfirm] = useState(false);
479
+ const [isResettingAll, setIsResettingAll] = useState(false);
480
+
481
+ const handleResetAllDefaults = async () => {
482
+ setIsResettingAll(true);
483
+ try {
484
+ const { deleteDoc } = await import('firebase/firestore');
485
+ await Promise.all([
486
+ deleteDoc(doc(db, 'sys_pages', 'home')),
487
+ deleteDoc(doc(db, 'sys_pages', 'contact')),
488
+ deleteDoc(doc(db, 'sys_tabs', 'support'))
489
+ ]);
490
+ setHomeConfig(defaultHomeConfig);
491
+ setContactConfig(defaultContactConfig);
492
+ setSupportConfig(defaultSupportConfig);
493
+ } catch (e) {
494
+ console.error("Error resetting defaults:", e);
495
+ }
496
+ setTimeout(() => {
497
+ setIsResettingAll(false);
498
+ setShowResetAllConfirm(false);
499
+ }, 1000);
500
+ };
501
+
502
+ return (
503
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
504
+ {/* Header */}
505
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-6 flex items-center justify-between">
506
+ <div>
507
+ <h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
508
+ Pages
509
+ </h1>
510
+ <div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
511
+ <span className="w-8 h-[1px] bg-foreground/10" />
512
+ /pages{expandedPageId ? `/${expandedPageId}` : ''}
513
+ </div>
514
+ </div>
515
+ </motion.div>
516
+
517
+ <div className="flex flex-col gap-6 flex-1 pb-16">
518
+ <DashboardNav />
519
+
520
+ <div className="flex flex-col glass-panel border border-[var(--panel-border)] rounded-3xl overflow-hidden flex-1 shadow-2xl bg-background">
521
+ <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-6 pb-6 border-b border-[var(--panel-border)]/50 relative z-10 shrink-0 px-6 pt-6 md:px-10 md:pt-10">
522
+ <div>
523
+ <h2 className="text-xl font-extrabold text-foreground tracking-tight">Architecture</h2>
524
+ <p className="text-[14px] font-medium text-foreground/60 mt-2 leading-relaxed">
525
+ Manage the structure of your application tabs and public pages.
526
+ </p>
527
+ </div>
528
+
529
+ <div className="relative z-50 shrink-0 w-full md:w-auto flex justify-end gap-1">
530
+ <Button variant="secondary" onClick={() => setShowResetAllConfirm(true)} className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-foreground/5 rounded-xl flex items-center justify-center shrink-0 transition-colors group cursor-pointer" title="Reset Defaults">
531
+ <RotateCcw className="w-4 h-4 text-foreground/60 group-hover:text-foreground transition-colors" />
532
+ </Button>
533
+ <div className="relative">
534
+ <Button variant="secondary" onClick={() => setIsAddingPage(!isAddingPage)} className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-foreground/5 rounded-xl flex items-center justify-center shrink-0 transition-colors group cursor-pointer" title="Add New Page">
535
+ <Plus className="w-4 h-4 text-foreground/60 group-hover:text-foreground transition-colors" />
536
+ </Button>
537
+ <AnimatePresence>
538
+ {isAddingPage && (
539
+ <motion.div
540
+ ref={addMenuRef}
541
+ initial={{ opacity: 0, y: 10, scale: 0.95 }}
542
+ animate={{ opacity: 1, y: 0, scale: 1 }}
543
+ exit={{ opacity: 0, y: 10, scale: 0.95 }}
544
+ className="absolute top-full mt-2 right-0 w-48 glass-panel border border-[var(--panel-border)] rounded-2xl p-2 shadow-xl z-50 flex flex-col gap-1"
545
+ >
546
+ {['public', 'private', 'shared', 'admin'].map((t) => (
547
+ <button
548
+ key={t}
549
+ onClick={() => { startDraft(t as PageType); setIsAddingPage(false); }}
550
+ className="text-left px-3 py-2 text-[13px] font-bold uppercase tracking-wider text-foreground/70 hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors cursor-pointer"
551
+ >
552
+ {t} Page
553
+ </button>
554
+ ))}
555
+ </motion.div>
556
+ )}
557
+ </AnimatePresence>
558
+ </div>
559
+ </div>
560
+ </div>
561
+ <div className="px-6 md:px-10 pb-6 md:pb-10 flex flex-col pt-0 gap-10 overflow-y-auto mt-2 relative min-h-[400px]">
562
+ <AnimatePresence>
563
+ {isResettingAll && (
564
+ <motion.div
565
+ initial={{ opacity: 0 }}
566
+ animate={{ opacity: 1 }}
567
+ exit={{ opacity: 0 }}
568
+ className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-[2px] rounded-3xl"
569
+ >
570
+ <div className="flex flex-col items-center justify-center p-6 glass-panel rounded-2xl shadow-xl">
571
+ <Loader2 className="w-8 h-8 md:w-10 md:h-10 animate-spin text-accent mb-4" />
572
+ <span className="text-sm font-bold text-foreground">Synchronizing Architecture...</span>
573
+ </div>
574
+ </motion.div>
575
+ )}
576
+ </AnimatePresence>
577
+ {sections.map((section, idx) => {
578
+ const unsortedSectionPages = pages.filter(section.filter);
579
+ // Sort pages within each section by tab_order/page_order
580
+ const orderItems = section.id === 'public' ? pageOrderItems : tabOrderItems;
581
+ const sectionPages = unsortedSectionPages.sort((a, b) => {
582
+ const aIdx = orderItems.findIndex(o => o.id === a.id);
583
+ const bIdx = orderItems.findIndex(o => o.id === b.id);
584
+ // Items in the order list come first, in order; items not in the list go to the end
585
+ if (aIdx === -1 && bIdx === -1) return 0;
586
+ if (aIdx === -1) return 1;
587
+ if (bIdx === -1) return -1;
588
+ return aIdx - bIdx;
589
+ });
590
+ const Icon = section.icon;
591
+
592
+ const isPageHidden = (p: PageItem) =>
593
+ p.type === 'public' ? pageHiddenIds.has(p.id) : tabHiddenIds.has(p.id);
594
+
595
+ const activePages = sectionPages.filter(p => !isPageHidden(p));
596
+ const hiddenPages = sectionPages.filter(p => isPageHidden(p));
597
+
598
+ const getPublicKeys = (val: any) => {
599
+ if (!val) return [];
600
+ const keys = ['route', 'pageName', 'template'];
601
+ if (val.template === 'none') {
602
+ keys.push('pageTitle', 'copyPrompt');
603
+ keys.push('showButton');
604
+ if (val.showButton !== false) {
605
+ keys.push('buttonText', 'action');
606
+ if (val.action === 'form') keys.push('form');
607
+ else if (val.action === 'redirect') keys.push('redirectUrl');
608
+ }
609
+ } else if (val.template === 'popup_form' || val.template === 'inline_form') {
610
+ keys.push('showButton');
611
+ if (val.showButton !== false) {
612
+ keys.push('form');
613
+ }
614
+ }
615
+ return keys;
616
+ };
617
+
618
+ const getPrivateKeys = (val: any) => {
619
+ if (!val) return [];
620
+ const keys = ['tabName', 'iconName', 'route', 'template'];
621
+ if (val.template === 'popup_form' || val.template === 'inline_form') {
622
+ keys.push('showButton');
623
+ if (val.showButton !== false) {
624
+ keys.push('form');
625
+ }
626
+ } else if (val.template === 'none') {
627
+ keys.push('showPrompt', 'tabTitle');
628
+ keys.push('showButton');
629
+ if (val.showButton !== false) {
630
+ keys.push('buttonStyle', 'buttonText', 'buttonAction');
631
+ }
632
+ keys.push('storage');
633
+ if (val.storage !== false) {
634
+ keys.push('buttonStorageIcon', 'buttonStorageText', 'buttonStorageStyle', 'buttonStorageAction');
635
+ }
636
+ } else if (val.template === 'calendar') {
637
+ // Calendar-specific settings
638
+ keys.push('tabTitle', 'showPrompt');
639
+ keys.push('defaultView', 'defaultTimeFormat');
640
+ keys.push('showButton');
641
+ if (val.showButton !== false) {
642
+ keys.push('buttonText', 'buttonStyle');
643
+ }
644
+ keys.push('showViewsButton');
645
+ if (val.showViewsButton !== false) {
646
+ keys.push('viewsButtonStyle');
647
+ }
648
+ } else if (val.template === 'table') {
649
+ // Table-specific settings
650
+ keys.push('tabTitle', 'showPrompt');
651
+ keys.push('showButton');
652
+ if (val.showButton !== false) {
653
+ keys.push('buttonText', 'buttonStyle');
654
+ }
655
+ keys.push('showExportButton');
656
+ if (val.showExportButton !== false) {
657
+ keys.push('exportButtonIcon');
658
+ }
659
+ } else if (val.template === 'board') {
660
+ // Board-specific settings
661
+ keys.push('tabTitle', 'showPrompt');
662
+ keys.push('showButton');
663
+ if (val.showButton !== false) {
664
+ keys.push('buttonText', 'buttonStyle');
665
+ }
666
+ keys.push('keyName', 'valueName');
667
+ }
668
+ return keys;
669
+ };
670
+
671
+ // Inject template-specific defaults when switching templates
672
+ const injectTemplateDefaults = (currentVal: any, newVal: any): any => {
673
+ if (newVal.template === currentVal.template) return newVal;
674
+ if (newVal.template === 'calendar') {
675
+ return {
676
+ ...newVal,
677
+ defaultView: newVal.defaultView || 'calendar',
678
+ defaultTimeFormat: newVal.defaultTimeFormat || '12h',
679
+ showViewsButton: newVal.showViewsButton !== undefined ? newVal.showViewsButton : true,
680
+ viewsButtonStyle: newVal.viewsButtonStyle || 'secondary',
681
+ };
682
+ }
683
+ if (newVal.template === 'table') {
684
+ return {
685
+ ...newVal,
686
+ showExportButton: newVal.showExportButton !== undefined ? newVal.showExportButton : true,
687
+ exportButtonIcon: newVal.exportButtonIcon || 'download-square',
688
+ };
689
+ }
690
+ if (newVal.template === 'board') {
691
+ return {
692
+ ...newVal,
693
+ keyName: newVal.keyName || 'key',
694
+ valueName: newVal.valueName || 'value',
695
+ };
696
+ }
697
+ return newVal;
698
+ };
699
+
700
+ const renderPage = (page: PageItem, pIdx: number, isHidden: boolean) => {
701
+ const isHome = page.id === 'home';
702
+ const isContact = page.id === 'contact';
703
+ const isSupport = page.id === 'support';
704
+ const isExpanded = expandedPageId === page.id;
705
+ const isConfigurable = isHome || isContact || isSupport || !page.isSystem;
706
+
707
+ return (
708
+ <div
709
+ id={`page-${page.id}`}
710
+ key={`${section.id}-${page._uiKey || page.id}`}
711
+ onClick={(e) => {
712
+ if (isConfigurable) {
713
+ const newId = isExpanded ? null : page.id;
714
+ setExpandedPageId(newId);
715
+ window.history.replaceState(null, '', newId ? `/pages/${newId}` : `/pages`);
716
+ }
717
+ }}
718
+ className={`group flex flex-col py-4 px-5 glass-panel border transition-all rounded-2xl hover:shadow-sm ${isConfigurable ? 'cursor-pointer hover:border-accent/40' : ''} ${isHidden ? 'opacity-40 grayscale bg-foreground/[0.01] border-[var(--panel-border)]/50' : 'border-transparent hover:border-[var(--panel-border)]'}`}
719
+ >
720
+ <div className="flex flex-col sm:flex-row items-center justify-between w-full gap-4">
721
+ <div className="flex items-center gap-1 w-full flex-1">
722
+ <div className="flex items-center gap-2">
723
+ <h3 className="text-[14px] font-bold text-foreground truncate">{page.title}</h3>
724
+ {((isHome && savingHome) || (isContact && savingContact) || (isSupport && savingSupport)) && <Loader2 className="w-3 h-3 animate-spin text-accent ml-2" />}
725
+ </div>
726
+ <div className={`text-[11px] font-medium font-mono px-2 py-0.5 rounded w-fit ml-6 border ${isHidden ? 'text-foreground/50 border-foreground/10 bg-foreground/5' : 'text-accent/70 bg-accent/5 border-accent/10'}`}>
727
+ {window.location.host}{page.path}
728
+ </div>
729
+ </div>
730
+
731
+ <div className="flex items-center gap-4 shrink-0">
732
+ {(!page.isSystem || ['contact', 'support', 'templates'].includes(page.id)) ? (
733
+ <button onClick={(e) => { e.stopPropagation(); duplicatePage(page); }} className="p-1.5 hover:bg-accent/10 text-foreground/40 hover:text-accent transition-colors rounded-lg flex items-center justify-center shrink-0" title="Duplicate Page">
734
+ <Copy className="w-4 h-4" />
735
+ </button>
736
+ ) : (
737
+ <div className="w-[28px] shrink-0" />
738
+ )}
739
+ <div className="flex items-center justify-end w-8 shrink-0">
740
+ {page.id !== 'home' ? (
741
+ <Toggle enabled={!isPageHidden(page)} onChange={() => togglePage(page.id, page.isSystem, page.type)} />
742
+ ) : (
743
+ <div className="w-[28px] shrink-0" />
744
+ )}
745
+ </div>
746
+
747
+ {!page.isSystem ? (
748
+ <button onClick={(e) => { e.stopPropagation(); deletePage(page.id, page.isSystem); }} className="p-1.5 hover:bg-red-500/10 text-foreground/30 hover:text-red-500 transition-colors rounded-lg flex items-center justify-center shrink-0" title="Delete Page">
749
+ <Trash2 className="w-4 h-4" />
750
+ </button>
751
+ ) : (
752
+ <div className="w-[28px] shrink-0" />
753
+ )}
754
+
755
+ {isConfigurable ? (
756
+ <div className={`p-1.5 transition-transform duration-300 text-foreground/40 shrink-0 ${isExpanded ? 'rotate-180' : ''}`}>
757
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
758
+ </div>
759
+ ) : (
760
+ <div className="w-[24px] shrink-0" />
761
+ )}
762
+ </div>
763
+ </div>
764
+
765
+ <AnimatePresence>
766
+ {isExpanded && isHome && homeConfig && (
767
+ <motion.div
768
+ initial={{ height: 0, opacity: 0 }}
769
+ animate={{ height: "auto", opacity: 1 }}
770
+ exit={{ height: 0, opacity: 0 }}
771
+ className="overflow-hidden mt-4 pt-4 border-t border-[var(--panel-border)]/50 pb-4"
772
+ onClick={e => e.stopPropagation()}
773
+ >
774
+ <div className="flex flex-col font-mono text-[13px] leading-relaxed relative bg-background/50 p-4 py-6 rounded-xl border border-[var(--panel-border)] shadow-inner">
775
+
776
+ <div className="absolute top-4 right-4 flex items-center gap-1 z-20">
777
+ <button onClick={handleResetHome} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-lg flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
778
+ {resettingHome ? <Loader2 className="w-3.5 h-3.5 text-red-500 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5 text-foreground/40 group-hover:text-red-500 transition-colors" />}
779
+ </button>
780
+ <button onClick={(e) => { e.stopPropagation(); handleCopyBtn('home', homeConfig); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" title="Copy JSON">
781
+ {copyStateId === 'home' ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5 text-foreground/40 hover:text-foreground transition-colors" />}
782
+ </button>
783
+ </div>
784
+ <JsonNode
785
+ value={homeConfig}
786
+ keysToRender={['hero']}
787
+ onChange={(val: any) => handleSaveConfig('home', val)}
788
+ isLast={true}
789
+ isGlobalSaving={savingHome}
790
+ customEnums={{ form: publicForms }}
791
+ customExplanations={{
792
+ form: "User can click to switch to any public form we have in the forms tab. Private forms are not an option here. Add a new form in forms with a link that actually works."
793
+ }}
794
+ />
795
+ </div>
796
+ </motion.div>
797
+ )}
798
+ {isExpanded && isContact && contactConfig && (
799
+ <motion.div
800
+ initial={{ height: 0, opacity: 0 }}
801
+ animate={{ height: "auto", opacity: 1 }}
802
+ exit={{ height: 0, opacity: 0 }}
803
+ className="overflow-hidden mt-4 pt-4 border-t border-[var(--panel-border)]/50 pb-4"
804
+ onClick={e => e.stopPropagation()}
805
+ >
806
+ <div className="flex flex-col font-mono text-[13px] leading-relaxed relative bg-background/50 p-4 py-6 rounded-xl border border-[var(--panel-border)] shadow-inner">
807
+
808
+ <div className="absolute top-4 right-4 flex items-center gap-1 z-20">
809
+ <button onClick={handleResetContact} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-lg flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
810
+ {resettingContact ? <Loader2 className="w-3.5 h-3.5 text-red-500 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5 text-foreground/40 group-hover:text-red-500 transition-colors" />}
811
+ </button>
812
+ <button onClick={(e) => { e.stopPropagation(); handleCopyBtn('contact', contactConfig); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" title="Copy JSON">
813
+ {copyStateId === 'contact' ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5 text-foreground/40 hover:text-foreground transition-colors" />}
814
+ </button>
815
+ </div>
816
+ <JsonNode
817
+ value={contactConfig}
818
+ keysToRender={contactConfig.showButton !== false ? ['pageName', 'route', 'template', 'showButton', 'form'] : ['pageName', 'route', 'template', 'showButton']}
819
+ disabledKeys={['route']}
820
+ onChange={(val: any) => handleSaveConfig('contact', val)}
821
+ isLast={true}
822
+ isGlobalSaving={savingContact}
823
+ customEnums={{ form: publicForms, template: ['popup_form', 'inline_form'] }}
824
+ customExplanations={{
825
+ pageName: "This defines the text that actually appears globally in the Navigation menus.",
826
+ template: "This just simply tells our app when a user clicks on the contact page item what type of form do we load? is it a page or popup? this doesn't define form content! this setting just defines page layout.",
827
+ form: "This is the form that will be used. Go to /forms to edit it."
828
+ }}
829
+ />
830
+ </div>
831
+ </motion.div>
832
+ )}
833
+ {isExpanded && isSupport && supportConfig && (
834
+ <motion.div
835
+ initial={{ height: 0, opacity: 0 }}
836
+ animate={{ height: "auto", opacity: 1 }}
837
+ exit={{ height: 0, opacity: 0 }}
838
+ className="overflow-hidden mt-4 pt-4 border-t border-[var(--panel-border)]/50 pb-4"
839
+ onClick={e => e.stopPropagation()}
840
+ >
841
+ <div className="flex flex-col font-mono text-[13px] leading-relaxed relative bg-background/50 p-4 py-6 rounded-xl border border-[var(--panel-border)] shadow-inner">
842
+
843
+ <div className="absolute top-4 right-4 flex items-center gap-1 z-20">
844
+ <button onClick={handleResetSupport} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-lg flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
845
+ {resettingSupport ? <Loader2 className="w-3.5 h-3.5 text-red-500 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5 text-foreground/40 group-hover:text-red-500 transition-colors" />}
846
+ </button>
847
+ <button onClick={(e) => { e.stopPropagation(); handleCopyBtn('support', supportConfig); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" title="Copy JSON">
848
+ {copyStateId === 'support' ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5 text-foreground/40 hover:text-foreground transition-colors" />}
849
+ </button>
850
+ </div>
851
+ <JsonNode
852
+ value={supportConfig}
853
+ keysToRender={supportConfig.showButton !== false ? ['pageName', 'iconName', 'route', 'template', 'showButton', 'form'] : ['pageName', 'iconName', 'route', 'template', 'showButton']}
854
+ disabledKeys={['route']}
855
+ onChange={(val: any) => handleSaveConfig('support', val)}
856
+ isLast={true}
857
+ isGlobalSaving={savingSupport}
858
+ customEnums={{ form: privateForms, template: ['popup_form', 'inline_form'] }}
859
+ customExplanations={{
860
+ pageName: "Defines the textual title label rendering in internal sidebars.",
861
+ iconName: "To modify this icon, visit https://iconoir.com/, find an icon, and copy its exact name (e.g. 'learning', 'server'). Then paste it here.",
862
+ template: "This just simply tells our app when a user clicks on the support page item what type of form do we load? is it a page or popup? this doesn't define form content! this setting just defines page layout.",
863
+ form: "This is the form that will be used. Go to /forms to edit it."
864
+ }}
865
+ />
866
+ </div>
867
+ </motion.div>
868
+ )}
869
+ {isExpanded && !page.isSystem && draftConfigs[page.id] && (
870
+ <motion.div
871
+ initial={{ height: 0, opacity: 0 }}
872
+ animate={{ height: "auto", opacity: 1 }}
873
+ exit={{ height: 0, opacity: 0 }}
874
+ className="overflow-hidden mt-4 pt-4 border-t border-[var(--panel-border)]/50 pb-4"
875
+ onClick={e => e.stopPropagation()}
876
+ >
877
+ <div className="flex flex-col font-mono text-[13px] leading-relaxed relative bg-background/50 p-4 py-6 rounded-xl border border-[var(--panel-border)] shadow-inner">
878
+
879
+ <div className="absolute top-4 right-4 flex items-center gap-1 z-20">
880
+ <button onClick={(e) => handleResetPage(e, page.id, page.type === 'admin' ? defaultAdminConfig : page.type === 'shared' ? defaultSharedConfig : page.type === 'public' ? defaultPublicConfig : defaultPrivateConfig)} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-lg flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
881
+ {resettingMap[page.id] ? <Loader2 className="w-3.5 h-3.5 text-red-500 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5 text-foreground/40 group-hover:text-red-500 transition-colors" />}
882
+ </button>
883
+ <button onClick={(e) => { e.stopPropagation(); handleCopyBtn(page.id, draftConfigs[page.id]); }} className="w-8 h-8 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" title="Copy JSON">
884
+ {copyStateId === page.id ? <CheckCircle2 className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-foreground/10 rounded-lg flex items-center justify-center shrink-0 transition-colors" /> : <Copy className="w-3.5 h-3.5 text-foreground/40 hover:text-foreground transition-colors" />}
885
+ </button>
886
+ </div>
887
+ <JsonNode
888
+ value={draftConfigs[page.id]}
889
+ keysToRender={page.type === 'public' ? getPublicKeys(draftConfigs[page.id]) : getPrivateKeys(draftConfigs[page.id])}
890
+ onChange={(val: any) => handleSaveConfig(page.id, val)}
891
+ isLast={true}
892
+ isGlobalSaving={savingConfigMap[page.id]}
893
+ customEnums={{
894
+ form: page.type === 'public' ? publicForms : privateForms,
895
+ template: page.type === 'public' ? ['none', 'popup_form', 'inline_form']
896
+ : (page.type === 'admin' || page.type === 'shared') ? ['none', 'calendar', 'table', 'board']
897
+ : ['none', 'calendar', 'table', 'board', 'popup_form', 'inline_form'],
898
+ action: ['save_Info', 'form'],
899
+ defaultTimeFormat: ['12h', '24h'],
900
+ defaultView: ['calendar', 'board'],
901
+ buttonStyle: ['primary', 'secondary'],
902
+ buttonAction: ['save_info'],
903
+ viewsButtonStyle: ['primary', 'secondary'],
904
+ buttonStorageStyle: ['primary', 'secondary'],
905
+ buttonStorageAction: ['upload_file']
906
+ }}
907
+ customExplanations={{
908
+ tabName: "Defines the tab label in the dashboard navigation.",
909
+ iconName: "Visit https://iconoir.com/, find an icon, and paste its exact name (e.g. 'learning', 'server', 'cloud-upload').",
910
+ route: "The URL path for this tab (e.g. '/my-tab'). Must start with /.",
911
+ template: "Page layout type. 'none' gives you a freeform dashboard with add/upload buttons.",
912
+ form: "Which form to display. Only applies to popup_form or inline_form templates. Go to /forms to edit forms.",
913
+ tabTitle: "The heading that appears at the top of the page content area.",
914
+ showPrompt: "Shows the developer AI prompt copy button in the header.",
915
+ showButton: "Toggles the + add record button in the header.",
916
+ buttonStyle: "Visual style of the add button: 'primary' (accent) or 'secondary' (subtle).",
917
+ buttonText: "Label on the add button. Use '+' for a minimal icon-only button.",
918
+ buttonAction: "'save_info' saves a name + note record to the tab's Firestore collection.",
919
+ storage: "Enables the file upload button for this tab.",
920
+ buttonStorageIcon: "Icon name from https://iconoir.com/ for the upload button (e.g. 'upload', 'cloud-upload').",
921
+ buttonStorageText: "Label next to the upload icon. Leave empty for icon-only.",
922
+ buttonStorageStyle: "'primary' or 'secondary' visual style for the upload button.",
923
+ buttonStorageAction: page.type === 'admin'
924
+ ? "'upload_file' uploads to admin_files/ in Storage and mirrors metadata to admin_files collection (Drive > Admin)."
925
+ : page.type === 'shared'
926
+ ? "'upload_file' uploads to mem_files/ in Storage and mirrors metadata to mem_files collection (Drive > Shared)."
927
+ : "'upload_file' uploads to user_files/ in Storage and mirrors metadata to user_files collection (Drive > Private).",
928
+ defaultView: "Starting view: 'calendar' (month grid) or 'board' (event list). Users can switch between them.",
929
+ defaultTimeFormat: "12h or 24h time format. Users can toggle this in the toolbar.",
930
+ showViewsButton: "Show the calendar/board view toggle button in the toolbar.",
931
+ viewsButtonStyle: "'primary' or 'secondary' visual style for the view toggle.",
932
+ showExportButton: "Show the CSV export/download button in the table toolbar.",
933
+ exportButtonIcon: "Icon name from https://iconoir.com/ for the export button (e.g. 'download-square', 'import').",
934
+ keyName: "Label for the key/title field in board items (e.g. 'Task', 'Name').",
935
+ valueName: "Label for the value/description field in board items (e.g. 'Notes', 'Details').",
936
+ }}
937
+ hideInnerLoader={true}
938
+ />
939
+ </div>
940
+ </motion.div>
941
+ )}
942
+ </AnimatePresence>
943
+ </div>
944
+ );
945
+ };
946
+
947
+ return (
948
+ <motion.div
949
+ key={section.id}
950
+ initial={{ opacity: 0, y: 10 }}
951
+ animate={{ opacity: 1, y: 0 }}
952
+ className="flex flex-col relative"
953
+ >
954
+ <div className="flex items-center gap-3 mb-2 border-b border-[var(--panel-border)]/50 pb-4">
955
+ <div className="w-8 h-8 rounded-xl glass-panel border border-[var(--panel-border)] flex items-center justify-center -ml-1 shadow-sm">
956
+ <Icon className="w-4 h-4 text-accent" />
957
+ </div>
958
+ <h2 className="text-xl font-extrabold tracking-tight text-foreground">{section.title}</h2>
959
+ </div>
960
+ <p className="text-[13px] leading-relaxed text-foreground/50 font-medium max-w-3xl mb-4 ml-[2px]">
961
+ {section.description}
962
+ </p>
963
+
964
+ {sectionPages.length === 0 ? (
965
+ <div className="flex flex-col items-center justify-center py-8 opacity-40 glass-panel rounded-2xl border border-[var(--panel-border)]/50 border-dashed">
966
+ <Layers className="w-5 h-5 mb-2" />
967
+ <p className="text-[12px] font-bold">This section is currently empty.</p>
968
+ </div>
969
+ ) : (
970
+ <div className="flex flex-col gap-4">
971
+ {activePages.map((page) => {
972
+ const orgIdx = sectionPages.findIndex(p => p.id === page.id);
973
+ return renderPage(page, orgIdx, false);
974
+ })}
975
+
976
+ {hiddenPages.length > 0 && (
977
+ <details className="mt-4 group/details">
978
+ <summary className="text-[11px] font-bold uppercase tracking-wider text-foreground/40 cursor-pointer list-none flex items-center gap-2 select-none hover:text-foreground/70 transition-colors w-fit pb-1 ml-1 outline-none">
979
+ <ChevronDown className="w-3.5 h-3.5 group-open/details:-rotate-180 transition-transform" />
980
+ Hidden pages ({hiddenPages.length})
981
+ </summary>
982
+ <div className="flex flex-col gap-4 mt-4">
983
+ {hiddenPages.map((page) => {
984
+ const orgIdx = sectionPages.findIndex(p => p.id === page.id);
985
+ return renderPage(page, orgIdx, true);
986
+ })}
987
+ </div>
988
+ </details>
989
+ )}
990
+ </div>
991
+ )}
992
+
993
+ {idx < sections.length - 1 && (
994
+ <div className="w-full border-t border-[var(--panel-border)]/70 my-10" />
995
+ )}
996
+ </motion.div>
997
+ );
998
+ })}
999
+ </div>
1000
+ </div>
1001
+ </div>
1002
+ <ConfirmModal
1003
+ isOpen={showResetAllConfirm}
1004
+ onCancel={() => setShowResetAllConfirm(false)}
1005
+ onConfirm={handleResetAllDefaults}
1006
+ title="Reset System Pages"
1007
+ message="Are you sure you want to reset all system page configurations? Custom pages and their order will remain intact."
1008
+ confirmText="Reset"
1009
+ isProcessing={isResettingAll}
1010
+ />
1011
+ <ConfirmModal
1012
+ isOpen={!!deleteConfirmId}
1013
+ onCancel={() => setDeleteConfirmId(null)}
1014
+ onConfirm={confirmDeletePage}
1015
+ title="Delete Page"
1016
+ message={`Are you sure you want to delete this page? This action cannot be undone and will remove all associated configurations.`}
1017
+ confirmText="Remove Page"
1018
+ isProcessing={isDeleting}
1019
+ />
1020
+ </main>
1021
+ );
1022
+ }