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