firebase-os 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +5 -20
- package/dist/firebase-os.es.js +95 -90
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +86 -15
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +81 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +233 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +407 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +378 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- package/src/templates/TemplateTable.tsx +675 -0
|
@@ -0,0 +1,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
|
+
}
|