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
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 };