firebase-os 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/FirebaseOS.d.ts +15 -0
  2. package/dist/firebase-os.cjs.js +5 -20
  3. package/dist/firebase-os.es.js +95 -90
  4. package/dist/index.d.ts +3 -0
  5. package/dist/lib/ConfigContext.d.ts +12 -0
  6. package/package.json +3 -2
  7. package/scripts/postinstall.js +86 -15
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +81 -0
  11. package/src/assets/hero.png +0 -0
  12. package/src/assets/react.svg +1 -0
  13. package/src/assets/vite.svg +1 -0
  14. package/src/components/AdminNotifications.test.tsx +98 -0
  15. package/src/components/AdminNotifications.tsx +194 -0
  16. package/src/components/Button.test.tsx +22 -0
  17. package/src/components/Button.tsx +53 -0
  18. package/src/components/ConfirmModal.test.tsx +98 -0
  19. package/src/components/ConfirmModal.tsx +73 -0
  20. package/src/components/ContactPopup.test.tsx +98 -0
  21. package/src/components/ContactPopup.tsx +437 -0
  22. package/src/components/CustomSelect.test.tsx +47 -0
  23. package/src/components/CustomSelect.tsx +89 -0
  24. package/src/components/DashboardNav.test.tsx +98 -0
  25. package/src/components/DashboardNav.tsx +281 -0
  26. package/src/components/Input.test.tsx +33 -0
  27. package/src/components/Input.tsx +61 -0
  28. package/src/components/JsonEditor.tsx +579 -0
  29. package/src/components/Navbar.test.tsx +98 -0
  30. package/src/components/Navbar.tsx +563 -0
  31. package/src/configs/forms/contactForm.config.ts +15 -0
  32. package/src/configs/forms/index.ts +29 -0
  33. package/src/configs/forms/pubForm.config.ts +11 -0
  34. package/src/configs/forms/supportForm.config.ts +14 -0
  35. package/src/configs/forms/userForm.config.ts +11 -0
  36. package/src/configs/pages/admin.config.ts +29 -0
  37. package/src/configs/pages/contact.config.ts +6 -0
  38. package/src/configs/pages/home.config.ts +18 -0
  39. package/src/configs/pages/mem.config.ts +2 -0
  40. package/src/configs/pages/menuOrders.config.ts +11 -0
  41. package/src/configs/pages/pub.config.ts +11 -0
  42. package/src/configs/pages/shared.config.ts +29 -0
  43. package/src/configs/pages/support.config.ts +7 -0
  44. package/src/configs/pages/tabOrders.config.ts +33 -0
  45. package/src/configs/pages/user.config.ts +29 -0
  46. package/src/configs/theme.config.ts +93 -0
  47. package/src/index.css +403 -0
  48. package/src/index.ts +22 -0
  49. package/src/lib/AuthContext.test.tsx +88 -0
  50. package/src/lib/AuthContext.tsx +191 -0
  51. package/src/lib/ConfigContext.tsx +45 -0
  52. package/src/lib/ThemeContext.tsx +233 -0
  53. package/src/lib/firebase.ts +91 -0
  54. package/src/main.tsx +22 -0
  55. package/src/microcomponents/AdminExampleContent.tsx +44 -0
  56. package/src/microcomponents/PrivateExampleContent.tsx +39 -0
  57. package/src/microcomponents/Public.tsx +126 -0
  58. package/src/microcomponents/SharedExampleContent.tsx +53 -0
  59. package/src/pages/Dashboard.test.tsx +98 -0
  60. package/src/pages/Dashboard.tsx +60 -0
  61. package/src/pages/DynamicPage.tsx +237 -0
  62. package/src/pages/FormsAdmin.test.tsx +98 -0
  63. package/src/pages/FormsAdmin.tsx +459 -0
  64. package/src/pages/Home.test.tsx +98 -0
  65. package/src/pages/Home.tsx +144 -0
  66. package/src/pages/Login.test.tsx +98 -0
  67. package/src/pages/Login.tsx +108 -0
  68. package/src/pages/PagesAdmin.test.tsx +98 -0
  69. package/src/pages/PagesAdmin.tsx +1022 -0
  70. package/src/pages/Profile.test.tsx +98 -0
  71. package/src/pages/Profile.tsx +319 -0
  72. package/src/pages/Register.test.tsx +98 -0
  73. package/src/pages/Register.tsx +116 -0
  74. package/src/pages/Requests.test.tsx +95 -0
  75. package/src/pages/Requests.tsx +422 -0
  76. package/src/pages/ResetPassword.test.tsx +98 -0
  77. package/src/pages/ResetPassword.tsx +92 -0
  78. package/src/pages/Settings.test.tsx +98 -0
  79. package/src/pages/Settings.tsx +393 -0
  80. package/src/pages/Setup.tsx +407 -0
  81. package/src/pages/StorageAdmin.test.tsx +150 -0
  82. package/src/pages/StorageAdmin.tsx +769 -0
  83. package/src/pages/Submissions.test.tsx +95 -0
  84. package/src/pages/Submissions.tsx +378 -0
  85. package/src/pages/Templates.test.tsx +98 -0
  86. package/src/pages/Templates.tsx +103 -0
  87. package/src/pages/ThemeAdmin.test.tsx +144 -0
  88. package/src/pages/ThemeAdmin.tsx +1000 -0
  89. package/src/pages/Users.test.tsx +95 -0
  90. package/src/pages/Users.tsx +334 -0
  91. package/src/pages/Verify.test.tsx +98 -0
  92. package/src/pages/Verify.tsx +95 -0
  93. package/src/prompts/index.ts +13 -0
  94. package/src/prompts/pages/publicPage.ts +44 -0
  95. package/src/prompts/sharedConstants.ts +12 -0
  96. package/src/prompts/tabs/board/adminboard.ts +32 -0
  97. package/src/prompts/tabs/board/privateboard.ts +36 -0
  98. package/src/prompts/tabs/board/publicboard.ts +36 -0
  99. package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
  100. package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
  101. package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
  102. package/src/prompts/tabs/crud/admin.ts +54 -0
  103. package/src/prompts/tabs/crud/private.ts +55 -0
  104. package/src/prompts/tabs/crud/shared.ts +53 -0
  105. package/src/prompts/tabs/table/admintable.ts +32 -0
  106. package/src/prompts/tabs/table/privatetable.ts +36 -0
  107. package/src/prompts/tabs/table/publictable.ts +36 -0
  108. package/src/setupTests.ts +1 -0
  109. package/src/templates/AdminPageTemplate.tsx +678 -0
  110. package/src/templates/PrivatePageTemplate.tsx +594 -0
  111. package/src/templates/PublicPageTemplate.tsx +92 -0
  112. package/src/templates/SharedPageTemplate.tsx +551 -0
  113. package/src/templates/TemplateBoard.test.tsx +106 -0
  114. package/src/templates/TemplateBoard.tsx +642 -0
  115. package/src/templates/TemplateCalendar.test.tsx +106 -0
  116. package/src/templates/TemplateCalendar.tsx +848 -0
  117. package/src/templates/TemplateConfirmation.test.tsx +106 -0
  118. package/src/templates/TemplateConfirmation.tsx +145 -0
  119. package/src/templates/TemplateInlineForm.test.tsx +106 -0
  120. package/src/templates/TemplateInlineForm.tsx +129 -0
  121. package/src/templates/TemplatePopupForm.test.tsx +106 -0
  122. package/src/templates/TemplatePopupForm.tsx +174 -0
  123. package/src/templates/TemplateTable.test.tsx +106 -0
  124. package/src/templates/TemplateTable.tsx +675 -0
