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,92 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { motion } from 'framer-motion';
4
+ import { ArrowLeft, KeyRound } from 'lucide-react';
5
+ import { Input } from '../components/Input';
6
+ import { Button } from '../components/Button';
7
+ import { auth } from '../lib/firebase';
8
+ import { sendPasswordResetEmail } from 'firebase/auth';
9
+
10
+ export function ResetPassword() {
11
+ const [loading, setLoading] = useState(false);
12
+ const [msg, setMsg] = useState<string | null>(null);
13
+ const [timer, setTimer] = useState(0);
14
+ const [attempts, setAttempts] = useState(0);
15
+
16
+ useEffect(() => {
17
+ let interval: ReturnType<typeof setInterval>;
18
+ if (timer > 0) {
19
+ interval = setInterval(() => setTimer(t => t - 1), 1000);
20
+ }
21
+ return () => clearInterval(interval);
22
+ }, [timer]);
23
+
24
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
25
+ e.preventDefault();
26
+
27
+ setLoading(true);
28
+ setMsg(null);
29
+
30
+ const formData = new FormData(e.currentTarget);
31
+ const email = formData.get('email') as string;
32
+
33
+ try {
34
+ await sendPasswordResetEmail(auth, email);
35
+ } catch (err: unknown) {
36
+ console.error(err);
37
+ } finally {
38
+ setMsg('If an account exists, a password reset link has been sent. Please check your inbox (and spam folder).');
39
+ const newAttempts = attempts + 1;
40
+ setAttempts(newAttempts);
41
+ setTimer(newAttempts === 1 ? 60 : 120);
42
+ setLoading(false);
43
+ }
44
+ };
45
+
46
+ const formatTime = (s: number) => {
47
+ const mins = Math.floor(s / 60);
48
+ const secs = s % 60;
49
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
50
+ };
51
+
52
+ return (
53
+ <div className="flex-1 flex items-center justify-center w-full z-10 px-4 mb-16 mt-6 md:mt-8">
54
+ <motion.div
55
+ initial={{ opacity: 0, y: 10 }}
56
+ animate={{ opacity: 1, y: 0 }}
57
+ transition={{ duration: 0.5 }}
58
+ className="w-full max-w-[480px] p-6 md:p-8 lg:p-10 rounded-3xl glass-panel relative glow-hover shadow-[0_8px_32px_rgba(0,0,0,0.08)] text-center"
59
+ >
60
+ <div className="absolute -inset-[1px] bg-gradient-to-br from-[var(--primary-glow)] to-transparent opacity-40 blur-[15px] rounded-3xl -z-10" />
61
+
62
+ <div className="w-14 h-14 md:w-16 md:h-16 rounded-2xl glass-panel border border-[var(--panel-border)] flex items-center justify-center mx-auto mb-5 md:mb-6 animate-float shadow-xl">
63
+ <KeyRound className="w-7 h-7 md:w-8 md:h-8 text-accent opacity-90" />
64
+ </div>
65
+
66
+ <div className="mb-6 text-center">
67
+ <h3 className="text-2xl md:text-3xl font-extrabold mb-2 md:mb-3 text-foreground tracking-tight">Reset Password</h3>
68
+ <p className="text-[14px] md:text-[15px] font-medium text-foreground/60 tracking-wide">Enter your email to receive a recovery link.</p>
69
+ </div>
70
+
71
+ {msg && <div className="p-3 md:p-4 mb-5 md:mb-6 text-[13px] md:text-[14px] font-medium text-emerald-400 bg-emerald-500/10 rounded-xl border border-emerald-500/20 max-w-[360px] mx-auto">{msg}</div>}
72
+
73
+ <form className="space-y-4 text-left max-w-[360px] mx-auto" onSubmit={handleSubmit}>
74
+ <Input name="email" label="Work Email" type="email" placeholder="name@company.com" required />
75
+
76
+ <div className="pt-2">
77
+ <Button type="submit" className={`w-full py-2.5 md:py-3 text-[14px] font-bold tracking-wide rounded-xl md:rounded-xl transition-all ${timer > 0 ? 'bg-foreground/5 text-foreground/40 border border-[var(--panel-border)] shadow-none cursor-not-allowed hover:translate-y-0' : 'hover:-translate-y-0.5'}`} isLoading={loading} disabled={timer > 0}>
78
+ {timer > 0 ? `Resend link in ${formatTime(timer)}` : attempts > 0 ? 'Resend reset password link' : 'Send Reset Link'}
79
+ </Button>
80
+ </div>
81
+ </form>
82
+
83
+ <div className="mt-6 pt-5 border-t border-[var(--panel-border)] space-y-4 text-center">
84
+ <Link to="/login" className="inline-flex items-center text-[13px] md:text-[14px] text-foreground/70 font-medium hover:text-accent transition-colors tracking-wide">
85
+ <ArrowLeft className="w-4 h-4 mr-2" />
86
+ Back to Sign In
87
+ </Link>
88
+ </div>
89
+ </motion.div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,98 @@
1
+ // @generated-test
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, act } from '@testing-library/react';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import { AuthProvider } from '../lib/AuthContext';
6
+ import { ThemeProvider } from '../lib/ThemeContext';
7
+ import { SettingsTab } from './Settings';
8
+
9
+ // Global mocks
10
+ Object.defineProperty(window, 'matchMedia', {
11
+ writable: true,
12
+ value: vi.fn().mockImplementation(query => ({
13
+ matches: false,
14
+ media: query,
15
+ onchange: null,
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ addEventListener: vi.fn(),
19
+ removeEventListener: vi.fn(),
20
+ dispatchEvent: vi.fn(),
21
+ })),
22
+ });
23
+
24
+ vi.mock('../lib/AuthContext', () => ({
25
+ AuthProvider: ({ children }: any) => <>{children}</>,
26
+ useAuth: () => ({
27
+ user: { uid: 'mock-user-123', email: 'test@example.com' },
28
+ userWorkspaces: [],
29
+ activeWorkspace: null,
30
+ activeOrg: null,
31
+ loading: false
32
+ })
33
+ }));
34
+
35
+ vi.mock('../lib/ThemeContext', () => ({
36
+ ThemeProvider: ({ children }: any) => <>{children}</>,
37
+ useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
38
+ }));
39
+
40
+ vi.mock('firebase/auth', () => ({
41
+ getAuth: vi.fn(() => ({})),
42
+ onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
43
+ }));
44
+ vi.mock('firebase/firestore', () => ({
45
+ getFirestore: vi.fn(() => ({})),
46
+ collection: vi.fn(),
47
+ doc: vi.fn(),
48
+ setDoc: vi.fn(() => Promise.resolve()),
49
+ addDoc: vi.fn(() => Promise.resolve()),
50
+ updateDoc: vi.fn(() => Promise.resolve()),
51
+ deleteDoc: vi.fn(() => Promise.resolve()),
52
+ query: vi.fn(),
53
+ where: vi.fn(),
54
+ orderBy: vi.fn(),
55
+ limit: vi.fn(),
56
+ getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
57
+ getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
58
+ onSnapshot: vi.fn((...args: any[]) => {
59
+ let cb = args[1];
60
+ if (typeof args[2] === 'function') {
61
+ cb = args[2];
62
+ }
63
+ if (typeof cb === 'function') {
64
+ cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
65
+ }
66
+ return () => {};
67
+ })
68
+ }));
69
+ vi.mock('firebase/storage', () => ({
70
+ getStorage: vi.fn(() => ({})),
71
+ ref: vi.fn(),
72
+ listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
73
+ getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
74
+ getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
75
+ }));
76
+
77
+ describe('SettingsTab Component', () => {
78
+ it('renders without crashing', async () => {
79
+ // Wrap in standard application providers inside act to process async side effects and prevent warnings
80
+ await act(async () => {
81
+ render(
82
+ <BrowserRouter>
83
+ <AuthProvider>
84
+ <ThemeProvider>
85
+ {/* @ts-ignore */}
86
+ <SettingsTab />
87
+ </ThemeProvider>
88
+ </AuthProvider>
89
+ </BrowserRouter>
90
+ );
91
+ // Wait a tick to flush background state updates
92
+ await new Promise(resolve => setTimeout(resolve, 0));
93
+ });
94
+
95
+ // Check if the document has anything rendered without throwing
96
+ expect(document.body).toBeDefined();
97
+ });
98
+ });
@@ -0,0 +1,393 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { Menu, ChevronUp, ChevronDown, Eye, EyeOff, Trash2, RotateCcw, Blocks, Loader2 } from 'lucide-react';
4
+ import { DashboardNav } from '../components/DashboardNav';
5
+ import { ConfirmModal } from '../components/ConfirmModal';
6
+ import { db } from '../lib/firebase';
7
+ import { doc, onSnapshot, setDoc, deleteDoc, collection, writeBatch } from 'firebase/firestore';
8
+ import type { OrderItem } from '../configs/pages/tabOrders.config';
9
+ import { parseOrderItems, defaultTabOrder } from '../configs/pages/tabOrders.config';
10
+ import { defaultMenuOrder } from '../configs/pages/menuOrders.config';
11
+
12
+ // ── Info about each page/tab (labels, paths) ──────────────────────────────
13
+ interface PageInfo {
14
+ id: string;
15
+ title: string;
16
+ path: string;
17
+ isSystem: boolean;
18
+ type: string;
19
+ }
20
+
21
+ // System tab definitions (used to always show these in the list)
22
+ const systemTabIds = ['pages', 'forms', 'drive', 'users', 'theme', 'submissions', 'requests', 'settings', 'profile', 'support'];
23
+ const systemTabTitles: Record<string, string> = {
24
+ pages: 'Pages', forms: 'Forms', drive: 'Drive', users: 'Users', theme: 'Theme',
25
+ submissions: 'Submissions', requests: 'Requests', settings: 'Settings',
26
+ profile: 'Profile', support: 'Request Support'
27
+ };
28
+
29
+ // IDs that can never be hidden via the eye icon
30
+ const ALWAYS_VISIBLE_PAGES = new Set(['home']);
31
+ const ALWAYS_VISIBLE_TABS = new Set(['settings']);
32
+
33
+ export function SettingsTab() {
34
+ const [activeTab, setActiveTab] = useState<'menu' | 'tabs'>('menu');
35
+
36
+ // Info maps (labels / paths from Firestore)
37
+ const [pageInfoMap, setPageInfoMap] = useState<Record<string, PageInfo>>({});
38
+ const [tabInfoMap, setTabInfoMap] = useState<Record<string, PageInfo>>({});
39
+
40
+ // Order arrays stored in sys_configs — these include `hidden`
41
+ const [pageOrder, setPageOrder] = useState<OrderItem[]>(defaultMenuOrder);
42
+ const [tabOrder, setTabOrder] = useState<OrderItem[]>(defaultTabOrder);
43
+
44
+ const [isResetting, setIsResetting] = useState(false);
45
+
46
+ // Delete confirm state
47
+ const [deleteTarget, setDeleteTarget] = useState<{ info: PageInfo; isTab: boolean } | null>(null);
48
+ const [isDeleting, setIsDeleting] = useState(false);
49
+
50
+ useEffect(() => {
51
+ // ── Public page info ──────────────────────────────────────────
52
+ const unsubPages = onSnapshot(collection(db, 'sys_pages'), (snap) => {
53
+ const map: Record<string, PageInfo> = {};
54
+ snap.forEach(d => {
55
+ const data = d.data();
56
+ map[d.id] = {
57
+ id: d.id,
58
+ title: data.pageName || data.title || d.id,
59
+ path: data.route || (d.id === 'home' ? '/' : '/' + d.id),
60
+ type: 'public',
61
+ isSystem: ['home', 'templates', 'contact', 'setup'].includes(d.id),
62
+ };
63
+ });
64
+ // Ensure system stubs exist even if not in Firestore
65
+ if (!map['setup']) map['setup'] = { id: 'setup', title: 'Setup', path: '/setup', type: 'public', isSystem: true };
66
+ if (!map['templates']) map['templates'] = { id: 'templates', title: 'Templates', path: '/templates', type: 'public', isSystem: true };
67
+ if (!map['contact']) map['contact'] = { id: 'contact', title: 'Contact', path: '/contact', type: 'public', isSystem: true };
68
+ setPageInfoMap(map);
69
+ });
70
+
71
+ // ── Dashboard tab info ────────────────────────────────────────
72
+ const unsubTabs = onSnapshot(collection(db, 'sys_tabs'), (snap) => {
73
+ const map: Record<string, PageInfo> = {};
74
+ snap.forEach(d => {
75
+ const data = d.data();
76
+ map[d.id] = {
77
+ id: d.id,
78
+ title: data.pageName || data.tabName || data.title || systemTabTitles[d.id] || d.id,
79
+ path: data.route || '/' + d.id,
80
+ type: data.pageType || 'admin',
81
+ isSystem: systemTabIds.includes(d.id),
82
+ };
83
+ });
84
+ // Ensure system stubs exist even if not in Firestore
85
+ systemTabIds.forEach(id => {
86
+ if (!map[id]) map[id] = { id, title: systemTabTitles[id] || id, path: '/' + id, type: 'admin', isSystem: true };
87
+ });
88
+ setTabInfoMap(map);
89
+ });
90
+
91
+ // ── Order & visibility ────────────────────────────────────────
92
+ const unsubPageOrd = onSnapshot(doc(db, 'sys_configs', 'page_order'), d => {
93
+ setPageOrder(d.exists() ? parseOrderItems(d.data().order || []) : defaultMenuOrder);
94
+ });
95
+ const unsubTabOrd = onSnapshot(doc(db, 'sys_configs', 'tab_order'), d => {
96
+ setTabOrder(d.exists() ? parseOrderItems(d.data().order || []) : defaultTabOrder);
97
+ });
98
+
99
+ return () => { unsubPages(); unsubTabs(); unsubPageOrd(); unsubTabOrd(); };
100
+ }, []);
101
+
102
+ // ── Toggle visibility: flip `hidden` in the order array ───────────────
103
+ const handleToggle = async (id: string, isTab: boolean) => {
104
+ const guards = isTab ? ALWAYS_VISIBLE_TABS : ALWAYS_VISIBLE_PAGES;
105
+ if (guards.has(id)) return;
106
+
107
+ const currentOrder = isTab ? tabOrder : pageOrder;
108
+ const docId = isTab ? 'tab_order' : 'page_order';
109
+
110
+ // Clean up any ghosts
111
+ const cleanOrder = currentOrder.filter(o =>
112
+ (isTab ? tabInfoMap : pageInfoMap)[o.id] ||
113
+ (isTab ? systemTabIds.includes(o.id) : ['home', 'contact'].includes(o.id))
114
+ );
115
+
116
+ // Ensure item exists in order, then flip hidden
117
+ const inOrder = cleanOrder.some(o => o.id === id);
118
+ const newOrder: OrderItem[] = inOrder
119
+ ? cleanOrder.map(o => o.id === id ? { ...o, hidden: !o.hidden } : o)
120
+ : [...cleanOrder, { id, hidden: true }];
121
+
122
+ await setDoc(doc(db, 'sys_configs', docId), { order: newOrder });
123
+ };
124
+
125
+ // ── Delete a custom (non-system) item ────────────────────────────────
126
+ const handleDelete = (info: PageInfo, isTab: boolean) => {
127
+ if (info.isSystem) return;
128
+ setDeleteTarget({ info, isTab });
129
+ };
130
+
131
+ const confirmDelete = async () => {
132
+ if (!deleteTarget) return;
133
+ setIsDeleting(true);
134
+ const coll = deleteTarget.isTab ? 'sys_tabs' : 'sys_pages';
135
+ const docId = deleteTarget.isTab ? 'tab_order' : 'page_order';
136
+ const currentOrder = deleteTarget.isTab ? tabOrder : pageOrder;
137
+
138
+ try {
139
+ await deleteDoc(doc(db, coll, deleteTarget.info.id));
140
+
141
+ // Clean from order array
142
+ const newOrder = currentOrder.filter(o => o.id !== deleteTarget.info.id);
143
+ await setDoc(doc(db, 'sys_configs', docId), { order: newOrder });
144
+
145
+ } catch (e) {
146
+ console.error('Delete failed', e);
147
+ }
148
+ setIsDeleting(false);
149
+ setDeleteTarget(null);
150
+ };
151
+
152
+ // ── Reorder: move item up/down in the order array ────────────────────
153
+ const moveOrder = async (isTab: boolean, id: string, direction: 'up' | 'down') => {
154
+ const currentOrder = isTab ? tabOrder : pageOrder;
155
+ const infoMap = isTab ? tabInfoMap : pageInfoMap;
156
+ const docId = isTab ? 'tab_order' : 'page_order';
157
+
158
+ // Build complete list: ordered items first, then any not yet in order
159
+ let items = currentOrder.filter(o => infoMap[o.id] || (isTab ? systemTabIds.includes(o.id) : ['home', 'contact'].includes(o.id)));
160
+ Object.keys(infoMap).forEach(infoId => {
161
+ if (!items.some(o => o.id === infoId)) {
162
+ items.push({ id: infoId, hidden: false });
163
+ }
164
+ });
165
+
166
+ const idx = items.findIndex(o => o.id === id);
167
+ if (idx === -1) return;
168
+
169
+ // Home always pinned first in menu
170
+ const minIdx = (!isTab && items[0]?.id === 'home') ? 1 : 0;
171
+ const newIdx = direction === 'up'
172
+ ? Math.max(minIdx, idx - 1)
173
+ : Math.min(items.length - 1, idx + 1);
174
+ if (idx === newIdx) return;
175
+
176
+ const newOrder = [...items];
177
+ const [moved] = newOrder.splice(idx, 1);
178
+ newOrder.splice(newIdx, 0, moved);
179
+
180
+ await setDoc(doc(db, 'sys_configs', docId), { order: newOrder });
181
+ };
182
+
183
+ // ── Reset: restore defaults but keep custom pages at the FRONT of the list ─
184
+ const handleRestoreDefaults = async () => {
185
+ if (isResetting) return;
186
+ setIsResetting(true);
187
+ try {
188
+ // Collect custom (non-default) page/tab IDs from Firestore
189
+ const defaultPageIds = new Set(defaultMenuOrder.map(o => o.id));
190
+ const defaultTabIds = new Set(defaultTabOrder.map(o => o.id));
191
+
192
+ // Custom items currently in the order lists that are not in the defaults
193
+ const customPageItems = pageOrder.filter(o => !defaultPageIds.has(o.id));
194
+ const customTabItems = tabOrder.filter(o => !defaultTabIds.has(o.id));
195
+
196
+ // Custom items come FIRST, then the default order (all unhidden)
197
+ const newPageOrder: OrderItem[] = [
198
+ ...customPageItems.map(o => ({ ...o, hidden: false })),
199
+ ...defaultMenuOrder,
200
+ ];
201
+ const newTabOrder: OrderItem[] = [
202
+ ...customTabItems.map(o => ({ ...o, hidden: false })),
203
+ ...defaultTabOrder,
204
+ ];
205
+
206
+ const batch = writeBatch(db);
207
+ batch.set(doc(db, 'sys_configs', 'page_order'), { order: newPageOrder });
208
+ batch.set(doc(db, 'sys_configs', 'tab_order'), { order: newTabOrder });
209
+ await batch.commit();
210
+ } finally {
211
+ setTimeout(() => setIsResetting(false), 600);
212
+ }
213
+ };
214
+
215
+ // ── Build the display list ────────────────────────────────────────────
216
+ const buildDisplayList = (isTab: boolean) => {
217
+ const currentOrder = isTab ? tabOrder : pageOrder;
218
+ const infoMap = isTab ? tabInfoMap : pageInfoMap;
219
+
220
+ // Clean up ghost items so deleted pages instantly vanish
221
+ const validOrder = currentOrder.filter(o => infoMap[o.id] || (isTab ? systemTabIds.includes(o.id) : ['home', 'contact'].includes(o.id)));
222
+
223
+ // Merge order entries with any known IDs not yet in the order
224
+ const placed = new Set(validOrder.map(o => o.id));
225
+ const extras: OrderItem[] = Object.keys(infoMap)
226
+ .filter(id => !placed.has(id))
227
+ .map(id => ({ id, hidden: false }));
228
+
229
+ let display = [...validOrder, ...extras];
230
+
231
+ // For menu: pin home at front (but don't show it as a toggle-able item)
232
+ if (!isTab) {
233
+ display = display.filter(o => o.id !== 'home');
234
+ }
235
+
236
+ return display;
237
+ };
238
+
239
+ const renderSortList = (isTab: boolean) => {
240
+ const infoMap = isTab ? tabInfoMap : pageInfoMap;
241
+ const guards = isTab ? ALWAYS_VISIBLE_TABS : ALWAYS_VISIBLE_PAGES;
242
+ const display = buildDisplayList(isTab);
243
+
244
+ return (
245
+ <div className="flex flex-col gap-3">
246
+ {display.map((orderItem, idx) => {
247
+ const info = infoMap[orderItem.id];
248
+ const title = info?.title || systemTabTitles[orderItem.id] || orderItem.id;
249
+ const path = info?.path || '/' + orderItem.id;
250
+ const isSystem = info?.isSystem ?? systemTabIds.includes(orderItem.id);
251
+ const isGuarded = guards.has(orderItem.id);
252
+
253
+ const isFirst = idx === 0;
254
+ const isLast = idx === display.length - 1;
255
+
256
+ return (
257
+ <div
258
+ key={orderItem.id}
259
+ className={`flex items-center justify-between p-4 glass-panel border border-[var(--panel-border)] rounded-2xl transition-all duration-200 ${orderItem.hidden ? 'opacity-40' : 'opacity-100'}`}
260
+ >
261
+ <div className="flex items-center gap-4">
262
+ <div className="flex flex-col items-center w-6">
263
+ <button
264
+ disabled={isFirst}
265
+ onClick={() => moveOrder(isTab, orderItem.id, 'up')}
266
+ className="text-foreground/40 hover:text-accent disabled:opacity-20"
267
+ >
268
+ <ChevronUp className="w-4 h-4" />
269
+ </button>
270
+ <button
271
+ disabled={isLast}
272
+ onClick={() => moveOrder(isTab, orderItem.id, 'down')}
273
+ className="text-foreground/40 hover:text-accent disabled:opacity-20 -mt-1"
274
+ >
275
+ <ChevronDown className="w-4 h-4" />
276
+ </button>
277
+ </div>
278
+ <div className="flex flex-col">
279
+ <span className="font-bold text-foreground text-[14px]">{title}</span>
280
+ <span className="text-[12px] font-mono text-foreground/50">{path}</span>
281
+ </div>
282
+ </div>
283
+
284
+ <div className="flex items-center gap-3">
285
+ {!isGuarded && (
286
+ <button
287
+ onClick={() => handleToggle(orderItem.id, isTab)}
288
+ title={orderItem.hidden ? 'Show this page' : 'Hide this page'}
289
+ className={`p-2 rounded-xl transition-colors ${orderItem.hidden ? 'hover:bg-foreground/5 text-foreground/40' : 'hover:bg-accent/10 text-accent'}`}
290
+ >
291
+ {orderItem.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
292
+ </button>
293
+ )}
294
+ {info && !isSystem && (
295
+ <button
296
+ onClick={() => handleDelete(info, isTab)}
297
+ className="p-2 rounded-xl hover:bg-red-500/10 text-foreground/40 hover:text-red-500 transition-colors"
298
+ >
299
+ <Trash2 className="w-4 h-4" />
300
+ </button>
301
+ )}
302
+ </div>
303
+ </div>
304
+ );
305
+ })}
306
+ </div>
307
+ );
308
+ };
309
+
310
+ return (
311
+ <>
312
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
313
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
314
+ <h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
315
+ Settings
316
+ </h1>
317
+ <div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
318
+ <span className="w-8 h-[1px] bg-foreground/10" />
319
+ /settings
320
+ </div>
321
+ </motion.div>
322
+
323
+ <div className="flex flex-col gap-6 animate-in fade-in duration-500">
324
+ <div className="flex justify-start">
325
+ <DashboardNav />
326
+ </div>
327
+
328
+ <div className="glass-panel rounded-3xl border border-[var(--panel-border)] shadow-sm grid grid-cols-1 md:grid-cols-3 min-h-[500px]">
329
+ {/* Sidebar */}
330
+ <div className="md:col-span-1 border-r md:border-r-[var(--panel-border)] border-b md:border-b-0 border-[var(--panel-border)] flex flex-col bg-black/5 dark:bg-white/5 rounded-t-3xl md:rounded-tr-none md:rounded-l-3xl">
331
+ <div className="p-6 pb-4 font-bold text-[13px] text-foreground/40 uppercase tracking-widest">Configuration</div>
332
+ <div className="flex-1 overflow-y-auto px-4 pb-4 space-y-1">
333
+ <button
334
+ onClick={() => setActiveTab('menu')}
335
+ className={`w-full p-3 rounded-xl transition-all flex items-center gap-3 cursor-pointer ${activeTab === 'menu' ? 'bg-accent/10 text-accent font-bold' : 'bg-transparent text-foreground/70 hover:bg-foreground/5 hover:text-foreground font-medium'}`}
336
+ >
337
+ <Menu className="w-4 h-4 shrink-0" />
338
+ <span className="text-[14px]">Menu Settings</span>
339
+ </button>
340
+ <button
341
+ onClick={() => setActiveTab('tabs')}
342
+ className={`w-full p-3 rounded-xl transition-all flex items-center gap-3 cursor-pointer ${activeTab === 'tabs' ? 'bg-accent/10 text-accent font-bold' : 'bg-transparent text-foreground/70 hover:bg-foreground/5 hover:text-foreground font-medium'}`}
343
+ >
344
+ <Blocks className="w-4 h-4 shrink-0" />
345
+ <span className="text-[14px]">Tabs Settings</span>
346
+ </button>
347
+ </div>
348
+ </div>
349
+
350
+ {/* Content */}
351
+ <div className="md:col-span-2 flex flex-col p-6 md:p-10">
352
+ <div className="animate-in fade-in duration-300 w-full">
353
+ <div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-8">
354
+ <div className="flex flex-col">
355
+ <h3 className="text-2xl md:text-3xl font-extrabold text-foreground tracking-tighter mb-2">
356
+ {activeTab === 'menu' ? 'Menu Settings' : 'Dashboard Tabs'}
357
+ </h3>
358
+ <p className="text-[14px] text-foreground/60 max-w-xl">
359
+ {activeTab === 'menu'
360
+ ? 'Reorder or hide public menu items. Changes take effect instantly for all visitors.'
361
+ : 'Manage visibility and order of all internal dashboard tabs.'}
362
+ </p>
363
+ </div>
364
+ <button
365
+ onClick={handleRestoreDefaults}
366
+ disabled={isResetting}
367
+ title="Reset order and visibility to defaults"
368
+ className="p-3 h-fit bg-foreground/5 hover:bg-foreground/10 text-foreground/70 active:scale-95 disabled:opacity-50 disabled:pointer-events-none rounded-xl transition-all font-bold text-[13px] flex items-center justify-center border border-transparent hover:border-[var(--panel-border)] shadow-sm shrink-0"
369
+ >
370
+ {isResetting
371
+ ? <Loader2 className="w-5 h-5 text-foreground/50 animate-spin" />
372
+ : <RotateCcw className="w-5 h-5 text-foreground/50" />}
373
+ </button>
374
+ </div>
375
+
376
+ {renderSortList(activeTab === 'tabs')}
377
+ </div>
378
+ </div>
379
+ </div>
380
+ </div>
381
+ </main>
382
+ <ConfirmModal
383
+ isOpen={!!deleteTarget}
384
+ onCancel={() => setDeleteTarget(null)}
385
+ onConfirm={confirmDelete}
386
+ title="Delete Item"
387
+ message={`Are you sure you want to delete "${deleteTarget?.info?.title ?? ''}"? This cannot be undone.`}
388
+ confirmText="Delete"
389
+ isProcessing={isDeleting}
390
+ />
391
+ </>
392
+ );
393
+ }