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
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ── Firebase OS — Library Entry Point ─────────────────────────────────────
|
|
2
|
+
// This file is the ONLY public API surface of the library.
|
|
3
|
+
// Everything else (pages, templates, components) is internal.
|
|
4
|
+
|
|
5
|
+
// The main component — this IS the library
|
|
6
|
+
export { FirebaseOS } from './FirebaseOS';
|
|
7
|
+
export type { FirebaseOSProps } from './FirebaseOS';
|
|
8
|
+
|
|
9
|
+
// Optional hooks for advanced users who want to build around it
|
|
10
|
+
export { useAuth } from './lib/AuthContext';
|
|
11
|
+
export { useTheme } from './lib/ThemeContext';
|
|
12
|
+
|
|
13
|
+
// Config context for library consumers who need deep integration
|
|
14
|
+
export { useConfig } from './lib/ConfigContext';
|
|
15
|
+
export type { FirebaseOSConfig } from './lib/ConfigContext';
|
|
16
|
+
|
|
17
|
+
// ── Hybrid Architecture Exports ───────────────────────────────────────────
|
|
18
|
+
// These are exported so consumers can copy and modify templates/pages
|
|
19
|
+
// while still relying on the internal UI components and Firebase connections.
|
|
20
|
+
export { db, auth, storage } from './lib/firebase';
|
|
21
|
+
export { ContactPopup } from './components/ContactPopup';
|
|
22
|
+
export { defaultHomeConfig } from './configs/pages/home.config';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor, act } from '@testing-library/react';
|
|
3
|
+
import { AuthProvider, useAuth } from '../lib/AuthContext';
|
|
4
|
+
import { onAuthStateChanged } from 'firebase/auth';
|
|
5
|
+
|
|
6
|
+
vi.mock('firebase/auth', () => ({
|
|
7
|
+
getAuth: vi.fn(() => ({})),
|
|
8
|
+
onAuthStateChanged: vi.fn(),
|
|
9
|
+
signInWithEmailAndPassword: vi.fn(),
|
|
10
|
+
GoogleAuthProvider: vi.fn(),
|
|
11
|
+
signInWithPopup: vi.fn(),
|
|
12
|
+
signOut: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('firebase/firestore', () => ({
|
|
16
|
+
getFirestore: vi.fn(() => ({})),
|
|
17
|
+
collection: vi.fn(),
|
|
18
|
+
query: vi.fn(),
|
|
19
|
+
where: vi.fn(),
|
|
20
|
+
getDocs: vi.fn(() => Promise.resolve({ empty: true, docs: [] })),
|
|
21
|
+
doc: vi.fn(),
|
|
22
|
+
writeBatch: vi.fn(() => ({
|
|
23
|
+
set: vi.fn(),
|
|
24
|
+
commit: vi.fn(() => Promise.resolve())
|
|
25
|
+
})),
|
|
26
|
+
serverTimestamp: vi.fn(),
|
|
27
|
+
setDoc: vi.fn(),
|
|
28
|
+
onSnapshot: vi.fn((...args: any[]) => {
|
|
29
|
+
const cb = args.find(arg => typeof arg === "function");
|
|
30
|
+
if (typeof cb === 'function') cb({ exists: () => true, data: () => ({ role: 'super_admin' }) });
|
|
31
|
+
else if (args.length > 2 && typeof args[2] === 'function') args[1]({ exists: () => true, data: () => ({ role: 'super_admin' }) });
|
|
32
|
+
else if (args.length > 1 && typeof args[1] === 'function') args[1]({ exists: () => true, data: () => ({ role: 'super_admin' }) });
|
|
33
|
+
return () => {};
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const TestComponent = () => {
|
|
38
|
+
const { user, loading } = useAuth();
|
|
39
|
+
if (loading) return <div>Loading...</div>;
|
|
40
|
+
if (!user) return <div>Not logged in</div>;
|
|
41
|
+
return <div>Logged in as {user.email}</div>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('AuthContext integration with Firebase', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('initially displays loading state', () => {
|
|
50
|
+
(onAuthStateChanged as any).mockImplementation(() => () => {});
|
|
51
|
+
render(
|
|
52
|
+
<AuthProvider>
|
|
53
|
+
<TestComponent />
|
|
54
|
+
</AuthProvider>
|
|
55
|
+
);
|
|
56
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('updates state when onAuthStateChanged triggers', async () => {
|
|
60
|
+
let callback: any;
|
|
61
|
+
(onAuthStateChanged as any).mockImplementation((_auth: any, cb: any) => {
|
|
62
|
+
callback = cb;
|
|
63
|
+
return () => {};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
<AuthProvider>
|
|
68
|
+
<TestComponent />
|
|
69
|
+
</AuthProvider>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
73
|
+
|
|
74
|
+
// Trigger auth state change
|
|
75
|
+
await act(async () => {
|
|
76
|
+
await callback({
|
|
77
|
+
email: 'test@company.com',
|
|
78
|
+
emailVerified: true,
|
|
79
|
+
uid: '123',
|
|
80
|
+
getIdToken: vi.fn(() => Promise.resolve('mock-token'))
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByText('Logged in as test@company.com')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { auth, db } from './firebase';
|
|
3
|
+
import { onAuthStateChanged, signOut as firebaseSignOut, updateProfile, sendEmailVerification } from 'firebase/auth';
|
|
4
|
+
import type { User } from 'firebase/auth';
|
|
5
|
+
import { doc, setDoc, onSnapshot, updateDoc } from 'firebase/firestore';
|
|
6
|
+
import { useConfig } from './ConfigContext';
|
|
7
|
+
interface AuthContextType {
|
|
8
|
+
user: User | null;
|
|
9
|
+
userRole: string | null;
|
|
10
|
+
loading: boolean;
|
|
11
|
+
signOut: () => Promise<void>;
|
|
12
|
+
registerUser: (email: string, password: string, firstName: string, lastName: string) => Promise<void>;
|
|
13
|
+
userInactive: boolean;
|
|
14
|
+
roleResolved: boolean;
|
|
15
|
+
refreshUser: () => Promise<void>;
|
|
16
|
+
activeOrg?: any;
|
|
17
|
+
activeWorkspace?: any;
|
|
18
|
+
checkOrg?: any;
|
|
19
|
+
workspaceAccess?: any;
|
|
20
|
+
userWorkspaces?: any;
|
|
21
|
+
switchWorkspace?: any;
|
|
22
|
+
createWorkspace?: any;
|
|
23
|
+
createOrg?: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const AuthContext = createContext<AuthContextType>({
|
|
27
|
+
user: null,
|
|
28
|
+
userRole: null,
|
|
29
|
+
loading: true,
|
|
30
|
+
signOut: async () => {},
|
|
31
|
+
registerUser: async () => {},
|
|
32
|
+
refreshUser: async () => {},
|
|
33
|
+
userInactive: false,
|
|
34
|
+
roleResolved: false,
|
|
35
|
+
activeOrg: null,
|
|
36
|
+
activeWorkspace: null,
|
|
37
|
+
checkOrg: async () => {},
|
|
38
|
+
workspaceAccess: {},
|
|
39
|
+
userWorkspaces: [],
|
|
40
|
+
switchWorkspace: async () => {},
|
|
41
|
+
createWorkspace: async () => {},
|
|
42
|
+
createOrg: async () => {},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
46
|
+
const osConfig = useConfig();
|
|
47
|
+
const [user, setUser] = useState<User | null>(null);
|
|
48
|
+
const [userRole, setUserRole] = useState<string | null>(null);
|
|
49
|
+
const [userInactive, setUserInactive] = useState<boolean>(false);
|
|
50
|
+
const [loading, setLoading] = useState(true);
|
|
51
|
+
const [roleResolved, setRoleResolved] = useState(false);
|
|
52
|
+
|
|
53
|
+
const initialRoleRef = React.useRef<string | null>(null);
|
|
54
|
+
const currentUidRef = React.useRef<string | null>(null);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
let profileUnsub: (() => void) | undefined;
|
|
58
|
+
|
|
59
|
+
if (!auth) {
|
|
60
|
+
setLoading(false);
|
|
61
|
+
setRoleResolved(true);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const unsub = onAuthStateChanged(auth, async (u) => {
|
|
66
|
+
// If the user hasn't changed, don't re-run the entire resolution logic
|
|
67
|
+
if (u?.uid === currentUidRef.current && roleResolved) {
|
|
68
|
+
setUser(u); // Update user object just in case (token etc)
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
currentUidRef.current = u?.uid || null;
|
|
73
|
+
|
|
74
|
+
if (u) {
|
|
75
|
+
console.log("[AuthContext] 1. onAuthStateChanged fired for user:", u.uid);
|
|
76
|
+
setUser(u);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const userRef = doc(db, 'user_profiles', u.uid);
|
|
80
|
+
await setDoc(userRef, {
|
|
81
|
+
uid: u.uid,
|
|
82
|
+
email: u.email,
|
|
83
|
+
displayName: u.displayName || '',
|
|
84
|
+
lastLoginAt: new Date().toISOString()
|
|
85
|
+
}, { merge: true });
|
|
86
|
+
|
|
87
|
+
console.log("[AuthContext] 2. Successfully synced user data into user_profiles.");
|
|
88
|
+
|
|
89
|
+
if (profileUnsub) profileUnsub();
|
|
90
|
+
profileUnsub = onSnapshot(userRef, (snap) => {
|
|
91
|
+
if (snap.exists()) {
|
|
92
|
+
const data = snap.data();
|
|
93
|
+
let currentRole = data.role || 'user';
|
|
94
|
+
|
|
95
|
+
// Auto-assignment of admin role if email is in the admin list
|
|
96
|
+
// Library mode: use props. Standalone mode: use env var.
|
|
97
|
+
let adminEmailsRaw = '';
|
|
98
|
+
try { const env = import.meta.env; adminEmailsRaw = (env as any)?.['VITE_ADMIN_EMAILS'] || ''; } catch {}
|
|
99
|
+
const adminEmailsList = osConfig.adminEmails || (adminEmailsRaw ? adminEmailsRaw.split(',').map((e: string) => e.trim().toLowerCase()).filter(Boolean) : []);
|
|
100
|
+
if (adminEmailsList.length > 0 && !data.role) {
|
|
101
|
+
const adminEmails = adminEmailsList.map((e: string) => e.toLowerCase());
|
|
102
|
+
const isConfiguredAdmin = adminEmails.some((adminEmail: string) => u.email?.toLowerCase() === adminEmail);
|
|
103
|
+
|
|
104
|
+
if (isConfiguredAdmin) {
|
|
105
|
+
console.log(`[AuthContext] Auto-assigning admin role to ${u.email}...`);
|
|
106
|
+
currentRole = 'admin'; // Sync update locally to prevent redirect flicker
|
|
107
|
+
updateDoc(userRef, { role: 'admin' }).catch(err => {
|
|
108
|
+
console.error("[AuthContext] Error auto-setting admin role:", err);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (initialRoleRef.current === null) {
|
|
114
|
+
initialRoleRef.current = currentRole;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setUserRole(currentRole);
|
|
118
|
+
setUserInactive(data.inactive === true);
|
|
119
|
+
}
|
|
120
|
+
setRoleResolved(true);
|
|
121
|
+
setLoading(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("[AuthContext] ERROR in auth resolution:", error);
|
|
126
|
+
setRoleResolved(true);
|
|
127
|
+
setLoading(false);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
console.log("[AuthContext] No user currently signed in.");
|
|
131
|
+
setUser(null);
|
|
132
|
+
setUserRole(null);
|
|
133
|
+
setUserInactive(false);
|
|
134
|
+
setRoleResolved(true);
|
|
135
|
+
setLoading(false);
|
|
136
|
+
initialRoleRef.current = null;
|
|
137
|
+
if (profileUnsub) profileUnsub();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return () => {
|
|
141
|
+
unsub();
|
|
142
|
+
if (profileUnsub) profileUnsub();
|
|
143
|
+
};
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const registerUser = async (email: string, password: string, firstName: string, lastName: string) => {
|
|
147
|
+
const { createUserWithEmailAndPassword } = await import('firebase/auth');
|
|
148
|
+
const { user: newUser } = await createUserWithEmailAndPassword(auth, email, password);
|
|
149
|
+
const fullName = `${firstName} ${lastName}`.trim();
|
|
150
|
+
await updateProfile(newUser, { displayName: fullName });
|
|
151
|
+
|
|
152
|
+
// Explicitly seed the user profile upon registration
|
|
153
|
+
try {
|
|
154
|
+
// Force sync the Auth token with Firestore to prevent 'Insufficient Permissions' race condition
|
|
155
|
+
await newUser.getIdToken(true);
|
|
156
|
+
|
|
157
|
+
const userRef = doc(db, 'user_profiles', newUser.uid);
|
|
158
|
+
await setDoc(userRef, {
|
|
159
|
+
uid: newUser.uid,
|
|
160
|
+
email: newUser.email,
|
|
161
|
+
displayName: fullName,
|
|
162
|
+
createdAt: new Date().toISOString()
|
|
163
|
+
}, { merge: true });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.warn("Could not write public profile on registration.", e);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await sendEmailVerification(newUser);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const signOut = async () => {
|
|
172
|
+
await firebaseSignOut(auth);
|
|
173
|
+
setUser(null);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const refreshUser = async () => {
|
|
177
|
+
if (auth.currentUser) {
|
|
178
|
+
await auth.currentUser.reload();
|
|
179
|
+
setUser(Object.assign(Object.create(Object.getPrototypeOf(auth.currentUser)), auth.currentUser));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<AuthContext.Provider value={{ user, userRole, loading, signOut, registerUser, refreshUser, userInactive, roleResolved }}>
|
|
185
|
+
{children}
|
|
186
|
+
</AuthContext.Provider>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
191
|
+
export const useAuth = () => useContext(AuthContext);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
// ── Library configuration context ──────────────────────────────────────────
|
|
4
|
+
// This allows <FirebaseOS /> to pass props (firebaseConfig, adminEmails, etc.)
|
|
5
|
+
// down to internal components without modifying their signatures.
|
|
6
|
+
// When running as a standalone app (npm run dev), this context is empty
|
|
7
|
+
// and components fall back to import.meta.env as before.
|
|
8
|
+
|
|
9
|
+
export interface FirebaseOSConfig {
|
|
10
|
+
firebaseConfig?: {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
authDomain: string;
|
|
13
|
+
projectId: string;
|
|
14
|
+
storageBucket: string;
|
|
15
|
+
messagingSenderId: string;
|
|
16
|
+
appId: string;
|
|
17
|
+
};
|
|
18
|
+
adminEmails?: string[];
|
|
19
|
+
basename?: string;
|
|
20
|
+
onAuthChange?: (user: any) => void;
|
|
21
|
+
themeConfig?: any;
|
|
22
|
+
components?: {
|
|
23
|
+
Home?: React.ComponentType<any>;
|
|
24
|
+
TemplateBoard?: React.ComponentType<any>;
|
|
25
|
+
TemplateTable?: React.ComponentType<any>;
|
|
26
|
+
TemplateCalendar?: React.ComponentType<any>;
|
|
27
|
+
TemplatePopupForm?: React.ComponentType<any>;
|
|
28
|
+
TemplateInlineForm?: React.ComponentType<any>;
|
|
29
|
+
TemplatePages?: React.ComponentType<any>;
|
|
30
|
+
TemplateStorage?: React.ComponentType<any>;
|
|
31
|
+
TemplateNone?: React.ComponentType<any>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ConfigContext = createContext<FirebaseOSConfig>({});
|
|
36
|
+
|
|
37
|
+
export function ConfigProvider({ config, children }: { config: FirebaseOSConfig; children: React.ReactNode }) {
|
|
38
|
+
return (
|
|
39
|
+
<ConfigContext.Provider value={config}>
|
|
40
|
+
{children}
|
|
41
|
+
</ConfigContext.Provider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const useConfig = () => useContext(ConfigContext);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { themeConfig } from '../configs/theme.config';
|
|
3
|
+
import { doc, onSnapshot } from 'firebase/firestore';
|
|
4
|
+
import { db } from './firebase';
|
|
5
|
+
|
|
6
|
+
function deepMerge(target: any, source: any): any {
|
|
7
|
+
if (typeof source !== 'object' || source === null) return source;
|
|
8
|
+
const merged = { ...target };
|
|
9
|
+
for (const key of Object.keys(source)) {
|
|
10
|
+
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
|
|
11
|
+
merged[key] = deepMerge(target[key], source[key]);
|
|
12
|
+
} else {
|
|
13
|
+
merged[key] = source[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return merged;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Theme = 'light' | 'dark';
|
|
20
|
+
|
|
21
|
+
interface ThemeContextType {
|
|
22
|
+
theme: Theme;
|
|
23
|
+
setTheme: (theme: Theme) => void;
|
|
24
|
+
toggleTheme: () => void;
|
|
25
|
+
isSystem: boolean;
|
|
26
|
+
setIsSystem: (is: boolean) => void;
|
|
27
|
+
config: typeof themeConfig;
|
|
28
|
+
activeConfig: any;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
export function ThemeProvider({ children, scopeSelector }: { children: React.ReactNode, scopeSelector?: string }) {
|
|
34
|
+
const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => {
|
|
35
|
+
const saved = localStorage.getItem('theme-mode') as 'light' | 'dark' | 'system' | null;
|
|
36
|
+
return saved || themeConfig.defaultTheme;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const [currentTheme, setCurrentTheme] = useState<Theme>('dark');
|
|
40
|
+
const [activeConfig, setActiveConfig] = useState(() => {
|
|
41
|
+
try {
|
|
42
|
+
const cached = localStorage.getItem('theme_config_cache');
|
|
43
|
+
if (cached) return JSON.parse(cached);
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
return themeConfig;
|
|
46
|
+
});
|
|
47
|
+
const [isReady, setIsReady] = useState(() => !!localStorage.getItem('theme_config_cache'));
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!db) {
|
|
51
|
+
setActiveConfig(themeConfig);
|
|
52
|
+
setIsReady(true);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const unsub = onSnapshot(doc(db, 'sys_configs', 'theme'), (snap) => {
|
|
56
|
+
if (snap.exists()) {
|
|
57
|
+
const data = snap.data();
|
|
58
|
+
delete data.fontStyles;
|
|
59
|
+
delete data.fontCustomization;
|
|
60
|
+
const merged = deepMerge(themeConfig, data);
|
|
61
|
+
setActiveConfig(merged);
|
|
62
|
+
localStorage.setItem('theme_config_cache', JSON.stringify(merged));
|
|
63
|
+
|
|
64
|
+
const lastSeenDefaultTheme = localStorage.getItem('last-seen-default-theme');
|
|
65
|
+
if (merged.defaultTheme && merged.defaultTheme !== lastSeenDefaultTheme) {
|
|
66
|
+
localStorage.setItem('last-seen-default-theme', merged.defaultTheme);
|
|
67
|
+
setThemeMode(merged.defaultTheme);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
setActiveConfig(themeConfig);
|
|
71
|
+
localStorage.setItem('theme_config_cache', JSON.stringify(themeConfig));
|
|
72
|
+
}
|
|
73
|
+
setIsReady(true);
|
|
74
|
+
});
|
|
75
|
+
return () => unsub();
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const handleSystemTheme = (e: MediaQueryListEvent | MediaQueryList) => {
|
|
80
|
+
if (themeMode === 'system') {
|
|
81
|
+
setCurrentTheme(e.matches ? 'light' : 'dark');
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const query = window.matchMedia('(prefers-color-scheme: light)');
|
|
86
|
+
|
|
87
|
+
if (themeMode === 'system') {
|
|
88
|
+
setCurrentTheme(query.matches ? 'light' : 'dark');
|
|
89
|
+
query.addEventListener('change', handleSystemTheme);
|
|
90
|
+
} else {
|
|
91
|
+
setCurrentTheme(themeMode);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return () => query.removeEventListener('change', handleSystemTheme);
|
|
95
|
+
}, [themeMode]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
// In library mode, scope to the .firebase-os container (via scopeSelector).
|
|
99
|
+
// In standalone dev mode, fall back to documentElement.
|
|
100
|
+
const root = (scopeSelector ? document.querySelector(scopeSelector) : document.documentElement) as HTMLElement || document.documentElement;
|
|
101
|
+
const colors = currentTheme === 'dark' ? activeConfig.colors.dark : activeConfig.colors.light;
|
|
102
|
+
|
|
103
|
+
// Core Colors
|
|
104
|
+
root.style.setProperty('--bg-color', colors.background);
|
|
105
|
+
root.style.setProperty('--bg-secondary-color', colors.backgroundSecondary || colors.background);
|
|
106
|
+
root.style.setProperty('--fg-color', colors.foreground);
|
|
107
|
+
root.style.setProperty('--accent-color', colors.accent);
|
|
108
|
+
|
|
109
|
+
// Panels & Glass
|
|
110
|
+
root.style.setProperty('--panel-bg', colors.panelBg);
|
|
111
|
+
root.style.setProperty('--panel-border', colors.panelBorder);
|
|
112
|
+
|
|
113
|
+
// Gradients
|
|
114
|
+
const buildGradient = (arr: string[] | undefined, fallback?: string) => {
|
|
115
|
+
if (!arr || arr.length === 0) return fallback || 'transparent';
|
|
116
|
+
if (arr.length === 1) return arr[0];
|
|
117
|
+
const stops = arr.map((c, i) => `${c} ${Math.round((i / (arr.length - 1)) * 100)}%`).join(', ');
|
|
118
|
+
return `linear-gradient(135deg, ${stops})`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const defaultBtnGrad = currentTheme === 'dark'
|
|
122
|
+
? 'linear-gradient(135deg, #6d28d9 0%, #a78bfa 100%)'
|
|
123
|
+
: 'linear-gradient(135deg, #5b21b6 0%, #a78bfa 100%)';
|
|
124
|
+
|
|
125
|
+
root.style.setProperty('--button-bg', buildGradient(colors.buttonGradient, defaultBtnGrad));
|
|
126
|
+
root.style.setProperty('--button-fg', colors.buttonText || '#ffffff');
|
|
127
|
+
root.style.setProperty('--button-hover-bg', buildGradient(colors.buttonHoverGradient || colors.buttonGradient, defaultBtnGrad));
|
|
128
|
+
root.style.setProperty('--text-gradient', buildGradient(colors.textGradient, `linear-gradient(135deg, ${colors.foreground} 0%, ${colors.accent} 100%)`));
|
|
129
|
+
root.style.setProperty('--glow-color', colors.glowColor || 'rgba(124, 58, 237, 0.3)');
|
|
130
|
+
root.style.setProperty('--notification-icon-color', colors.notificationIconColor || colors.accent);
|
|
131
|
+
|
|
132
|
+
// Secondary Buttons
|
|
133
|
+
const secBg = colors.secondaryButton?.bg || (currentTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)');
|
|
134
|
+
const secBorder = colors.secondaryButton?.border || (currentTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.06)');
|
|
135
|
+
const secText = colors.secondaryButton?.text || colors.foreground;
|
|
136
|
+
|
|
137
|
+
root.style.setProperty('--btn-sec-bg', secBg);
|
|
138
|
+
root.style.setProperty('--btn-sec-border', secBorder);
|
|
139
|
+
root.style.setProperty('--btn-sec-text', secText);
|
|
140
|
+
|
|
141
|
+
// Text Selection
|
|
142
|
+
root.style.setProperty('--selection-bg', colors.selectionBg || 'color-mix(in srgb, var(--accent-color) 40%, transparent)');
|
|
143
|
+
root.style.setProperty('--selection-text', colors.selectionText || '#ffffff');
|
|
144
|
+
|
|
145
|
+
root.setAttribute('data-theme', currentTheme);
|
|
146
|
+
localStorage.setItem('theme-mode', themeMode);
|
|
147
|
+
|
|
148
|
+
// Dynamic Corner Radii
|
|
149
|
+
if (activeConfig.cornerRounding === 'custom' && activeConfig.cornerRadii) {
|
|
150
|
+
root.style.setProperty('--btn-radius', `${parseFloat(String(activeConfig.cornerRadii.buttonRadius)) || 0}rem`);
|
|
151
|
+
root.style.setProperty('--card-radius', `${parseFloat(String(activeConfig.cornerRadii.cardRadius)) || 0}rem`);
|
|
152
|
+
root.style.setProperty('--input-radius', `${parseFloat(String(activeConfig.cornerRadii.inputRadius)) || 0}rem`);
|
|
153
|
+
root.style.setProperty('--modal-radius', `${parseFloat(String(activeConfig.cornerRadii.modalRadius)) || 0}rem`);
|
|
154
|
+
} else {
|
|
155
|
+
root.style.setProperty('--btn-radius', `0.75rem`);
|
|
156
|
+
root.style.setProperty('--card-radius', `1.5rem`);
|
|
157
|
+
root.style.setProperty('--input-radius', `0.75rem`);
|
|
158
|
+
root.style.setProperty('--modal-radius', `1.5rem`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Dynamic Font Customization
|
|
162
|
+
const applyFonts = (familyName: string) => {
|
|
163
|
+
const gFonts: Record<string, string> = {
|
|
164
|
+
'Inter': '"Inter", sans-serif',
|
|
165
|
+
'Outfit': '"Outfit", sans-serif',
|
|
166
|
+
'Roboto': '"Roboto", sans-serif',
|
|
167
|
+
'Space Grotesk': '"Space Grotesk", sans-serif',
|
|
168
|
+
'Plus Jakarta Sans': '"Plus Jakarta Sans", sans-serif',
|
|
169
|
+
'System UI': 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
root.style.setProperty('--font-family', gFonts[familyName] || gFonts['Inter']);
|
|
173
|
+
|
|
174
|
+
let fontLink = document.getElementById('dynamic-google-font') as HTMLLinkElement;
|
|
175
|
+
if (familyName !== 'System UI') {
|
|
176
|
+
if (!fontLink) {
|
|
177
|
+
fontLink = document.createElement('link');
|
|
178
|
+
fontLink.id = 'dynamic-google-font';
|
|
179
|
+
fontLink.rel = 'stylesheet';
|
|
180
|
+
document.head.appendChild(fontLink);
|
|
181
|
+
}
|
|
182
|
+
fontLink.href = `https://fonts.googleapis.com/css2?family=${familyName.replace(/ /g, '+')}:wght@400;500;600;700;800;900&display=swap`;
|
|
183
|
+
} else if (fontLink) {
|
|
184
|
+
fontLink.remove();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
root.style.fontSize = `16px`;
|
|
188
|
+
root.style.setProperty('--weight-h1', '800');
|
|
189
|
+
root.style.setProperty('--weight-h2', '700');
|
|
190
|
+
root.style.setProperty('--weight-h3', '700');
|
|
191
|
+
root.style.setProperty('--leading-normal', '1.5');
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
applyFonts(activeConfig.fontFamily || 'Inter');
|
|
195
|
+
|
|
196
|
+
// Favicon Link Update
|
|
197
|
+
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
|
198
|
+
if (!favicon) {
|
|
199
|
+
favicon = document.createElement('link');
|
|
200
|
+
favicon.rel = 'icon';
|
|
201
|
+
document.head.appendChild(favicon);
|
|
202
|
+
}
|
|
203
|
+
if (activeConfig.faviconUrl) {
|
|
204
|
+
favicon.href = activeConfig.faviconUrl;
|
|
205
|
+
}
|
|
206
|
+
}, [currentTheme, themeMode, activeConfig]);
|
|
207
|
+
|
|
208
|
+
const toggleTheme = () => {
|
|
209
|
+
setThemeMode(prev => prev === 'light' ? 'dark' : 'light');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const setTheme = (t: Theme) => setThemeMode(t);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<ThemeContext.Provider value={{
|
|
216
|
+
theme: currentTheme,
|
|
217
|
+
setTheme,
|
|
218
|
+
toggleTheme,
|
|
219
|
+
isSystem: themeMode === 'system',
|
|
220
|
+
setIsSystem: (is: boolean) => setThemeMode(is ? 'system' : currentTheme),
|
|
221
|
+
config: activeConfig,
|
|
222
|
+
activeConfig
|
|
223
|
+
}}>
|
|
224
|
+
{isReady ? children : null}
|
|
225
|
+
</ThemeContext.Provider>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const useTheme = () => {
|
|
230
|
+
const context = useContext(ThemeContext);
|
|
231
|
+
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
|
232
|
+
return context;
|
|
233
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { initializeApp, getApp, getApps } from "firebase/app";
|
|
2
|
+
import { getAuth } from "firebase/auth";
|
|
3
|
+
import { getFirestore } from "firebase/firestore";
|
|
4
|
+
import { getStorage } from "firebase/storage";
|
|
5
|
+
|
|
6
|
+
let app: any = null;
|
|
7
|
+
let auth: any = null;
|
|
8
|
+
let db: any = null;
|
|
9
|
+
let storage: any = null;
|
|
10
|
+
|
|
11
|
+
// ── Public API for library mode ──────────────────────────────────────────
|
|
12
|
+
// Called by <FirebaseOS firebaseConfig={...} /> to initialize Firebase
|
|
13
|
+
// from props instead of environment variables.
|
|
14
|
+
export function initializeFirebase(config: Record<string, string>) {
|
|
15
|
+
if (getApps().length) {
|
|
16
|
+
app = getApp();
|
|
17
|
+
} else {
|
|
18
|
+
app = initializeApp(config);
|
|
19
|
+
}
|
|
20
|
+
auth = getAuth(app);
|
|
21
|
+
db = getFirestore(app);
|
|
22
|
+
storage = getStorage(app);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Read env safely ──────────────────────────────────────────────────────
|
|
26
|
+
// Use a dynamic key lookup so Vite does NOT bake env values at build time.
|
|
27
|
+
// This is critical for library mode — we must NOT leak the developer's keys
|
|
28
|
+
// into the published bundle.
|
|
29
|
+
function readEnv(key: string): string {
|
|
30
|
+
try {
|
|
31
|
+
const env = import.meta.env;
|
|
32
|
+
return (env as any)?.[key] || '';
|
|
33
|
+
} catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Standalone mode (npm run dev) ────────────────────────────────────────
|
|
39
|
+
// Auto-init from VITE_ env variables if available.
|
|
40
|
+
// This keeps the original app working exactly as before.
|
|
41
|
+
const envKeys = [
|
|
42
|
+
'VITE_FIREBASE_API_KEY',
|
|
43
|
+
'VITE_FIREBASE_AUTH_DOMAIN',
|
|
44
|
+
'VITE_FIREBASE_PROJECT_ID',
|
|
45
|
+
'VITE_FIREBASE_STORAGE_BUCKET',
|
|
46
|
+
'VITE_FIREBASE_MESSAGING_SENDER_ID',
|
|
47
|
+
'VITE_FIREBASE_APP_ID',
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
const keyMap: Record<string, string> = {
|
|
51
|
+
VITE_FIREBASE_API_KEY: 'apiKey',
|
|
52
|
+
VITE_FIREBASE_AUTH_DOMAIN: 'authDomain',
|
|
53
|
+
VITE_FIREBASE_PROJECT_ID: 'projectId',
|
|
54
|
+
VITE_FIREBASE_STORAGE_BUCKET: 'storageBucket',
|
|
55
|
+
VITE_FIREBASE_MESSAGING_SENDER_ID: 'messagingSenderId',
|
|
56
|
+
VITE_FIREBASE_APP_ID: 'appId',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function getConfigFromEnv(): Record<string, string> | null {
|
|
60
|
+
const config: Record<string, string> = {};
|
|
61
|
+
for (const envKey of envKeys) {
|
|
62
|
+
const val = readEnv(envKey);
|
|
63
|
+
if (!val) return null; // Missing key — not all set
|
|
64
|
+
config[keyMap[envKey]] = val;
|
|
65
|
+
}
|
|
66
|
+
return config;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getConfigFromLocalStorage(): Record<string, string> | null {
|
|
70
|
+
try {
|
|
71
|
+
const saved = localStorage.getItem('firebase_config');
|
|
72
|
+
if (saved) {
|
|
73
|
+
const config = JSON.parse(saved);
|
|
74
|
+
if (config.apiKey) return config;
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Try env first, then localStorage
|
|
81
|
+
const envConfig = getConfigFromEnv();
|
|
82
|
+
if (envConfig && !getApps().length) {
|
|
83
|
+
initializeFirebase(envConfig);
|
|
84
|
+
} else {
|
|
85
|
+
const lsConfig = getConfigFromLocalStorage();
|
|
86
|
+
if (lsConfig && !getApps().length) {
|
|
87
|
+
initializeFirebase(lsConfig);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { auth, db, storage };
|