@@ -0,0 +1,1000 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { Save, Loader2, RotateCcw, Copy, Clipboard, Check } from 'lucide-react';
4
+ import { db } from '../lib/firebase';
5
+ import { doc, setDoc } from 'firebase/firestore';
6
+ import { Link } from 'react-router-dom';
7
+ import { DashboardNav } from '../components/DashboardNav';
8
+ import { Button } from '../components/Button';
9
+ import { useTheme } from '../lib/ThemeContext';
10
+
11
+ const explanations: Record<string, string> = {
12
+ appName: "The main name of your application shown in headers and titles.",
13
+ defaultTheme: "Choose how the app starts immediately on load.",
14
+ showThemeToggle: "Allow users to manually toggle dark mode on/off.",
15
+ logoUrl: "Direct URL to your app's main logo. Need to upload one?",
16
+ faviconUrl: "Direct URL to your app's favicon. Need to upload one?",
17
+ background: "Main background canvas color under all content.",
18
+ foreground: "Primary typography color used across the app.",
19
+ accent: "The primary brand color for highlights, buttons, and links.",
20
+ panelBg: "Background color of glass cards and panels.",
21
+ panelBorder: "Borders of panels, inputs, and dropdowns.",
22
+ glowColor: "The global glow shadow applied to borders of focused inputs.",
23
+ buttonHoverGradient: "Start and end hex for primary button hover effects.",
24
+ textGradient: "Three tone gradient applied to large headers.",
25
+ buttonRadius: "Border radius for standard buttons.",
26
+ cardRadius: "Curvature of main content panels.",
27
+ inputRadius: "Rounding applied to form inputs.",
28
+ modalRadius: "Global popup containers and dashboard content cards.",
29
+ blurIntensity: "Glassmorphism backdrop-blur amounts.",
30
+ notificationIconColor: "Color applied to notification badging.",
31
+
32
+ fontFamily: "Choose the main typography of your brand.",
33
+
34
+ home: "Complete customization of the landing page settings.",
35
+ title: "Main heading displayed on the Landing Page or Form.",
36
+ subtitle: "Smaller colored overline text above the main title.",
37
+ text: "The descriptive paragraph text under the hero headers or buttons.",
38
+ primary: "Main call-to-action block settings.",
39
+ secondary: "Secondary ghost button block settings.",
40
+ sections: "Toggle visibility of landing page sectors.",
41
+ contactForm: "Customize the form wrapper and its labels.",
42
+ nameLabel: "Placeholder/Label for the Name input.",
43
+ emailLabel: "Placeholder/Label for the Email input.",
44
+ messageLabel: "Placeholder/Label for the Message textarea.",
45
+ submitText: "Text for the form submit button.",
46
+ action: "Where the button routes the user.",
47
+ form: "Which public form to open. Note: Form submissions will securely map to the Submissions tab in your admin dashboard.",
48
+ url: "External http link or internal /route destination."
49
+ };
50
+
51
+ const colorPresets = [
52
+ { name: 'Purple Standard', preview: ['#2e1065', '#cdc1ff'], dark: { buttonText: '#ffffff', background: '#07070f', backgroundSecondary: '#0a0a18', accent: '#a78bfa', gradient: ['#6d28d9', '#a78bfa'], text: ['#f0eeff', '#c4b5fd', '#a78bfa'] }, light: { buttonText: '#ffffff', background: '#fcfaff', backgroundSecondary: '#f7f3ff', accent: '#7c3aed', gradient: ['#5b21b6', '#a78bfa'], text: ['#2e1065', '#6d28d9', '#cdc1ff'] } },
53
+ { name: 'Royal Navy', preview: ['#1e3a8a', '#60a5fa'], dark: { buttonText: '#ffffff', background: '#050b14', backgroundSecondary: '#0a192f', accent: '#60a5fa', gradient: ['#1d4ed8', '#60a5fa'], text: ['#dbeafe', '#93c5fd', '#60a5fa'] }, light: { buttonText: '#ffffff', background: '#f8fafc', backgroundSecondary: '#f1f5f9', accent: '#2563eb', gradient: ['#1e3a8a', '#60a5fa'], text: ['#1e3a8a', '#2563eb', '#60a5fa'] } },
54
+ { name: 'Emerald Green', preview: ['#064e3b', '#10b981'], dark: { buttonText: '#ffffff', background: '#050806', backgroundSecondary: '#0a2218', accent: '#34d399', gradient: ['#047857', '#34d399'], text: ['#d1fae5', '#6ee7b7', '#34d399'] }, light: { buttonText: '#ffffff', background: '#f6fbf8', backgroundSecondary: '#eff7f2', accent: '#059669', gradient: ['#064e3b', '#10b981'], text: ['#064e3b', '#059669', '#10b981'] } },
55
+ { name: 'Gold', preview: ['#854d0e', '#eab308'], dark: { buttonText: '#000000', background: '#0f0905', backgroundSecondary: '#1c1208', accent: '#fcd34d', gradient: ['#ca8a04', '#facc15'], text: ['#fefce8', '#fef08a', '#facc15'] }, light: { buttonText: '#ffffff', background: '#fbfaf6', backgroundSecondary: '#f5f2eb', accent: '#d97706', gradient: ['#854d0e', '#eab308'], text: ['#854d0e', '#ca8a04', '#eab308'] } },
56
+ { name: 'Silver', preview: ['#1e293b', '#94a3b8'], dark: { buttonText: '#ffffff', background: '#090e17', backgroundSecondary: '#101725', accent: '#cbd5e1', gradient: ['#475569', '#94a3b8'], text: ['#e2e8f0', '#94a3b8', '#cbd5e1'] }, light: { buttonText: '#ffffff', background: '#f8fafc', backgroundSecondary: '#e2e8f0', accent: '#64748b', gradient: ['#1e293b', '#94a3b8'], text: ['#1e293b', '#475569', '#94a3b8'] } },
57
+ { name: 'Barbie Pink', preview: ['#d946ef', '#f472b6'], dark: { buttonText: '#ffffff', background: '#0c030a', backgroundSecondary: '#1a0515', accent: '#ff80ff', gradient: ['#c026d3', '#f472b6'], text: ['#ffeeff', '#ffb3ff', '#ff80ff'] }, light: { buttonText: '#ffffff', background: '#fcf5fa', backgroundSecondary: '#f5ebf2', accent: '#d946ef', gradient: ['#c026d3', '#ec4899'], text: ['#a21caf', '#c026d3', '#ec4899'] } },
58
+ { name: 'Orange', preview: ['#7c2d12', '#f97316'], dark: { buttonText: '#ffffff', background: '#0a0200', backgroundSecondary: '#1c0b05', accent: '#fb923c', gradient: ['#c2410c', '#fb923c'], text: ['#fff7ed', '#fdba74', '#fb923c'] }, light: { buttonText: '#ffffff', background: '#fdf6f2', backgroundSecondary: '#f5ebe4', accent: '#ea580c', gradient: ['#7c2d12', '#f97316'], text: ['#7c2d12', '#ea580c', '#f97316'] } },
59
+ { name: 'Red', preview: ['#7f1d1d', '#ef4444'], dark: { buttonText: '#ffffff', background: '#0a0000', backgroundSecondary: '#2e0a0a', accent: '#ef4444', gradient: ['#b91c1c', '#f87171'], text: ['#fef2f2', '#fecaca', '#f87171'] }, light: { buttonText: '#ffffff', background: '#fdf5f5', backgroundSecondary: '#f5ebeb', accent: '#dc2626', gradient: ['#7f1d1d', '#ef4444'], text: ['#7f1d1d', '#dc2626', '#ef4444'] } },
60
+ { name: 'Black & White', preview: ['#ffffff', '#000000'], dark: { buttonText: '#000000', background: '#000000', backgroundSecondary: '#111111', accent: '#ffffff', gradient: ['#ffffff', '#e5e7eb'], text: ['#ffffff', '#f3f4f6', '#ffffff'] }, light: { buttonText: '#ffffff', background: '#ffffff', backgroundSecondary: '#f3f4f6', accent: '#000000', gradient: ['#000000', '#1f2937'], text: ['#000000', '#1f2937', '#374151'] } }
61
+ ];
62
+
63
+ export let sharedEnums: Record<string, string[]> = {
64
+ defaultTheme: ['system', 'light', 'dark'],
65
+ cornerRounding: ['default', 'custom'],
66
+ action: ['sign_in', 'register', 'redirect', 'form'],
67
+ type: ['text', 'email', 'tel', 'textarea'],
68
+ form: ['contact'],
69
+ formType: ['public', 'private'],
70
+ submitPage: ['submissions', 'requests'],
71
+ fontFamily: ['Inter', 'Outfit', 'Roboto', 'Space Grotesk', 'Plus Jakarta Sans', 'System UI']
72
+ };
73
+
74
+ import { getDocs, collection } from 'firebase/firestore';
75
+ if (db) {
76
+ getDocs(collection(db, 'sys_forms')).then(snap => {
77
+ const forms: string[] = [];
78
+ snap.forEach(d => {
79
+ if (d.data().formType !== 'private') forms.push(d.id);
80
+ });
81
+ if (forms.length > 0) Object.assign(sharedEnums, { form: forms });
82
+ }).catch(() => {});
83
+ }
84
+
85
+ export const AutoResizeInput = ({ value, onChange, onBlur, onFocus, className, isNumber, handleBlurOrEnter, placeholder, disabled }: any) => {
86
+ const [localVal, setLocalVal] = React.useState(value);
87
+ const [isFocused, setIsFocused] = React.useState(false);
88
+ const [inputWidth, setInputWidth] = React.useState(0);
89
+ const inputRef = React.useRef<HTMLInputElement>(null);
90
+ const spanRef = React.useRef<HTMLSpanElement>(null);
91
+
92
+ React.useEffect(() => { setLocalVal(value); }, [value]);
93
+
94
+ React.useLayoutEffect(() => {
95
+ if (spanRef.current) {
96
+ // Perfect hugging width.
97
+ setInputWidth(spanRef.current.getBoundingClientRect().width);
98
+ }
99
+ }, [localVal, placeholder]);
100
+
101
+ // If text or link is pasted into quotes, make sure to always scroll to the left
102
+ useEffect(() => {
103
+ if (!isFocused && inputRef.current) {
104
+ inputRef.current.scrollLeft = 0;
105
+ }
106
+ }, [localVal, isFocused]);
107
+
108
+ // Strip max-width from input and pass it to wrapper
109
+ const wrapperClassMatch = typeof className === 'string' ? className.match(/(?:max-w-\[[^\]]+\]|sm:max-w-\[[^\]]+\]|md:max-w-\[[^\]]+\])\s*/g) : null;
110
+ const wrapperClasses = wrapperClassMatch ? wrapperClassMatch.join(' ') : 'max-w-[200px] sm:max-w-[300px]';
111
+ const spanClass = typeof className === 'string' ? className.replace(/(?:max-w-\[[^\]]+\]|sm:max-w-\[[^\]]+\]|md:max-w-\[[^\]]+\]|text-ellipsis)\s*/g, '') : '';
112
+ const inputCleanClass = spanClass.trim();
113
+
114
+ return (
115
+ <div className={`relative inline-flex flex-col group/autoresize ${wrapperClasses}`}>
116
+ <div className="flex-1 overflow-x-auto overflow-y-hidden pb-[1px] styled-scrollbars-mini flex items-center">
117
+ <span
118
+ ref={spanRef}
119
+ className={`${spanClass} absolute opacity-0 pointer-events-none whitespace-pre border-none p-0 inline-block`}
120
+ style={{ width: 'max-content', minWidth: 'max-content' }}
121
+ aria-hidden="true"
122
+ >
123
+ {localVal || placeholder || (isNumber ? '0' : ' ')}
124
+ </span>
125
+ <input
126
+ disabled={disabled}
127
+ ref={inputRef}
128
+ type={isNumber ? "number" : "text"}
129
+ value={localVal}
130
+ placeholder={placeholder}
131
+ spellCheck={false}
132
+ className={`${inputCleanClass} transition-all duration-200 ${disabled ? 'cursor-not-allowed opacity-50 select-none' : ''} ${isFocused ? 'relative z-50 bg-background shadow-md' : ''}`}
133
+ onDoubleClick={(e) => { if (!disabled) e.currentTarget.select(); }}
134
+ onPaste={() => {
135
+ setTimeout(() => {
136
+ if (inputRef.current) inputRef.current.scrollLeft = 0;
137
+ }, 10);
138
+ }}
139
+ onChange={(e) => {
140
+ if (!disabled) setLocalVal(e.target.value);
141
+ }}
142
+ onKeyDown={(e) => {
143
+ if (disabled) return;
144
+ if (e.key === 'Enter') {
145
+ const finalVal = isNumber ? parseFloat(e.currentTarget.value) || 0 : e.currentTarget.value;
146
+ if (finalVal !== value) onChange(finalVal);
147
+ if (handleBlurOrEnter) handleBlurOrEnter(e);
148
+ e.currentTarget.blur();
149
+ }
150
+ }}
151
+ onBlur={(e) => {
152
+ if (disabled) return;
153
+ setIsFocused(false);
154
+ const finalVal = isNumber ? parseFloat(e.currentTarget.value) || 0 : e.currentTarget.value;
155
+ if (finalVal !== value) onChange(finalVal);
156
+ if (handleBlurOrEnter) handleBlurOrEnter(e);
157
+ if (onBlur) onBlur(e);
158
+ e.currentTarget.scrollLeft = 0;
159
+ }}
160
+ onFocus={(e) => {
161
+ if (disabled) return;
162
+ setIsFocused(true);
163
+ if (onFocus) onFocus(e);
164
+ }}
165
+ style={{
166
+ width: inputWidth > 0 ? `${inputWidth + 6}px` : 'auto',
167
+ minWidth: '20px',
168
+ maxWidth: isFocused ? 'none' : undefined
169
+ }}
170
+ />
171
+ </div>
172
+ </div>
173
+ );
174
+ };
175
+
176
+ import { themeConfig } from '../configs/theme.config';
177
+
178
+ export const JsonNode = ({ objectKey, displayKey, value, onChange, depth = 0, isLast = false, keysToRender, isGlobalSaving, customExplanations, customEnums, disabledKeys = [], hideInnerLoader = false, meta = {} }: any) => {
179
+ const renderedKey = displayKey || objectKey;
180
+ const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
181
+ const isArray = Array.isArray(value);
182
+ const [showPicker, setShowPicker] = useState<number | boolean>(false);
183
+ const [isFolded, setIsFolded] = useState<boolean>(objectKey === 'colors' || objectKey === 'fields');
184
+ const [showLoader, setShowLoader] = useState(false);
185
+ const [urlError, setUrlError] = useState(false);
186
+ const focusValRef = React.useRef<string | null>(null);
187
+ const [pendingEnum, setPendingEnum] = React.useState<string | null>(null);
188
+ const enumRef = React.useRef<HTMLDivElement>(null);
189
+
190
+ useEffect(() => {
191
+ if (!isGlobalSaving) {
192
+ setShowLoader(false);
193
+ }
194
+ }, [isGlobalSaving]);
195
+
196
+ const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
197
+ focusValRef.current = e.currentTarget.value;
198
+ };
199
+
200
+ const handleBlurOrEnter = (e: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) => {
201
+ if ('key' in e && e.key === 'Enter') e.currentTarget.blur();
202
+ if (('type' in e && e.type === 'blur') || ('key' in e && e.key === 'Enter')) {
203
+ if (focusValRef.current !== null && focusValRef.current !== e.currentTarget.value) {
204
+ if (!hideInnerLoader) setShowLoader(true);
205
+ }
206
+ focusValRef.current = null;
207
+ }
208
+ };
209
+
210
+ React.useEffect(() => { setPendingEnum(null); }, [value]);
211
+
212
+ const explanation = customExplanations?.[objectKey] || explanations[objectKey as keyof typeof explanations];
213
+ const enumOptions = customEnums?.[objectKey] || sharedEnums[objectKey as keyof typeof sharedEnums];
214
+ const isFieldDisabled = Array.isArray(disabledKeys) && disabledKeys.includes(objectKey);
215
+
216
+ const renderWrapper = (children: React.ReactNode) => (
217
+ <div className="flex flex-col text-[14px] font-mono leading-relaxed my-0.5" style={{ marginLeft: depth > 0 ? 32 : 0 }}>
218
+ {explanation && (
219
+ <span className="text-foreground/40 text-[12px] font-mono italic select-none mt-2 mb-0.5 flex flex-wrap items-center gap-1">
220
+ {"// "}
221
+ {explanation.split(/(https?:\/\/[^\s,]+)/g).map((part: string, i: number) => {
222
+ if (part.startsWith('http')) {
223
+ return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accentDeep underline underline-offset-2 cursor-pointer transition-colors pointer-events-auto">{part}</a>;
224
+ }
225
+ return <span key={i}>{part}</span>;
226
+ })}
227
+ {(objectKey === 'logoUrl' || objectKey === 'faviconUrl') && (
228
+ <Link to="/drive/public" className="text-accent flex items-center gap-1 hover:text-accentDeep underline underline-offset-2 ml-1 cursor-pointer transition-colors">
229
+ Open Drive / Public
230
+ </Link>
231
+ )}
232
+ </span>
233
+ )}
234
+ {children}
235
+ </div>
236
+ );
237
+
238
+ if (isObject) {
239
+ const keys = Object.keys(value);
240
+
241
+ let mappedKeys = keysToRender ? [...keysToRender] : keys;
242
+
243
+ mappedKeys = mappedKeys.filter(k => {
244
+ if (k === 'showThemeToggle' && value['defaultTheme'] !== 'system') return false;
245
+ if (k === 'cornerRadii' && value['cornerRounding'] !== 'custom') return false;
246
+ if (k === 'url' && 'action' in value && value['action'] !== 'redirect') return false;
247
+ if (k === 'form' && 'action' in value && value['action'] !== 'form') return false;
248
+ if (k === 'currencySymbol' && value['type'] !== 'currency') return false;
249
+ if (k === 'options' && value['type'] !== 'select') return false;
250
+ return true;
251
+ });
252
+
253
+ if (value['action'] === 'redirect' && !mappedKeys.includes('url') && value['showButton'] !== false) {
254
+ mappedKeys.push('url');
255
+ }
256
+ if (value['action'] === 'form' && !mappedKeys.includes('form') && value['showButton'] !== false) {
257
+ mappedKeys.push('form');
258
+ }
259
+ if (value['type'] === 'currency' && !mappedKeys.includes('currencySymbol')) {
260
+ mappedKeys.push('currencySymbol');
261
+ }
262
+ if (value['type'] === 'select' && !mappedKeys.includes('options')) {
263
+ mappedKeys.push('options');
264
+ }
265
+
266
+ return renderWrapper(
267
+ <div className="flex flex-col group/obj">
268
+ {objectKey ? (
269
+ <div className={`flex items-center gap-1 hover:bg-foreground/[0.04] rounded-lg pl-0.5 pr-2 py-0.5 -ml-[18px] w-fit transition-colors ${isFolded ? 'cursor-pointer' : ''}`} onClick={() => isFolded && setIsFolded(false)}>
270
+ <button onClick={(e) => { e.stopPropagation(); setIsFolded(!isFolded); }} className="text-foreground/30 hover:text-foreground/80 transition-colors w-4 flex items-center justify-center font-black text-[10px]" title={isFolded ? "Unfold" : "Fold"}>
271
+ {isFolded ? '►' : '▼'}
272
+ </button>
273
+ <span className="text-accent font-extrabold">"{renderedKey}"</span><span className="text-foreground/50">:</span> <span className="text-foreground/70 font-bold">{isFolded ? '{...}' : '{'}</span>
274
+ </div>
275
+ ) : depth > 0 ? (
276
+ <div className="text-foreground/70 font-bold px-2">{'{'}</div>
277
+ ) : null}
278
+ {!isFolded && (
279
+ <div className={`flex flex-col ${depth > 0 ? 'border-l border-[var(--panel-border)]/30 ml-2 pl-1' : ''}`}>
280
+ {mappedKeys.map((k, idx, arr) => {
281
+ if (k === 'subtitle' && value['showSubtitle'] === false) return null;
282
+ if (k === 'url' && 'action' in value && value['action'] !== 'redirect') return null;
283
+ if (k === 'form' && 'action' in value && value['action'] !== 'form') return null;
284
+
285
+ return (
286
+ <JsonNode
287
+ key={k}
288
+ objectKey={k}
289
+ value={value[k]}
290
+ onChange={(newVal: any) => {
291
+ const newObj = { ...value, [k]: newVal };
292
+ if (k === 'action' && newVal === 'form' && !newObj.form) newObj.form = 'contact';
293
+ if (k === 'action' && newVal === 'redirect' && !newObj.url) newObj.url = '/';
294
+ if (k === 'type' && newVal === 'currency' && !newObj.currencySymbol) newObj.currencySymbol = '$';
295
+ if (k === 'type' && newVal === 'select' && !newObj.options) newObj.options = ['Option 1', 'Option 2'];
296
+ onChange(newObj);
297
+ }}
298
+ depth={depth + 1}
299
+ isLast={idx === arr.length - 1}
300
+ isGlobalSaving={isGlobalSaving}
301
+ customExplanations={customExplanations}
302
+ customEnums={customEnums}
303
+ disabledKeys={disabledKeys}
304
+ hideInnerLoader={hideInnerLoader}
305
+ meta={meta}
306
+ />
307
+ );
308
+ })}
309
+ </div>
310
+ )}
311
+ {objectKey && !isFolded && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{'}'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
312
+ {objectKey && isFolded && !isLast && <span className="text-foreground/50 -mt-1 ml-2">,</span>}
313
+ </div>
314
+ );
315
+ }
316
+
317
+ if (isArray) {
318
+ return renderWrapper(
319
+ <div className="flex flex-col group/arr">
320
+ {objectKey ? (
321
+ <div className={`flex items-center gap-1 hover:bg-foreground/[0.04] rounded-lg pl-0.5 pr-2 py-0.5 -ml-[18px] w-fit transition-colors ${isFolded ? 'cursor-pointer' : ''}`} onClick={() => isFolded && setIsFolded(false)}>
322
+ <button onClick={(e) => { e.stopPropagation(); setIsFolded(!isFolded); }} className="text-foreground/30 hover:text-foreground/80 transition-colors w-4 flex items-center justify-center font-black text-[10px]" title={isFolded ? "Unfold" : "Fold"}>
323
+ {isFolded ? '►' : '▼'}
324
+ </button>
325
+ <span className="text-accent font-extrabold">"{renderedKey}"</span><span className="text-foreground/50">:</span> <span className="text-foreground/70 font-bold">{isFolded ? '[...]' : '['}</span>
326
+ </div>
327
+ ) : (
328
+ <div className="flex items-center gap-1 group-hover/arr:bg-foreground/[0.02] rounded-lg px-2 py-0.5 -ml-2 w-fit transition-colors">
329
+ <span className="text-foreground/70 font-bold">{'['}</span>
330
+ </div>
331
+ )}
332
+ {!isFolded && (
333
+ <div className="flex flex-col border-l border-[var(--panel-border)]/30 ml-2 mt-1 pl-1" style={{ marginLeft: 32 }}>
334
+ {value.map((item: any, i: number) => {
335
+ if (item !== null && typeof item === 'object') {
336
+ return (
337
+ <div key={i} className="pl-2 border-l border-[var(--panel-border)]/20 mt-1 mb-1 relative group/arr-item">
338
+ <button onClick={() => { const arr = [...value]; arr.splice(i, 1); onChange(arr); }} className="absolute -left-[11px] top-1.5 w-5 h-5 bg-background border border-[var(--panel-border)] text-red-500 hover:bg-red-500/10 rounded flex items-center justify-center opacity-0 group-hover/arr-item:opacity-100 transition-all font-bold text-[14px] leading-none z-20" title="Remove Item">
339
+ ×
340
+ </button>
341
+ <JsonNode
342
+ value={item}
343
+ onChange={(newVal: any) => {
344
+ const arr = [...value];
345
+ arr[i] = newVal;
346
+ onChange(arr);
347
+ }}
348
+ depth={depth + 1}
349
+ isLast={i === value.length - 1}
350
+ isGlobalSaving={isGlobalSaving}
351
+ customExplanations={customExplanations}
352
+ customEnums={customEnums}
353
+ disabledKeys={disabledKeys}
354
+ />
355
+ </div>
356
+ );
357
+ }
358
+
359
+ const isColorString = typeof item === 'string' && (item.startsWith('#') || item.startsWith('rgba') || item.startsWith('rgb'));
360
+ return (
361
+ <div key={i} className="flex items-center gap-1 group/item w-fit relative z-10 my-0.5">
362
+ <button onClick={() => { const arr = [...value]; arr.splice(i, 1); onChange(arr); }} className="absolute -left-6 top-1/2 -translate-y-1/2 w-4 h-4 text-red-500 hover:bg-red-500/10 rounded flex items-center justify-center opacity-0 group-hover/item:opacity-100 transition-all font-bold text-[14px] leading-none" title="Remove Item">
363
+ ×
364
+ </button>
365
+ <span className="text-foreground/40 text-[11px] w-4 select-none font-bold">{i}:</span>
366
+ <span className="text-foreground/40 shrink-0">"</span>
367
+ {isColorString && (
368
+ <div className="relative w-5 h-5 rounded-full overflow-hidden border border-[var(--panel-border)] shrink-0 mx-1 group/item hover:border-accent transition-colors bg-[var(--background)]">
369
+ <input
370
+ type="color"
371
+ value={item.length === 7 && item.startsWith('#') ? item : '#000000'}
372
+ onChange={(e) => {
373
+ const arr = [...value];
374
+ arr[i] = e.target.value;
375
+ onChange(arr);
376
+ }}
377
+ className="absolute -inset-8 w-24 h-24 cursor-pointer opacity-0"
378
+ />
379
+ <div className="absolute inset-0 pointer-events-none transition-transform group-hover/item:scale-110" style={{ backgroundColor: item }} />
380
+ </div>
381
+ )}
382
+ <AutoResizeInput
383
+ value={item}
384
+ isNumber={typeof item === 'number'}
385
+ onChange={(newVal: any) => {
386
+ const arr = [...value];
387
+ arr[i] = newVal;
388
+ onChange(arr);
389
+ }}
390
+ onFocus={handleFocus}
391
+ handleBlurOrEnter={handleBlurOrEnter}
392
+ className="no-glow bg-transparent text-foreground font-bold outline-none focus:outline-none focus:ring-0 p-0 m-0 transition-colors max-w-[200px] sm:max-w-[300px] text-ellipsis placeholder:text-foreground/20"
393
+ />
394
+ <span className="text-foreground/40 shrink-0">"</span>
395
+ <span className="text-foreground/50 shrink-0">{i < value.length - 1 ? ',' : ''}</span>
396
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 shrink-0" />}
397
+ </div>
398
+ );
399
+ })}
400
+
401
+ {(!(value.length > 0 && typeof value[0] === 'string' && (value[0].startsWith('#') || value[0].startsWith('rgba') || value[0].startsWith('color-mix'))) || value.length < 6) && (
402
+ <button
403
+ onClick={() => {
404
+ const arr = [...value];
405
+ let clone: any = '';
406
+ if (objectKey === 'fields') {
407
+ const existingNames = arr.map(f => f.name);
408
+
409
+ if (meta?.formType === 'private') {
410
+ if (!existingNames.includes('issue_type')) {
411
+ clone = { type: 'select', name: 'issue_type', label: 'Issue Type', required: true, options: ['Bug Report', 'Feature Request', 'Billing', 'General Question'] };
412
+ } else if (!existingNames.includes('urgency')) {
413
+ clone = { type: 'select', name: 'urgency', label: 'Urgency Level', required: true, options: ['Low', 'Medium', 'High', 'Critical'] };
414
+ } else if (!existingNames.includes('description')) {
415
+ clone = { type: 'textarea', name: 'description', label: 'Description', required: true, placeholder: 'Please provide detailed information...' };
416
+ } else if (!existingNames.includes('device')) {
417
+ clone = { type: 'text', name: 'device', label: 'Device / OS', required: false, placeholder: 'e.g., iPhone 15, Windows 11' };
418
+ } else if (!existingNames.includes('subscribe')) {
419
+ clone = { type: 'checkbox', name: 'subscribe', label: 'Receive status updates via email', required: false };
420
+ } else {
421
+ clone = { type: 'text', name: `custom_field_${arr.length}`, label: 'Custom Field', required: false, placeholder: '' };
422
+ }
423
+ } else {
424
+ if (!existingNames.includes('name')) {
425
+ clone = { type: 'text', name: 'name', label: 'Full Name', required: true, placeholder: 'Enter your name' };
426
+ } else if (!existingNames.includes('email')) {
427
+ clone = { type: 'email', name: 'email', label: 'Email Address', required: true, placeholder: 'you@example.com' };
428
+ } else if (!existingNames.includes('phone')) {
429
+ clone = { type: 'tel', name: 'phone', label: 'Phone Number', required: false, placeholder: '+1 (555) 000-0000' };
430
+ } else if (!existingNames.includes('questions')) {
431
+ clone = { type: 'textarea', name: 'questions', label: 'Questions', required: false, placeholder: 'How can we help you?' };
432
+ } else if (!existingNames.includes('budget')) {
433
+ clone = { type: 'currency', name: 'budget', label: 'Budget', required: false, placeholder: 'e.g. 5000', currencySymbol: '$' };
434
+ } else if (!existingNames.includes('service_type')) {
435
+ clone = { type: 'select', name: 'service_type', label: 'Service Interested In', required: false, options: ['Consulting', 'Development', 'Design'] };
436
+ } else if (!existingNames.includes('subscribe')) {
437
+ clone = { type: 'checkbox', name: 'subscribe', label: 'Subscribe to newsletter', required: false };
438
+ } else if (!existingNames.includes('age')) {
439
+ clone = { type: 'age', name: 'age', label: 'Age', required: false, placeholder: 'e.g. 25' };
440
+ } else {
441
+ clone = { type: 'text', name: `custom_field_${arr.length}`, label: 'Custom Field', required: false, placeholder: '' };
442
+ }
443
+ }
444
+ } else if (typeof arr[0] === 'string') {
445
+ const genericServices = ['Marketing', 'Support', 'Analysis', 'Strategy', 'Maintenance'];
446
+ clone = genericServices[Math.min(arr.length, genericServices.length) - 1] || `Option ${arr.length + 1}`;
447
+ } else {
448
+ clone = arr.length > 0 ? JSON.parse(JSON.stringify(arr[arr.length - 1])) : '';
449
+ if (clone && typeof clone === 'object') {
450
+ if (clone.name) clone.name = `${clone.name}_${arr.length}`;
451
+ if (clone.label) clone.label = `${clone.label} ${arr.length}`;
452
+ }
453
+ }
454
+ arr.push(clone);
455
+ onChange(arr);
456
+ }}
457
+ className="mt-2 mb-1 w-fit flex items-center gap-1.5 px-3 py-1 bg-accent/10 hover:bg-accent/20 text-accent font-extrabold text-[11px] uppercase tracking-wider rounded-md transition-colors border border-accent/20"
458
+ >
459
+ + Add Item
460
+ </button>
461
+ )}
462
+ </div>
463
+ )}
464
+ {objectKey && !isFolded && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{']'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
465
+ {!objectKey && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{']'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
466
+ {objectKey && isFolded && !isLast && <span className="text-foreground/50 -mt-1 ml-2">,</span>}
467
+ </div>
468
+ );
469
+ }
470
+
471
+ if (typeof value === 'boolean') {
472
+ return renderWrapper(
473
+ <div className="flex items-center gap-1 group w-fit relative z-10 my-0.5">
474
+ <span className="text-accent font-extrabold shrink-0">"{renderedKey}"</span><span className="text-foreground/50 shrink-0">:</span>
475
+ <button
476
+ disabled={isFieldDisabled || showLoader}
477
+ onClick={() => {
478
+ if (!hideInnerLoader) setShowLoader(true);
479
+ onChange(!value);
480
+ }}
481
+ className={`px-3 py-1 rounded-md text-[12px] font-extrabold transition-colors ml-1 uppercase tracking-wider ${value ? 'bg-emerald-500/20 text-emerald-500' : 'bg-red-500/20 text-red-500'} ${(isFieldDisabled || showLoader) ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02] active:scale-[0.98]'}`}
482
+ >
483
+ {value ? 'true' : 'false'}
484
+ </button>
485
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1" />}
486
+ {!isLast && <span className="text-foreground/50">,</span>}
487
+ </div>
488
+ );
489
+ }
490
+
491
+ const isColorString = typeof value === 'string' && (value.startsWith('#') || value.startsWith('rgba') || value.startsWith('rgb'));
492
+
493
+ const isUrl = typeof value === 'string' && objectKey && String(objectKey).endsWith('Url');
494
+
495
+ return renderWrapper(
496
+ <div className={`flex items-center gap-1 group relative z-10 my-0.5 ${isUrl ? 'w-full pr-0 sm:pr-8' : 'w-fit'}`}>
497
+ <span className="text-accent font-extrabold shrink-0">"{renderedKey}"</span><span className="text-foreground/50 shrink-0">:</span>
498
+
499
+ {enumOptions ? (
500
+ <div className="flex items-center gap-1 ml-1 relative group/enum" ref={enumRef}>
501
+ <span className="text-foreground/40">"</span>
502
+ <button
503
+ disabled={isFieldDisabled || showLoader}
504
+ title={isFieldDisabled ? "Cannot be edited" : "Click to cycle options"}
505
+ tabIndex={0}
506
+ onClick={() => {
507
+ if (isFieldDisabled) return;
508
+ const currentVal = pendingEnum !== null ? pendingEnum : value;
509
+ const currentIndex = enumOptions.indexOf(currentVal);
510
+ const nextVal = enumOptions[(currentIndex + 1) % enumOptions.length];
511
+ setPendingEnum(nextVal);
512
+ if (!hideInnerLoader) setShowLoader(true);
513
+ onChange(nextVal);
514
+ }}
515
+ className={`${
516
+ (isFieldDisabled || showLoader)
517
+ ? 'bg-foreground/5 text-foreground/40 cursor-not-allowed opacity-50'
518
+ : 'bg-accent/10 hover:bg-accent/20 text-accent cursor-pointer shadow-sm hover:scale-[1.02] active:scale-[0.98]'
519
+ } outline-none px-3 py-1 rounded-md transition-all font-extrabold uppercase tracking-wider text-[11px] border border-transparent`}
520
+ >
521
+ {pendingEnum !== null ? pendingEnum : value}
522
+ </button>
523
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1" />}
524
+ <span className="text-foreground/40">"</span>
525
+ </div>
526
+ ) : (
527
+ <div className="flex items-center gap-0.5 ml-1 flex-1 min-w-0">
528
+ <span className="text-foreground/40 shrink-0">"</span>
529
+
530
+ {isColorString && (
531
+ <div className="relative w-5 h-5 rounded-full overflow-hidden border border-[var(--panel-border)] shrink-0 mx-1 group/item hover:border-accent transition-colors bg-[var(--background)]">
532
+ <input
533
+ type="color"
534
+ value={value.length === 7 && value.startsWith('#') ? value : '#000000'}
535
+ onFocus={handleFocus}
536
+ onBlur={(e) => {
537
+ onChange(e.target.value);
538
+ focusValRef.current = null;
539
+ }}
540
+ onChange={(e) => onChange(e.target.value)}
541
+ className="absolute -inset-8 w-24 h-24 cursor-pointer opacity-0"
542
+ />
543
+ <div className="absolute inset-0 pointer-events-none transition-transform group-hover/item:scale-110" style={{ backgroundColor: value }} />
544
+ </div>
545
+ )}
546
+
547
+ <AutoResizeInput
548
+ disabled={isFieldDisabled}
549
+ value={value}
550
+ isNumber={typeof value === 'number'}
551
+ placeholder={isUrl ? "https://..." : undefined}
552
+ className={`no-glow bg-transparent font-extrabold outline-none focus:outline-none focus:ring-0 p-0 m-0 transition-colors leading-none ${isUrl ? "text-accent/80 font-medium placeholder:text-accent/30" : "text-foreground"} ${isFieldDisabled ? 'opacity-50 grayscale pointer-events-none' : ''}`}
553
+ onFocus={handleFocus}
554
+ handleBlurOrEnter={(e: any) => {
555
+ let val = e.currentTarget.value;
556
+ if (isUrl && val && val !== 'none' && !val.includes('http') && !val.includes('www.') && !val.startsWith('/')) {
557
+ val = '';
558
+ onChange('');
559
+ setUrlError(true);
560
+ setTimeout(() => setUrlError(false), 2500);
561
+ }
562
+ if (focusValRef.current !== null && focusValRef.current !== val) {
563
+ setShowLoader(true);
564
+ }
565
+ focusValRef.current = null;
566
+ handleBlurOrEnter(e);
567
+ }}
568
+ onChange={(newVal: any) => onChange(newVal)}
569
+ />
570
+ <span className="text-foreground/40 shrink-0">"</span>
571
+ {urlError ? (
572
+ <span className="text-red-500 font-bold ml-1.5 text-[10px] tracking-widest uppercase">invalid</span>
573
+ ) : (
574
+ showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1.5 shrink-0" />
575
+ )}
576
+
577
+ {(objectKey === 'logoUrl' || objectKey === 'faviconUrl') && (
578
+ <button
579
+ onClick={() => onChange(objectKey === 'logoUrl' ? themeConfig.logoUrl : themeConfig.faviconUrl)}
580
+ className="w-4 h-4 ml-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded flex items-center justify-center transition-colors text-[10px] font-bold"
581
+ title="Reset to default icon"
582
+ >✕</button>
583
+ )}
584
+ </div>
585
+ )}
586
+ {!isLast && <span className="text-foreground/50 shrink-0">,</span>}
587
+ {objectKey && ["inputRadius", "modalRadius", "badgeRadius", "buttonRadius", "cardRadius"].includes(objectKey) && (
588
+ <span className="text-foreground/30 text-[10px] font-medium ml-2 tracking-wide whitespace-nowrap hidden md:inline-block pointer-events-none">
589
+ // {objectKey === 'inputRadius' ? 'Forms & text fields' : objectKey === 'modalRadius' ? 'Popups & dialogs' : objectKey === 'badgeRadius' ? 'Tags & pills' : objectKey === 'buttonRadius' ? 'Interactive buttons' : 'Big panels & cards'}
590
+ </span>
591
+ )}
592
+ </div>
593
+ );
594
+ };
595
+ const ThemePreview = () => null;
596
+
597
+ export function ThemeAdmin() {
598
+ const { activeConfig } = useTheme();
599
+ // We keep a fully reactive object tree locally instead of string
600
+ const [localConfig, setLocalConfig] = useState<any>(null);
601
+ const [isSaving, setIsSaving] = useState(false);
602
+ const [isResetting, setIsResetting] = useState(false);
603
+
604
+ useEffect(() => {
605
+ // Clone live config locally to edit
606
+ if (!localConfig) {
607
+ setLocalConfig(JSON.parse(JSON.stringify(activeConfig)));
608
+ }
609
+ }, [activeConfig]);
610
+
611
+ const [copiedStatus, setCopiedStatus] = useState<boolean>(false);
612
+ const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
613
+
614
+ // Auto-Save background processor (debounced 1s)
615
+ useEffect(() => {
616
+ if (!localConfig || !activeConfig) return;
617
+ if (JSON.stringify(localConfig) === JSON.stringify(activeConfig)) {
618
+ setIsSaving(false);
619
+ return;
620
+ }
621
+
622
+ const timer = setTimeout(async () => {
623
+ setIsSaving(true);
624
+ try {
625
+ await setDoc(doc(db, 'sys_configs', 'theme'), localConfig);
626
+ } catch (err: any) {
627
+ console.error("Auto-save failed", err);
628
+ }
629
+ setIsSaving(false);
630
+ }, 300);
631
+ return () => clearTimeout(timer);
632
+ }, [localConfig, activeConfig]);
633
+
634
+ const resetToDefault = async () => {
635
+ setIsResetting(true);
636
+ try {
637
+ const module = await import('../configs/theme.config');
638
+ await setDoc(doc(db, 'sys_configs', 'theme'), module.themeConfig);
639
+ setLocalConfig(JSON.parse(JSON.stringify(module.themeConfig)));
640
+ } catch (e) {}
641
+ setTimeout(() => {
642
+ setIsResetting(false);
643
+ setShowResetConfirm(false);
644
+ }, 1000);
645
+ };
646
+
647
+ const hexToRgb = (hex: string) => {
648
+ if (!hex) return '167, 139, 250';
649
+ let r = 0, g = 0, b = 0;
650
+ if (hex.length === 4) {
651
+ r = parseInt(hex[1] + hex[1], 16);
652
+ g = parseInt(hex[2] + hex[2], 16);
653
+ b = parseInt(hex[3] + hex[3], 16);
654
+ } else if (hex.length === 7) {
655
+ r = parseInt(hex.substring(1, 3), 16);
656
+ g = parseInt(hex.substring(3, 5), 16);
657
+ b = parseInt(hex.substring(5, 7), 16);
658
+ }
659
+ return `${r}, ${g}, ${b}`;
660
+ };
661
+
662
+ const applyPreset = (preset: typeof colorPresets[0]) => {
663
+ if (!localConfig) return;
664
+ const newConfig = JSON.parse(JSON.stringify(localConfig));
665
+ if(newConfig.colors && newConfig.colors.dark) {
666
+ const rgb = hexToRgb(preset.dark.accent);
667
+ newConfig.colors.dark.accent = preset.dark.accent;
668
+ newConfig.colors.dark.buttonGradient = preset.dark.gradient;
669
+ newConfig.colors.dark.buttonHoverGradient = preset.dark.gradient;
670
+ newConfig.colors.dark.buttonText = preset.dark.buttonText;
671
+ newConfig.colors.dark.notificationIconColor = preset.dark.accent;
672
+ newConfig.colors.dark.textGradient = preset.dark.text;
673
+
674
+ newConfig.colors.dark.foreground = preset.dark.text[0];
675
+ newConfig.colors.dark.background = preset.dark.background;
676
+ newConfig.colors.dark.backgroundSecondary = preset.dark.backgroundSecondary;
677
+
678
+ newConfig.colors.dark.panelBg = `rgba(${rgb}, 0.05)`;
679
+ newConfig.colors.dark.panelBorder = `rgba(${rgb}, 0.15)`;
680
+ newConfig.colors.dark.glowColor = `rgba(${rgb}, 0.40)`;
681
+ newConfig.colors.dark.selectionBg = `rgba(${rgb}, 0.40)`;
682
+ if(newConfig.colors.dark.secondaryButton) {
683
+ newConfig.colors.dark.secondaryButton.text = preset.dark.text[0];
684
+ }
685
+ }
686
+ if(newConfig.colors && newConfig.colors.light) {
687
+ const rgb = hexToRgb(preset.light.accent);
688
+ newConfig.colors.light.accent = preset.light.accent;
689
+ newConfig.colors.light.buttonGradient = preset.light.gradient;
690
+ newConfig.colors.light.buttonHoverGradient = preset.light.gradient;
691
+ newConfig.colors.light.buttonText = preset.light.buttonText;
692
+ newConfig.colors.light.notificationIconColor = preset.light.accent;
693
+ newConfig.colors.light.textGradient = preset.light.text;
694
+
695
+ newConfig.colors.light.foreground = preset.light.text[0];
696
+ newConfig.colors.light.background = preset.light.background;
697
+ newConfig.colors.light.backgroundSecondary = preset.light.backgroundSecondary;
698
+
699
+ newConfig.colors.light.panelBg = 'rgba(255, 255, 255, 0.70)';
700
+ newConfig.colors.light.panelBorder = `rgba(${rgb}, 0.08)`;
701
+ newConfig.colors.light.glowColor = `rgba(${rgb}, 0.30)`;
702
+ newConfig.colors.light.selectionBg = `rgba(${rgb}, 0.30)`;
703
+ if(newConfig.colors.light.secondaryButton) {
704
+ newConfig.colors.light.secondaryButton.text = preset.light.text[0];
705
+ }
706
+ }
707
+ setLocalConfig(newConfig);
708
+ };
709
+
710
+ const handleCopy = () => {
711
+ if (localConfig) {
712
+ const c = localConfig;
713
+ const d = c.colors?.dark || {};
714
+ const l = c.colors?.light || {};
715
+ const f = c.fontCustomization || {};
716
+ const cr = c.cornerRadii || {};
717
+
718
+ const tsTemplate = `/**
719
+ * THEME & BRANDING CONFIGURATION
720
+ *
721
+ * This is the central hub for customizing your app's look.
722
+ * Change any value below to rebrand your "Firebase OS" instantly.
723
+ */
724
+
725
+ export const themeConfig = {
726
+ // -------------------------------------------------------------------------
727
+ // 1. GLOBAL SETTINGS
728
+ // -------------------------------------------------------------------------
729
+ appName: '${c.appName || 'Firebase OS.'}',
730
+
731
+ // Choose how the app starts: 'system', 'light', or 'dark'
732
+ defaultTheme: '${c.defaultTheme || 'system'}' as 'system' | 'light' | 'dark',
733
+
734
+ // Show or hide the Sun/Moon toggle button in the top menu
735
+ showThemeToggle: ${c.showThemeToggle !== false},
736
+
737
+ // Links to your logo and favicon (Can be a URL or a local path like /favicon.svg)
738
+ logoUrl: '${c.logoUrl || ''}', // If empty, the app will use the Database icon
739
+ faviconUrl: '${c.faviconUrl || ''}',
740
+
741
+ // -------------------------------------------------------------------------
742
+ // 2. THEME COLORS
743
+ // -------------------------------------------------------------------------
744
+ colors: {
745
+ // --- DARK MODE ---
746
+ dark: {
747
+ background: '${d.background || '#07070f'}', // Main background color
748
+ backgroundSecondary: '${d.backgroundSecondary || '#0a0a18'}', // Gradient blending background color
749
+ foreground: '${d.foreground || '#f0eeff'}', // Primary text color
750
+
751
+ accent: '${d.accent || '#a78bfa'}', // Main branding color (Purple)
752
+
753
+ panelBg: '${d.panelBg || 'rgba(167, 139, 250, 0.05)'}', // Card/Panel background
754
+ panelBorder: '${d.panelBorder || 'rgba(167, 139, 250, 0.15)'}', // Card/Panel borders
755
+
756
+ buttonGradient: ['${d.buttonGradient?.[0] || '#6d28d9'}', '${d.buttonGradient?.[1] || '#a78bfa'}'], // [Start, End]
757
+ secondaryButton: {
758
+ bg: '${d.secondaryButton?.bg || 'rgba(255, 255, 255, 0.05)'}',
759
+ border: '${d.secondaryButton?.border || 'rgba(255, 255, 255, 0.1)'}',
760
+ text: '${d.secondaryButton?.text || '#f0eeff'}'
761
+ },
762
+ buttonHoverGradient: ['${d.buttonHoverGradient?.[0] || '#7c3aed'}', '${d.buttonHoverGradient?.[1] || '#c4b5fd'}'], // [Start, End]
763
+ glowColor: '${d.glowColor || 'rgba(167, 139, 250, 0.40)'}', // Component shadow glow
764
+ notificationIconColor: '${d.notificationIconColor || '#a78bfa'}',
765
+ textGradient: [${(d.textGradient || ['#f0eeff', '#c4b5fd', '#a78bfa']).map((x: string) => `'${x}'`).join(', ')}], // Can add up to 6 colors
766
+ selectionBg: '${d.selectionBg || 'rgba(167, 139, 250, 0.40)'}',
767
+ selectionText: '${d.selectionText || '#ffffff'}',
768
+ },
769
+
770
+ // --- LIGHT MODE ---
771
+ light: {
772
+ background: '${l.background || '#fcfaff'}', // Main background color
773
+ backgroundSecondary: '${l.backgroundSecondary || '#f7f3ff'}', // Gradient blending background color
774
+ foreground: '${l.foreground || '#1e1b4b'}', // Primary text color
775
+
776
+ accent: '${l.accent || '#7c3aed'}', // Main branding color (Purple)
777
+ panelBg: '${l.panelBg || 'rgba(255, 255, 255, 0.70)'}', // Card/Panel background
778
+ panelBorder: '${l.panelBorder || 'rgba(124, 58, 237, 0.08)'}', // Card/Panel borders
779
+
780
+ buttonGradient: ['${l.buttonGradient?.[0] || '#5b21b6'}', '${l.buttonGradient?.[1] || '#a78bfa'}'], // [Start, End]
781
+ secondaryButton: {
782
+ bg: '${l.secondaryButton?.bg || 'rgba(0, 0, 0, 0.03)'}',
783
+ border: '${l.secondaryButton?.border || 'rgba(0, 0, 0, 0.06)'}',
784
+ text: '${l.secondaryButton?.text || '#1e1b4b'}'
785
+ },
786
+ buttonHoverGradient: ['${l.buttonHoverGradient?.[0] || '#6d28d9'}', '${l.buttonHoverGradient?.[1] || '#a78bfa'}'], // [Start, End]
787
+ glowColor: '${l.glowColor || 'rgba(124, 58, 237, 0.30)'}',
788
+ notificationIconColor: '${l.notificationIconColor || '#7c3aed'}',
789
+ textGradient: [${(l.textGradient || ['#2e1065', '#6d28d9', '#cdc1ff']).map((x: string) => `'${x}'`).join(', ')}], // [Start, Middle, End]
790
+ selectionBg: '${l.selectionBg || 'rgba(124, 58, 237, 0.30)'}',
791
+ selectionText: '${l.selectionText || '#ffffff'}',
792
+ }
793
+ },
794
+
795
+ // -------------------------------------------------------------------------
796
+ // 3. FONT STYLES & TYPOGRAPHY
797
+ // -------------------------------------------------------------------------
798
+ fontFamily: '${c.fontFamily || 'Inter'}',
799
+
800
+ // -------------------------------------------------------------------------
801
+ // 6. COMPONENT STYLING & BORDER RADII (GLOBAL)
802
+ // -------------------------------------------------------------------------
803
+ cornerRounding: '${c.cornerRounding || 'default'}' as 'default' | 'custom',
804
+ cornerRadii: {
805
+ buttonRadius: ${cr.buttonRadius || 0.75}, // 12px (xl)
806
+ inputRadius: ${cr.inputRadius || 0.75}, // Form inputs
807
+ cardRadius: ${cr.cardRadius || 1.5}, // 24px (2xl, 3xl)
808
+ modalRadius: ${cr.modalRadius || 2.5}, // Global popups and main internal boxes
809
+ }
810
+ };`;
811
+
812
+ navigator.clipboard.writeText(tsTemplate);
813
+ setCopiedStatus(true);
814
+ setTimeout(() => setCopiedStatus(false), 2000);
815
+ }
816
+ };
817
+ useEffect(() => {
818
+ const handleKeyDown = (e: KeyboardEvent) => {
819
+ // Allow specific Ctrl+A (if they focus the body, but not an input)
820
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a' && document.activeElement === document.body) {
821
+ e.preventDefault();
822
+ }
823
+ if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
824
+ const selection = window.getSelection()?.toString();
825
+ if (!selection && localConfig) {
826
+ handleCopy();
827
+ }
828
+ }
829
+ };
830
+ window.addEventListener('keydown', handleKeyDown);
831
+ return () => window.removeEventListener('keydown', handleKeyDown);
832
+ }, [localConfig]);
833
+
834
+ // Live real-time Preview engine mapping values to Document root
835
+ useEffect(() => {
836
+ if (!localConfig?.colors) return;
837
+ const docTheme = document.documentElement.getAttribute('data-theme') || 'dark';
838
+ const isDark = docTheme === 'dark';
839
+ const activeColors = isDark ? localConfig.colors.dark : localConfig.colors.light;
840
+
841
+ const root = document.documentElement;
842
+ root.style.setProperty('--bg-color', activeColors.background);
843
+ if (activeColors.backgroundSecondary) root.style.setProperty('--bg-secondary-color', activeColors.backgroundSecondary);
844
+ root.style.setProperty('--fg-color', activeColors.foreground);
845
+ root.style.setProperty('--accent-color', activeColors.accent);
846
+ root.style.setProperty('--accent-deep', activeColors.accentDeep);
847
+ root.style.setProperty('--panel-bg', activeColors.panelBg);
848
+ root.style.setProperty('--panel-border', activeColors.panelBorder);
849
+ root.style.setProperty('--button-bg', `linear-gradient(135deg, ${activeColors.buttonGradient[0]} 0%, ${activeColors.buttonGradient[1]} 100%)`);
850
+ root.style.setProperty('--button-hover-bg', `linear-gradient(135deg, ${activeColors.buttonHoverGradient?.[0] || activeColors.buttonGradient[0]} 0%, ${activeColors.buttonHoverGradient?.[1] || activeColors.buttonGradient[1]} 100%)`);
851
+ root.style.setProperty('--text-gradient', `linear-gradient(135deg, ${activeColors.textGradient[0]} 0%, ${activeColors.textGradient[1]} 50%, ${activeColors.textGradient[2]} 100%)`);
852
+ root.style.setProperty('--glow-color', activeColors.glowColor || 'rgba(124, 58, 237, 0.3)');
853
+ root.style.setProperty('--button-fg', activeColors.buttonText || '#ffffff');
854
+ if (activeColors.notificationIconColor) {
855
+ root.style.setProperty('--notification-icon-color', activeColors.notificationIconColor);
856
+ }
857
+
858
+ }, [localConfig]);
859
+
860
+ // Cleanup effect on unmount matching the DB config
861
+ useEffect(() => {
862
+ return () => {
863
+ // Reset styles by firing a custom event for ThemeContext to reload, or manually re-inject
864
+ if (!activeConfig?.colors) return;
865
+ const docTheme = document.documentElement.getAttribute('data-theme') || 'dark';
866
+ const isDark = docTheme === 'dark';
867
+ const c = isDark ? activeConfig.colors.dark : activeConfig.colors.light;
868
+ const root = document.documentElement;
869
+ root.style.setProperty('--bg-color', c.background);
870
+ if (c.backgroundSecondary) root.style.setProperty('--bg-secondary-color', c.backgroundSecondary);
871
+ root.style.setProperty('--fg-color', c.foreground);
872
+ root.style.setProperty('--accent-color', c.accent);
873
+ root.style.setProperty('--accent-deep', c.accentDeep);
874
+ root.style.setProperty('--panel-bg', c.panelBg);
875
+ root.style.setProperty('--panel-border', c.panelBorder);
876
+ root.style.setProperty('--button-bg', `linear-gradient(135deg, ${c.buttonGradient[0]} 0%, ${c.buttonGradient[1]} 100%)`);
877
+ root.style.setProperty('--button-hover-bg', `linear-gradient(135deg, ${c.buttonHoverGradient?.[0] || c.buttonGradient[0]} 0%, ${c.buttonHoverGradient?.[1] || c.buttonGradient[1]} 100%)`);
878
+ root.style.setProperty('--text-gradient', `linear-gradient(135deg, ${c.textGradient[0]} 0%, ${c.textGradient[1]} 50%, ${c.textGradient[2]} 100%)`);
879
+ root.style.setProperty('--glow-color', c.glowColor || 'rgba(124, 58, 237, 0.3)');
880
+ root.style.setProperty('--button-fg', c.buttonText || '#ffffff');
881
+ if (c.notificationIconColor) {
882
+ root.style.setProperty('--notification-icon-color', c.notificationIconColor);
883
+ }
884
+ };
885
+ }, [activeConfig]);
886
+
887
+ return (
888
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12 min-h-screen">
889
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-6 flex items-center justify-between">
890
+ <div>
891
+ <h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
892
+ Theme
893
+ </h1>
894
+ <div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
895
+ <span className="w-8 h-[1px] bg-foreground/10" />
896
+ /theme
897
+ </div>
898
+ </div>
899
+ </motion.div>
900
+
901
+ <div className="flex flex-col gap-6 flex-1 pb-16">
902
+ <DashboardNav />
903
+
904
+ <div className="glass-panel border border-[var(--panel-border)] rounded-3xl p-6 md:p-10 shadow-2xl relative overflow-hidden bg-background flex flex-col min-h-[600px]">
905
+ <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-6 pb-6 border-b border-[var(--panel-border)]/50 relative z-10 shrink-0">
906
+ <div>
907
+ <h2 className="text-xl font-extrabold text-foreground tracking-tight">Theme Config</h2>
908
+ <p className="text-[14px] font-medium text-foreground/60 mt-2 leading-relaxed">
909
+ Changes here immediately format natively. Save to update your Live Preview panel entirely.
910
+ </p>
911
+ </div>
912
+ <div className="flex items-center gap-2 w-full md:w-auto shrink-0">
913
+ <Button variant="secondary" onClick={resetToDefault} className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-red-500/10 rounded-xl flex items-center justify-center shrink-0 transition-colors group" title="Reset to Defaults">
914
+ {isResetting ? <Loader2 className="w-4 h-4 text-red-500 animate-spin" /> : <RotateCcw className="w-4 h-4 text-foreground/60 group-hover:text-red-500 transition-colors" />}
915
+ </Button>
916
+ <Button variant="secondary" onClick={handleCopy} className="w-10 h-10 p-0 border-transparent bg-transparent hover:bg-foreground/5 rounded-xl flex items-center justify-center shrink-0 transition-colors" title="Copy Config (Ctrl+C)">
917
+ {copiedStatus ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4 text-foreground/60 hover:text-foreground transition-colors" />}
918
+ </Button>
919
+ </div>
920
+ </div>
921
+
922
+ <div className="flex flex-col gap-6 w-full relative z-10 items-start">
923
+ {isResetting && (
924
+ <div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center rounded-3xl pointer-events-none transition-all duration-300">
925
+ <Loader2 className="w-8 h-8 animate-spin text-accent" />
926
+ </div>
927
+ )}
928
+
929
+ {/* Branding & Colours */}
930
+ <div className="w-full glass-panel border border-[var(--panel-border)] rounded-3xl p-6 md:p-8 shadow-inner overflow-hidden relative bg-background">
931
+ <h3 className="text-lg font-extrabold text-foreground tracking-tight mb-4 flex items-center gap-3">Branding & Colours</h3>
932
+
933
+ <div className="flex items-center gap-3 overflow-x-auto hide-scrollbars pb-4 pt-2 mb-6 border-b border-[var(--panel-border)]/50 shrink-0">
934
+ <div className="text-[12px] font-bold text-foreground/40 shrink-0 uppercase tracking-widest pl-1 mr-2">Presets</div>
935
+ {colorPresets.map(preset => {
936
+ const isActive = localConfig?.colors?.dark?.accent === preset.dark.accent && localConfig?.colors?.dark?.buttonGradient?.[0] === preset.dark.gradient[0];
937
+ const isBW = preset.name === 'Black & White';
938
+ return (
939
+ <button
940
+ key={preset.name}
941
+ onClick={() => applyPreset(preset)}
942
+ className={`w-9 h-9 rounded-full shrink-0 outline-none transition-all shadow-sm relative group overflow-visible ${isActive ? 'ring-[3px] ring-foreground ring-offset-2 ring-offset-[var(--background)] scale-110 z-10' : 'opacity-80 hover:opacity-100 hover:ring-2 hover:ring-foreground/30 hover:ring-offset-2 hover:ring-offset-[var(--background)] hover:scale-105'}`}
943
+ title={preset.name}
944
+ >
945
+ <div
946
+ className="absolute inset-0 rounded-full"
947
+ style={isBW ? {
948
+ backgroundColor: '#ffffff',
949
+ border: '3px solid #000000',
950
+ boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.1)'
951
+ } : {
952
+ backgroundImage: `linear-gradient(135deg, ${preset.preview[0]}, ${preset.preview[1]})`
953
+ }}
954
+ />
955
+ </button>
956
+ );
957
+ })}
958
+ </div>
959
+
960
+ <div className="w-full relative min-h-[100px]">
961
+ {localConfig ? (
962
+ <div className="flex flex-col pl-2">
963
+ <span className="text-yellow-500 font-mono text-[13px] mb-2">export const themeConfig = {'{'}</span>
964
+ <JsonNode
965
+ value={localConfig}
966
+ keysToRender={['appName', 'defaultTheme', 'showThemeToggle', 'logoUrl', 'faviconUrl', 'cornerRounding', 'cornerRadii', 'fontFamily']}
967
+ onChange={(newConfig: any) => setLocalConfig(newConfig)}
968
+ isLast={false}
969
+ isGlobalSaving={isSaving}
970
+ />
971
+
972
+ {/* Render Colors Explicitly inside Box 1 */}
973
+ <div className="mt-4">
974
+ <JsonNode
975
+ objectKey="colors"
976
+ value={localConfig.colors}
977
+ onChange={(newColors: any) => setLocalConfig({ ...localConfig, colors: newColors })}
978
+ isLast={false}
979
+ isGlobalSaving={isSaving}
980
+ />
981
+ </div>
982
+ </div>
983
+ ) : (
984
+ <div className="flex items-center justify-center w-full h-full text-foreground/40 font-mono text-[13px] animate-pulse py-10">
985
+ Loading Data Tree...
986
+ </div>
987
+ )}
988
+ </div>
989
+ </div>
990
+
991
+
992
+
993
+ </div>
994
+ </div>
995
+ </div>
996
+
997
+
998
+ </main>
999
+ );
1000
+ }