firebase-os 1.1.3 → 1.1.5

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 +2 -17
  3. package/dist/firebase-os.es.js +63 -72
  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 +89 -10
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +80 -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 +227 -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 +401 -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 +372 -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,579 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Loader2 } from 'lucide-react';
3
+ import { Link } from 'react-router-dom';
4
+ import { db } from '../lib/firebase';
5
+ import { getDocs, collection } from 'firebase/firestore';
6
+ import { themeConfig } from '../configs/theme.config';
7
+
8
+ const explanations: Record<string, string> = {
9
+ appName: "The main name of your application shown in headers and titles.",
10
+ defaultTheme: "Choose how the app starts immediately on load.",
11
+ showThemeToggle: "Allow users to manually toggle dark mode on/off.",
12
+ logoUrl: "Direct URL to your app's main logo. Need to upload one?",
13
+ faviconUrl: "Direct URL to your app's favicon. Need to upload one?",
14
+ background: "Main background canvas color under all content.",
15
+ foreground: "Primary typography color used across the app.",
16
+ accent: "The primary brand color for highlights, buttons, and links.",
17
+ panelBg: "Background color of glass cards and panels.",
18
+ panelBorder: "Borders of panels, inputs, and dropdowns.",
19
+ glowColor: "The global glow shadow applied to borders of focused inputs.",
20
+ buttonHoverGradient: "Start and end hex for primary button hover effects.",
21
+ textGradient: "Three tone gradient applied to large headers.",
22
+ buttonRadius: "Border radius for standard buttons.",
23
+ cardRadius: "Curvature of main content panels.",
24
+ inputRadius: "Rounding applied to form inputs.",
25
+ modalRadius: "Global popup containers and dashboard content cards.",
26
+ blurIntensity: "Glassmorphism backdrop-blur amounts.",
27
+ notificationIconColor: "Color applied to notification badging.",
28
+
29
+ fontFamily: "Choose the main typography of your brand.",
30
+
31
+ home: "Complete customization of the landing page settings.",
32
+ title: "Main heading displayed on the Landing Page or Form.",
33
+ subtitle: "Smaller colored overline text above the main title.",
34
+ text: "The descriptive paragraph text under the hero headers or buttons.",
35
+ primary: "Main call-to-action block settings.",
36
+ secondary: "Secondary ghost button block settings.",
37
+ sections: "Toggle visibility of landing page sectors.",
38
+ contactForm: "Customize the form wrapper and its labels.",
39
+ nameLabel: "Placeholder/Label for the Name input.",
40
+ emailLabel: "Placeholder/Label for the Email input.",
41
+ messageLabel: "Placeholder/Label for the Message textarea.",
42
+ submitText: "Text for the form submit button.",
43
+ action: "Where the button routes the user.",
44
+ form: "Which public form to open. Note: Form submissions will securely map to the Submissions tab in your admin dashboard.",
45
+ url: "External http link or internal /route destination."
46
+ };
47
+
48
+ export let sharedEnums: Record<string, string[]> = {
49
+ defaultTheme: ['system', 'light', 'dark'],
50
+ cornerRounding: ['default', 'custom'],
51
+ action: ['sign_in', 'register', 'redirect', 'form'],
52
+ type: ['text', 'email', 'tel', 'textarea'],
53
+ form: ['contact'],
54
+ formType: ['public', 'private'],
55
+ submitPage: ['submissions', 'requests'],
56
+ fontFamily: ['Inter', 'Outfit', 'Roboto', 'Space Grotesk', 'Plus Jakarta Sans', 'System UI']
57
+ };
58
+
59
+
60
+ if (db) {
61
+ getDocs(collection(db, 'sys_forms')).then(snap => {
62
+ const forms: string[] = [];
63
+ snap.forEach(d => {
64
+ if (d.data().formType !== 'private') forms.push(d.id);
65
+ });
66
+ if (forms.length > 0) Object.assign(sharedEnums, { form: forms });
67
+ }).catch(() => {});
68
+ }
69
+
70
+ export const AutoResizeInput = ({ value, onChange, onBlur, onFocus, className, isNumber, handleBlurOrEnter, placeholder, disabled }: any) => {
71
+ const [localVal, setLocalVal] = React.useState(value);
72
+ const [isFocused, setIsFocused] = React.useState(false);
73
+ const [inputWidth, setInputWidth] = React.useState(0);
74
+ const inputRef = React.useRef<HTMLInputElement>(null);
75
+ const spanRef = React.useRef<HTMLSpanElement>(null);
76
+
77
+ React.useEffect(() => { setLocalVal(value); }, [value]);
78
+
79
+ React.useLayoutEffect(() => {
80
+ if (spanRef.current) {
81
+ // Perfect hugging width.
82
+ setInputWidth(spanRef.current.getBoundingClientRect().width);
83
+ }
84
+ }, [localVal, placeholder]);
85
+
86
+ // If text or link is pasted into quotes, make sure to always scroll to the left
87
+ useEffect(() => {
88
+ if (!isFocused && inputRef.current) {
89
+ inputRef.current.scrollLeft = 0;
90
+ }
91
+ }, [localVal, isFocused]);
92
+
93
+ // Strip max-width from input and pass it to wrapper
94
+ const wrapperClassMatch = typeof className === 'string' ? className.match(/(?:max-w-\[[^\]]+\]|sm:max-w-\[[^\]]+\]|md:max-w-\[[^\]]+\])\s*/g) : null;
95
+ const wrapperClasses = wrapperClassMatch ? wrapperClassMatch.join(' ') : 'max-w-[200px] sm:max-w-[300px]';
96
+ const spanClass = typeof className === 'string' ? className.replace(/(?:max-w-\[[^\]]+\]|sm:max-w-\[[^\]]+\]|md:max-w-\[[^\]]+\]|text-ellipsis)\s*/g, '') : '';
97
+ const inputCleanClass = spanClass.trim();
98
+
99
+ return (
100
+ <div className={`relative inline-flex flex-col group/autoresize ${wrapperClasses}`}>
101
+ <div className="flex-1 overflow-x-auto overflow-y-hidden pb-[1px] styled-scrollbars-mini flex items-center">
102
+ <span
103
+ ref={spanRef}
104
+ className={`${spanClass} absolute opacity-0 pointer-events-none whitespace-pre border-none p-0 inline-block`}
105
+ style={{ width: 'max-content', minWidth: 'max-content' }}
106
+ aria-hidden="true"
107
+ >
108
+ {localVal || placeholder || (isNumber ? '0' : ' ')}
109
+ </span>
110
+ <input
111
+ disabled={disabled}
112
+ ref={inputRef}
113
+ type={isNumber ? "number" : "text"}
114
+ value={localVal}
115
+ placeholder={placeholder}
116
+ spellCheck={false}
117
+ className={`${inputCleanClass} transition-all duration-200 ${disabled ? 'cursor-not-allowed opacity-50 select-none' : ''} ${isFocused ? 'relative z-50 bg-background shadow-md' : ''}`}
118
+ onDoubleClick={(e) => { if (!disabled) e.currentTarget.select(); }}
119
+ onPaste={() => {
120
+ setTimeout(() => {
121
+ if (inputRef.current) inputRef.current.scrollLeft = 0;
122
+ }, 10);
123
+ }}
124
+ onChange={(e) => {
125
+ if (!disabled) setLocalVal(e.target.value);
126
+ }}
127
+ onKeyDown={(e) => {
128
+ if (disabled) return;
129
+ if (e.key === 'Enter') {
130
+ const finalVal = isNumber ? parseFloat(e.currentTarget.value) || 0 : e.currentTarget.value;
131
+ if (finalVal !== value) onChange(finalVal);
132
+ if (handleBlurOrEnter) handleBlurOrEnter(e);
133
+ e.currentTarget.blur();
134
+ }
135
+ }}
136
+ onBlur={(e) => {
137
+ if (disabled) return;
138
+ setIsFocused(false);
139
+ const finalVal = isNumber ? parseFloat(e.currentTarget.value) || 0 : e.currentTarget.value;
140
+ if (finalVal !== value) onChange(finalVal);
141
+ if (handleBlurOrEnter) handleBlurOrEnter(e);
142
+ if (onBlur) onBlur(e);
143
+ e.currentTarget.scrollLeft = 0;
144
+ }}
145
+ onFocus={(e) => {
146
+ if (disabled) return;
147
+ setIsFocused(true);
148
+ if (onFocus) onFocus(e);
149
+ }}
150
+ style={{
151
+ width: inputWidth > 0 ? `${inputWidth + 6}px` : 'auto',
152
+ minWidth: '20px',
153
+ maxWidth: isFocused ? 'none' : undefined
154
+ }}
155
+ />
156
+ </div>
157
+ </div>
158
+ );
159
+ };
160
+
161
+
162
+
163
+ export const JsonNode = ({ objectKey, displayKey, value, onChange, depth = 0, isLast = false, keysToRender, isGlobalSaving, customExplanations, customEnums, disabledKeys = [], hideInnerLoader = false, meta = {} }: any) => {
164
+ const renderedKey = displayKey || objectKey;
165
+ const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
166
+ const isArray = Array.isArray(value);
167
+ const [showPicker, setShowPicker] = useState<number | boolean>(false);
168
+ const [isFolded, setIsFolded] = useState<boolean>(objectKey === 'colors' || objectKey === 'fields');
169
+ const [showLoader, setShowLoader] = useState(false);
170
+ const [urlError, setUrlError] = useState(false);
171
+ const focusValRef = React.useRef<string | null>(null);
172
+ const [pendingEnum, setPendingEnum] = React.useState<string | null>(null);
173
+ const enumRef = React.useRef<HTMLDivElement>(null);
174
+
175
+ useEffect(() => {
176
+ if (!isGlobalSaving) {
177
+ setShowLoader(false);
178
+ }
179
+ }, [isGlobalSaving]);
180
+
181
+ const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
182
+ focusValRef.current = e.currentTarget.value;
183
+ };
184
+
185
+ const handleBlurOrEnter = (e: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) => {
186
+ if ('key' in e && e.key === 'Enter') e.currentTarget.blur();
187
+ if (('type' in e && e.type === 'blur') || ('key' in e && e.key === 'Enter')) {
188
+ if (focusValRef.current !== null && focusValRef.current !== e.currentTarget.value) {
189
+ if (!hideInnerLoader) setShowLoader(true);
190
+ }
191
+ focusValRef.current = null;
192
+ }
193
+ };
194
+
195
+ React.useEffect(() => { setPendingEnum(null); }, [value]);
196
+
197
+ const explanation = customExplanations?.[objectKey] || explanations[objectKey as keyof typeof explanations];
198
+ const enumOptions = customEnums?.[objectKey] || sharedEnums[objectKey as keyof typeof sharedEnums];
199
+ const isFieldDisabled = Array.isArray(disabledKeys) && disabledKeys.includes(objectKey);
200
+
201
+ const renderWrapper = (children: React.ReactNode) => (
202
+ <div className="flex flex-col text-[14px] font-mono leading-relaxed my-0.5" style={{ marginLeft: depth > 0 ? 32 : 0 }}>
203
+ {explanation && (
204
+ <span className="text-foreground/40 text-[12px] font-mono italic select-none mt-2 mb-0.5 flex flex-wrap items-center gap-1">
205
+ {"// "}
206
+ {explanation.split(/(https?:\/\/[^\s,]+)/g).map((part: string, i: number) => {
207
+ if (part.startsWith('http')) {
208
+ 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>;
209
+ }
210
+ return <span key={i}>{part}</span>;
211
+ })}
212
+ {(objectKey === 'logoUrl' || objectKey === 'faviconUrl') && (
213
+ <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">
214
+ Open Drive / Public
215
+ </Link>
216
+ )}
217
+ </span>
218
+ )}
219
+ {children}
220
+ </div>
221
+ );
222
+
223
+ if (isObject) {
224
+ const keys = Object.keys(value);
225
+
226
+ let mappedKeys = keysToRender ? [...keysToRender] : keys;
227
+
228
+ mappedKeys = mappedKeys.filter(k => {
229
+ if (k === 'showThemeToggle' && value['defaultTheme'] !== 'system') return false;
230
+ if (k === 'cornerRadii' && value['cornerRounding'] !== 'custom') return false;
231
+ if (k === 'url' && 'action' in value && value['action'] !== 'redirect') return false;
232
+ if (k === 'form' && 'action' in value && value['action'] !== 'form') return false;
233
+ if (k === 'currencySymbol' && value['type'] !== 'currency') return false;
234
+ if (k === 'options' && value['type'] !== 'select') return false;
235
+ return true;
236
+ });
237
+
238
+ if (value['action'] === 'redirect' && !mappedKeys.includes('url') && value['showButton'] !== false) {
239
+ mappedKeys.push('url');
240
+ }
241
+ if (value['action'] === 'form' && !mappedKeys.includes('form') && value['showButton'] !== false) {
242
+ mappedKeys.push('form');
243
+ }
244
+ if (value['type'] === 'currency' && !mappedKeys.includes('currencySymbol')) {
245
+ mappedKeys.push('currencySymbol');
246
+ }
247
+ if (value['type'] === 'select' && !mappedKeys.includes('options')) {
248
+ mappedKeys.push('options');
249
+ }
250
+
251
+ return renderWrapper(
252
+ <div className="flex flex-col group/obj">
253
+ {objectKey ? (
254
+ <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)}>
255
+ <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"}>
256
+ {isFolded ? '►' : '▼'}
257
+ </button>
258
+ <span className="text-accent font-extrabold">"{renderedKey}"</span><span className="text-foreground/50">:</span> <span className="text-foreground/70 font-bold">{isFolded ? '{...}' : '{'}</span>
259
+ </div>
260
+ ) : depth > 0 ? (
261
+ <div className="text-foreground/70 font-bold px-2">{'{'}</div>
262
+ ) : null}
263
+ {!isFolded && (
264
+ <div className={`flex flex-col ${depth > 0 ? 'border-l border-[var(--panel-border)]/30 ml-2 pl-1' : ''}`}>
265
+ {mappedKeys.map((k, idx, arr) => {
266
+ if (k === 'subtitle' && value['showSubtitle'] === false) return null;
267
+ if (k === 'url' && 'action' in value && value['action'] !== 'redirect') return null;
268
+ if (k === 'form' && 'action' in value && value['action'] !== 'form') return null;
269
+
270
+ return (
271
+ <JsonNode
272
+ key={k}
273
+ objectKey={k}
274
+ value={value[k]}
275
+ onChange={(newVal: any) => {
276
+ const newObj = { ...value, [k]: newVal };
277
+ if (k === 'action' && newVal === 'form' && !newObj.form) newObj.form = 'contact';
278
+ if (k === 'action' && newVal === 'redirect' && !newObj.url) newObj.url = '/';
279
+ if (k === 'type' && newVal === 'currency' && !newObj.currencySymbol) newObj.currencySymbol = '$';
280
+ if (k === 'type' && newVal === 'select' && !newObj.options) newObj.options = ['Option 1', 'Option 2'];
281
+ onChange(newObj);
282
+ }}
283
+ depth={depth + 1}
284
+ isLast={idx === arr.length - 1}
285
+ isGlobalSaving={isGlobalSaving}
286
+ customExplanations={customExplanations}
287
+ customEnums={customEnums}
288
+ disabledKeys={disabledKeys}
289
+ hideInnerLoader={hideInnerLoader}
290
+ meta={meta}
291
+ />
292
+ );
293
+ })}
294
+ </div>
295
+ )}
296
+ {objectKey && !isFolded && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{'}'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
297
+ {objectKey && isFolded && !isLast && <span className="text-foreground/50 -mt-1 ml-2">,</span>}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ if (isArray) {
303
+ return renderWrapper(
304
+ <div className="flex flex-col group/arr">
305
+ {objectKey ? (
306
+ <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)}>
307
+ <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"}>
308
+ {isFolded ? '►' : '▼'}
309
+ </button>
310
+ <span className="text-accent font-extrabold">"{renderedKey}"</span><span className="text-foreground/50">:</span> <span className="text-foreground/70 font-bold">{isFolded ? '[...]' : '['}</span>
311
+ </div>
312
+ ) : (
313
+ <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">
314
+ <span className="text-foreground/70 font-bold">{'['}</span>
315
+ </div>
316
+ )}
317
+ {!isFolded && (
318
+ <div className="flex flex-col border-l border-[var(--panel-border)]/30 ml-2 mt-1 pl-1" style={{ marginLeft: 32 }}>
319
+ {value.map((item: any, i: number) => {
320
+ if (item !== null && typeof item === 'object') {
321
+ return (
322
+ <div key={i} className="pl-2 border-l border-[var(--panel-border)]/20 mt-1 mb-1 relative group/arr-item">
323
+ <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">
324
+ ×
325
+ </button>
326
+ <JsonNode
327
+ value={item}
328
+ onChange={(newVal: any) => {
329
+ const arr = [...value];
330
+ arr[i] = newVal;
331
+ onChange(arr);
332
+ }}
333
+ depth={depth + 1}
334
+ isLast={i === value.length - 1}
335
+ isGlobalSaving={isGlobalSaving}
336
+ customExplanations={customExplanations}
337
+ customEnums={customEnums}
338
+ disabledKeys={disabledKeys}
339
+ />
340
+ </div>
341
+ );
342
+ }
343
+
344
+ const isColorString = typeof item === 'string' && (item.startsWith('#') || item.startsWith('rgba') || item.startsWith('rgb'));
345
+ return (
346
+ <div key={i} className="flex items-center gap-1 group/item w-fit relative z-10 my-0.5">
347
+ <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">
348
+ ×
349
+ </button>
350
+ <span className="text-foreground/40 text-[11px] w-4 select-none font-bold">{i}:</span>
351
+ <span className="text-foreground/40 shrink-0">"</span>
352
+ {isColorString && (
353
+ <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)]">
354
+ <input
355
+ type="color"
356
+ value={item.length === 7 && item.startsWith('#') ? item : '#000000'}
357
+ onChange={(e) => {
358
+ const arr = [...value];
359
+ arr[i] = e.target.value;
360
+ onChange(arr);
361
+ }}
362
+ className="absolute -inset-8 w-24 h-24 cursor-pointer opacity-0"
363
+ />
364
+ <div className="absolute inset-0 pointer-events-none transition-transform group-hover/item:scale-110" style={{ backgroundColor: item }} />
365
+ </div>
366
+ )}
367
+ <AutoResizeInput
368
+ value={item}
369
+ isNumber={typeof item === 'number'}
370
+ onChange={(newVal: any) => {
371
+ const arr = [...value];
372
+ arr[i] = newVal;
373
+ onChange(arr);
374
+ }}
375
+ onFocus={handleFocus}
376
+ handleBlurOrEnter={handleBlurOrEnter}
377
+ 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"
378
+ />
379
+ <span className="text-foreground/40 shrink-0">"</span>
380
+ <span className="text-foreground/50 shrink-0">{i < value.length - 1 ? ',' : ''}</span>
381
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 shrink-0" />}
382
+ </div>
383
+ );
384
+ })}
385
+
386
+ {(!(value.length > 0 && typeof value[0] === 'string' && (value[0].startsWith('#') || value[0].startsWith('rgba') || value[0].startsWith('color-mix'))) || value.length < 6) && (
387
+ <button
388
+ onClick={() => {
389
+ const arr = [...value];
390
+ let clone: any = '';
391
+ if (objectKey === 'fields') {
392
+ const existingNames = arr.map(f => f.name);
393
+
394
+ if (meta?.formType === 'private') {
395
+ if (!existingNames.includes('issue_type')) {
396
+ clone = { type: 'select', name: 'issue_type', label: 'Issue Type', required: true, options: ['Bug Report', 'Feature Request', 'Billing', 'General Question'] };
397
+ } else if (!existingNames.includes('urgency')) {
398
+ clone = { type: 'select', name: 'urgency', label: 'Urgency Level', required: true, options: ['Low', 'Medium', 'High', 'Critical'] };
399
+ } else if (!existingNames.includes('description')) {
400
+ clone = { type: 'textarea', name: 'description', label: 'Description', required: true, placeholder: 'Please provide detailed information...' };
401
+ } else if (!existingNames.includes('device')) {
402
+ clone = { type: 'text', name: 'device', label: 'Device / OS', required: false, placeholder: 'e.g., iPhone 15, Windows 11' };
403
+ } else if (!existingNames.includes('subscribe')) {
404
+ clone = { type: 'checkbox', name: 'subscribe', label: 'Receive status updates via email', required: false };
405
+ } else {
406
+ clone = { type: 'text', name: `custom_field_${arr.length}`, label: 'Custom Field', required: false, placeholder: '' };
407
+ }
408
+ } else {
409
+ if (!existingNames.includes('name')) {
410
+ clone = { type: 'text', name: 'name', label: 'Full Name', required: true, placeholder: 'Enter your name' };
411
+ } else if (!existingNames.includes('email')) {
412
+ clone = { type: 'email', name: 'email', label: 'Email Address', required: true, placeholder: 'you@example.com' };
413
+ } else if (!existingNames.includes('phone')) {
414
+ clone = { type: 'tel', name: 'phone', label: 'Phone Number', required: false, placeholder: '+1 (555) 000-0000' };
415
+ } else if (!existingNames.includes('questions')) {
416
+ clone = { type: 'textarea', name: 'questions', label: 'Questions', required: false, placeholder: 'How can we help you?' };
417
+ } else if (!existingNames.includes('budget')) {
418
+ clone = { type: 'currency', name: 'budget', label: 'Budget', required: false, placeholder: 'e.g. 5000', currencySymbol: '$' };
419
+ } else if (!existingNames.includes('service_type')) {
420
+ clone = { type: 'select', name: 'service_type', label: 'Service Interested In', required: false, options: ['Consulting', 'Development', 'Design'] };
421
+ } else if (!existingNames.includes('subscribe')) {
422
+ clone = { type: 'checkbox', name: 'subscribe', label: 'Subscribe to newsletter', required: false };
423
+ } else if (!existingNames.includes('age')) {
424
+ clone = { type: 'age', name: 'age', label: 'Age', required: false, placeholder: 'e.g. 25' };
425
+ } else {
426
+ clone = { type: 'text', name: `custom_field_${arr.length}`, label: 'Custom Field', required: false, placeholder: '' };
427
+ }
428
+ }
429
+ } else if (typeof arr[0] === 'string') {
430
+ const genericServices = ['Marketing', 'Support', 'Analysis', 'Strategy', 'Maintenance'];
431
+ clone = genericServices[Math.min(arr.length, genericServices.length) - 1] || `Option ${arr.length + 1}`;
432
+ } else {
433
+ clone = arr.length > 0 ? JSON.parse(JSON.stringify(arr[arr.length - 1])) : '';
434
+ if (clone && typeof clone === 'object') {
435
+ if (clone.name) clone.name = `${clone.name}_${arr.length}`;
436
+ if (clone.label) clone.label = `${clone.label} ${arr.length}`;
437
+ }
438
+ }
439
+ arr.push(clone);
440
+ onChange(arr);
441
+ }}
442
+ 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"
443
+ >
444
+ + Add Item
445
+ </button>
446
+ )}
447
+ </div>
448
+ )}
449
+ {objectKey && !isFolded && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{']'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
450
+ {!objectKey && <div className="px-2 mt-1"><span className="text-foreground/70 font-bold">{']'}</span>{!isLast && <span className="text-foreground/50">,</span>}</div>}
451
+ {objectKey && isFolded && !isLast && <span className="text-foreground/50 -mt-1 ml-2">,</span>}
452
+ </div>
453
+ );
454
+ }
455
+
456
+ if (typeof value === 'boolean') {
457
+ return renderWrapper(
458
+ <div className="flex items-center gap-1 group w-fit relative z-10 my-0.5">
459
+ <span className="text-accent font-extrabold shrink-0">"{renderedKey}"</span><span className="text-foreground/50 shrink-0">:</span>
460
+ <button
461
+ disabled={isFieldDisabled || showLoader}
462
+ onClick={() => {
463
+ if (!hideInnerLoader) setShowLoader(true);
464
+ onChange(!value);
465
+ }}
466
+ 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]'}`}
467
+ >
468
+ {value ? 'true' : 'false'}
469
+ </button>
470
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1" />}
471
+ {!isLast && <span className="text-foreground/50">,</span>}
472
+ </div>
473
+ );
474
+ }
475
+
476
+ const isColorString = typeof value === 'string' && (value.startsWith('#') || value.startsWith('rgba') || value.startsWith('rgb'));
477
+
478
+ const isUrl = typeof value === 'string' && objectKey && String(objectKey).endsWith('Url');
479
+
480
+ return renderWrapper(
481
+ <div className={`flex items-center gap-1 group relative z-10 my-0.5 ${isUrl ? 'w-full pr-0 sm:pr-8' : 'w-fit'}`}>
482
+ <span className="text-accent font-extrabold shrink-0">"{renderedKey}"</span><span className="text-foreground/50 shrink-0">:</span>
483
+
484
+ {enumOptions ? (
485
+ <div className="flex items-center gap-1 ml-1 relative group/enum" ref={enumRef}>
486
+ <span className="text-foreground/40">"</span>
487
+ <button
488
+ disabled={isFieldDisabled || showLoader}
489
+ title={isFieldDisabled ? "Cannot be edited" : "Click to cycle options"}
490
+ tabIndex={0}
491
+ onClick={() => {
492
+ if (isFieldDisabled) return;
493
+ const currentVal = pendingEnum !== null ? pendingEnum : value;
494
+ const currentIndex = enumOptions.indexOf(currentVal);
495
+ const nextVal = enumOptions[(currentIndex + 1) % enumOptions.length];
496
+ setPendingEnum(nextVal);
497
+ if (!hideInnerLoader) setShowLoader(true);
498
+ onChange(nextVal);
499
+ }}
500
+ className={`${
501
+ (isFieldDisabled || showLoader)
502
+ ? 'bg-foreground/5 text-foreground/40 cursor-not-allowed opacity-50'
503
+ : 'bg-accent/10 hover:bg-accent/20 text-accent cursor-pointer shadow-sm hover:scale-[1.02] active:scale-[0.98]'
504
+ } outline-none px-3 py-1 rounded-md transition-all font-extrabold uppercase tracking-wider text-[11px] border border-transparent`}
505
+ >
506
+ {pendingEnum !== null ? pendingEnum : value}
507
+ </button>
508
+ {showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1" />}
509
+ <span className="text-foreground/40">"</span>
510
+ </div>
511
+ ) : (
512
+ <div className="flex items-center gap-0.5 ml-1 flex-1 min-w-0">
513
+ <span className="text-foreground/40 shrink-0">"</span>
514
+
515
+ {isColorString && (
516
+ <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)]">
517
+ <input
518
+ type="color"
519
+ value={value.length === 7 && value.startsWith('#') ? value : '#000000'}
520
+ onFocus={handleFocus}
521
+ onBlur={(e) => {
522
+ onChange(e.target.value);
523
+ focusValRef.current = null;
524
+ }}
525
+ onChange={(e) => onChange(e.target.value)}
526
+ className="absolute -inset-8 w-24 h-24 cursor-pointer opacity-0"
527
+ />
528
+ <div className="absolute inset-0 pointer-events-none transition-transform group-hover/item:scale-110" style={{ backgroundColor: value }} />
529
+ </div>
530
+ )}
531
+
532
+ <AutoResizeInput
533
+ disabled={isFieldDisabled}
534
+ value={value}
535
+ isNumber={typeof value === 'number'}
536
+ placeholder={isUrl ? "https://..." : undefined}
537
+ 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' : ''}`}
538
+ onFocus={handleFocus}
539
+ handleBlurOrEnter={(e: any) => {
540
+ let val = e.currentTarget.value;
541
+ if (isUrl && val && val !== 'none' && !val.includes('http') && !val.includes('www.') && !val.startsWith('/')) {
542
+ val = '';
543
+ onChange('');
544
+ setUrlError(true);
545
+ setTimeout(() => setUrlError(false), 2500);
546
+ }
547
+ if (focusValRef.current !== null && focusValRef.current !== val) {
548
+ setShowLoader(true);
549
+ }
550
+ focusValRef.current = null;
551
+ handleBlurOrEnter(e);
552
+ }}
553
+ onChange={(newVal: any) => onChange(newVal)}
554
+ />
555
+ <span className="text-foreground/40 shrink-0">"</span>
556
+ {urlError ? (
557
+ <span className="text-red-500 font-bold ml-1.5 text-[10px] tracking-widest uppercase">invalid</span>
558
+ ) : (
559
+ showLoader && <Loader2 className="w-3 h-3 animate-spin text-foreground/40 ml-1.5 shrink-0" />
560
+ )}
561
+
562
+ {(objectKey === 'logoUrl' || objectKey === 'faviconUrl') && (
563
+ <button
564
+ onClick={() => onChange(objectKey === 'logoUrl' ? themeConfig.logoUrl : themeConfig.faviconUrl)}
565
+ 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"
566
+ title="Reset to default icon"
567
+ >✕</button>
568
+ )}
569
+ </div>
570
+ )}
571
+ {!isLast && <span className="text-foreground/50 shrink-0">,</span>}
572
+ {objectKey && ["inputRadius", "modalRadius", "badgeRadius", "buttonRadius", "cardRadius"].includes(objectKey) && (
573
+ <span className="text-foreground/30 text-[10px] font-medium ml-2 tracking-wide whitespace-nowrap hidden md:inline-block pointer-events-none">
574
+ // {objectKey === 'inputRadius' ? 'Forms & text fields' : objectKey === 'modalRadius' ? 'Popups & dialogs' : objectKey === 'badgeRadius' ? 'Tags & pills' : objectKey === 'buttonRadius' ? 'Interactive buttons' : 'Big panels & cards'}
575
+ </span>
576
+ )}
577
+ </div>
578
+ );
579
+ };