mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
Added custom json edit to config tab
This commit is contained in:
@@ -21,7 +21,7 @@ import '../editor/src/styles/custom-properties/spacing.css';
|
||||
import Router from './src/router';
|
||||
|
||||
// Build canary: Verify fresh code is loading
|
||||
console.log('🔥 BUILD TIMESTAMP:', new Date().toISOString());
|
||||
console.log('🔥🔥 BUILD TIMESTAMP:', new Date().toISOString());
|
||||
|
||||
ipcRenderer.on('open-noodl-uri', async (event, uri) => {
|
||||
if (uri.startsWith('noodl:import/http')) {
|
||||
|
||||
@@ -881,6 +881,82 @@ export class ProjectModel extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
// App Configuration Methods
|
||||
/**
|
||||
* Gets the app configuration from project metadata.
|
||||
* @returns The app config object
|
||||
*/
|
||||
getAppConfig() {
|
||||
// Import types dynamically to avoid circular dependencies
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { DEFAULT_APP_CONFIG } = require('@noodl/runtime/src/config/types');
|
||||
return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the app configuration in project metadata.
|
||||
* @param config - The app config to save
|
||||
*/
|
||||
setAppConfig(config) {
|
||||
this.setMetaData('appConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the app configuration with partial values.
|
||||
* @param updates - Partial app config updates
|
||||
*/
|
||||
updateAppConfig(updates) {
|
||||
const current = this.getAppConfig();
|
||||
this.setAppConfig({
|
||||
...current,
|
||||
...updates,
|
||||
identity: {
|
||||
...current.identity,
|
||||
...(updates.identity || {})
|
||||
},
|
||||
seo: {
|
||||
...current.seo,
|
||||
...(updates.seo || {})
|
||||
},
|
||||
pwa: updates.pwa ? { ...current.pwa, ...updates.pwa } : current.pwa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all config variables.
|
||||
* @returns Array of config variables
|
||||
*/
|
||||
getConfigVariables() {
|
||||
return this.getAppConfig().variables || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or updates a config variable.
|
||||
* @param variable - The config variable to set
|
||||
*/
|
||||
setConfigVariable(variable) {
|
||||
const config = this.getAppConfig();
|
||||
const index = config.variables.findIndex((v) => v.key === variable.key);
|
||||
|
||||
if (index >= 0) {
|
||||
config.variables[index] = variable;
|
||||
} else {
|
||||
config.variables.push(variable);
|
||||
}
|
||||
|
||||
this.setAppConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a config variable by key.
|
||||
* @param key - The variable key to remove
|
||||
*/
|
||||
removeConfigVariable(key: string) {
|
||||
const config = this.getAppConfig();
|
||||
config.variables = config.variables.filter((v) => v.key !== key);
|
||||
this.setAppConfig(config);
|
||||
}
|
||||
|
||||
createNewVariant(name, node) {
|
||||
let variant = this.variants.find((v) => v.name === name && v.typename === node.type.localName);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import config from '../../shared/config/config';
|
||||
import { ComponentDiffDocumentProvider } from './views/documents/ComponentDiffDocument';
|
||||
import { EditorDocumentProvider } from './views/documents/EditorDocument';
|
||||
import { AppSetupPanel } from './views/panels/AppSetupPanel/AppSetupPanel';
|
||||
import { BackendServicesPanel } from './views/panels/BackendServicesPanel/BackendServicesPanel';
|
||||
import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFunctionsPanel';
|
||||
import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel';
|
||||
@@ -148,6 +149,15 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: BackendServicesPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'app-setup',
|
||||
name: 'App Setup',
|
||||
isDisabled: isLesson === true,
|
||||
order: 8.5,
|
||||
icon: IconName.Sliders,
|
||||
panel: AppSetupPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'settings',
|
||||
name: 'Project settings',
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface createModelOptions {
|
||||
* Create the Monaco Model, with better typings etc
|
||||
*/
|
||||
export function createModel(options: createModelOptions, node: NodeGraphNode): EditorModel {
|
||||
// Simple JSON editing - use plaintext (no workers needed)
|
||||
// Monaco workers require initialization from node context; plaintext avoids this.
|
||||
// JSON validation happens on save via JSON.parse()
|
||||
if (options.codeeditor === 'json') {
|
||||
return new EditorModel(monaco.editor.createModel(options.value, 'plaintext'));
|
||||
}
|
||||
|
||||
// arrays are edited as javascript (and eval:ed during runtime)
|
||||
// we are not going to add any extra typings here.
|
||||
if (options.type === 'array') {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
|
||||
|
||||
import { IdentitySection } from './sections/IdentitySection';
|
||||
import { PWASection } from './sections/PWASection';
|
||||
import { SEOSection } from './sections/SEOSection';
|
||||
import { VariablesSection } from './sections/VariablesSection';
|
||||
|
||||
export function AppSetupPanel() {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Listen for metadata changes to refresh the panel
|
||||
useEventListener(
|
||||
ProjectModel.instance,
|
||||
'ProjectModel.metadataChanged',
|
||||
useCallback((data: { key: string }) => {
|
||||
if (data.key === 'appConfig') {
|
||||
forceUpdate((prev) => prev + 1);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const config = ProjectModel.instance.getAppConfig();
|
||||
|
||||
const updateIdentity = useCallback((updates: Partial<typeof config.identity>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
identity: { ...currentConfig.identity, ...updates }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSEO = useCallback((updates: Partial<typeof config.seo>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
seo: { ...currentConfig.seo, ...updates }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updatePWA = useCallback((updates: Partial<NonNullable<typeof config.pwa>>) => {
|
||||
const currentConfig = ProjectModel.instance.getAppConfig();
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
pwa: { ...(currentConfig.pwa || {}), ...updates } as NonNullable<typeof config.pwa>
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateVariables = useCallback((variables: typeof config.variables) => {
|
||||
// Defer the update to avoid re-render race condition
|
||||
setTimeout(() => {
|
||||
ProjectModel.instance.updateAppConfig({
|
||||
variables
|
||||
});
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BasePanel title="App Setup" hasContentScroll>
|
||||
<IdentitySection identity={config.identity} onChange={updateIdentity} />
|
||||
|
||||
<SEOSection seo={config.seo} identity={config.identity} onChange={updateSEO} />
|
||||
|
||||
<PWASection pwa={config.pwa} onChange={updatePWA} />
|
||||
|
||||
<VariablesSection variables={config.variables} onChange={updateVariables} />
|
||||
</BasePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppIdentity } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface IdentitySectionProps {
|
||||
identity: AppIdentity;
|
||||
onChange: (updates: Partial<AppIdentity>) => void;
|
||||
}
|
||||
|
||||
export function IdentitySection({ identity, onChange }: IdentitySectionProps) {
|
||||
// Local state for immediate textarea updates
|
||||
const [localDescription, setLocalDescription] = useState(identity.description || '');
|
||||
|
||||
// Sync local state when external identity changes
|
||||
useEffect(() => {
|
||||
setLocalDescription(identity.description || '');
|
||||
}, [identity.description]);
|
||||
|
||||
return (
|
||||
<CollapsableSection title="App Identity" hasGutter hasVisibleOverflow>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
App Name
|
||||
</label>
|
||||
<PropertyPanelTextInput value={identity.appName || ''} onChange={(value) => onChange({ appName: value })} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={localDescription}
|
||||
onChange={(e) => {
|
||||
setLocalDescription(e.target.value);
|
||||
onChange({ description: e.target.value });
|
||||
}}
|
||||
placeholder="Describe your app..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Cover Image
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={identity.coverImage || ''}
|
||||
onChange={(value) => onChange({ coverImage: value })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to cover image in project
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppPWA } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface PWASectionProps {
|
||||
pwa: AppPWA | undefined;
|
||||
onChange: (updates: Partial<AppPWA>) => void;
|
||||
}
|
||||
|
||||
const DISPLAY_MODES = [
|
||||
{ value: 'standalone', label: 'Standalone (Recommended)' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'minimal-ui', label: 'Minimal UI' },
|
||||
{ value: 'browser', label: 'Browser' }
|
||||
] as const;
|
||||
|
||||
export function PWASection({ pwa, onChange }: PWASectionProps) {
|
||||
// Local state for immediate UI updates
|
||||
const [localEnabled, setLocalEnabled] = useState(pwa?.enabled || false);
|
||||
const [localDisplay, setLocalDisplay] = useState<AppPWA['display']>(pwa?.display || 'standalone');
|
||||
const [localBgColor, setLocalBgColor] = useState(pwa?.backgroundColor || '#ffffff');
|
||||
|
||||
// Sync local state when external pwa changes
|
||||
useEffect(() => {
|
||||
setLocalEnabled(pwa?.enabled || false);
|
||||
}, [pwa?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDisplay(pwa?.display || 'standalone');
|
||||
}, [pwa?.display]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalBgColor(pwa?.backgroundColor || '#ffffff');
|
||||
}, [pwa?.backgroundColor]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!localEnabled) {
|
||||
// Enable with defaults
|
||||
setLocalEnabled(true); // Immediate UI update
|
||||
onChange({
|
||||
enabled: true,
|
||||
startUrl: '/',
|
||||
display: 'standalone'
|
||||
});
|
||||
} else {
|
||||
// Disable
|
||||
setLocalEnabled(false); // Immediate UI update
|
||||
onChange({ enabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Progressive Web App" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
{/* Enable PWA Toggle */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localEnabled}
|
||||
onChange={handleToggle}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
Enable Progressive Web App
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px',
|
||||
marginLeft: '24px'
|
||||
}}
|
||||
>
|
||||
Allow users to install your app on their device
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PWA Configuration Fields - Only shown when enabled */}
|
||||
{localEnabled && (
|
||||
<>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Short Name
|
||||
</label>
|
||||
<PropertyPanelTextInput value={pwa?.shortName || ''} onChange={(value) => onChange({ shortName: value })} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Name shown on home screen (12 chars max recommended)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Start URL
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={pwa?.startUrl || '/'}
|
||||
onChange={(value) => onChange({ startUrl: value || '/' })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
URL the app opens to (default: /)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Display Mode
|
||||
</label>
|
||||
<select
|
||||
value={localDisplay}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value as AppPWA['display'];
|
||||
setLocalDisplay(newValue);
|
||||
onChange({ display: newValue });
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{DISPLAY_MODES.map((mode) => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
How the app appears when launched
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Background Color
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={localBgColor}
|
||||
onChange={(e) => {
|
||||
setLocalBgColor(e.target.value);
|
||||
onChange({ backgroundColor: e.target.value });
|
||||
}}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput
|
||||
value={localBgColor}
|
||||
onChange={(value) => {
|
||||
setLocalBgColor(value);
|
||||
onChange({ backgroundColor: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Splash screen background color
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Source Icon
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={pwa?.sourceIcon || ''}
|
||||
onChange={(value) => onChange({ sourceIcon: value })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to 512x512 icon (generates all sizes automatically)
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
interface SEOSectionProps {
|
||||
seo: AppSEO;
|
||||
identity: AppIdentity;
|
||||
onChange: (updates: Partial<AppSEO>) => void;
|
||||
}
|
||||
|
||||
export function SEOSection({ seo, identity, onChange }: SEOSectionProps) {
|
||||
// Local state for immediate UI updates
|
||||
const [localOgDescription, setLocalOgDescription] = useState(seo.ogDescription || '');
|
||||
const [localThemeColor, setLocalThemeColor] = useState(seo.themeColor || '#000000');
|
||||
|
||||
// Sync local state when external seo changes
|
||||
useEffect(() => {
|
||||
setLocalOgDescription(seo.ogDescription || '');
|
||||
}, [seo.ogDescription]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalThemeColor(seo.themeColor || '#000000');
|
||||
}, [seo.themeColor]);
|
||||
|
||||
// Smart defaults from identity
|
||||
const defaultOgTitle = identity.appName || '';
|
||||
const defaultOgDescription = identity.description || '';
|
||||
const defaultOgImage = identity.coverImage || '';
|
||||
|
||||
return (
|
||||
<CollapsableSection title="SEO & Metadata" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Title
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={seo.ogTitle || ''}
|
||||
onChange={(value) => onChange({ ogTitle: value || undefined })}
|
||||
/>
|
||||
{!seo.ogTitle && defaultOgTitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Description
|
||||
</label>
|
||||
<textarea
|
||||
value={localOgDescription}
|
||||
onChange={(e) => {
|
||||
setLocalOgDescription(e.target.value);
|
||||
onChange({ ogDescription: e.target.value || undefined });
|
||||
}}
|
||||
placeholder="Enter description..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
{!seo.ogDescription && defaultOgDescription && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgDescription.substring(0, 50)}
|
||||
{defaultOgDescription.length > 50 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Open Graph Image
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={seo.ogImage || ''}
|
||||
onChange={(value) => onChange({ ogImage: value || undefined })}
|
||||
/>
|
||||
{!seo.ogImage && defaultOgImage && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Defaults to: {defaultOgImage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Favicon
|
||||
</label>
|
||||
<PropertyPanelTextInput value={seo.favicon || ''} onChange={(value) => onChange({ favicon: value })} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Path to favicon (.ico, .png, .svg)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Theme Color
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={localThemeColor}
|
||||
onChange={(e) => {
|
||||
setLocalThemeColor(e.target.value);
|
||||
onChange({ themeColor: e.target.value });
|
||||
}}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput
|
||||
value={localThemeColor}
|
||||
onChange={(value) => {
|
||||
setLocalThemeColor(value);
|
||||
onChange({ themeColor: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
Browser theme color (hex format)
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ConfigVariable, ConfigType, RESERVED_CONFIG_KEYS } from '@noodl/runtime/src/config/types';
|
||||
|
||||
import { JSONEditor } from '@noodl-core-ui/components/json-editor';
|
||||
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
|
||||
import PopupLayer from '../../../popuplayer';
|
||||
|
||||
interface VariablesSectionProps {
|
||||
variables: ConfigVariable[];
|
||||
onChange: (variables: ConfigVariable[]) => void;
|
||||
}
|
||||
|
||||
// Separate component for JSON editor button that manages its own ref
|
||||
interface JSONEditorButtonProps {
|
||||
value: string;
|
||||
varType: 'array' | 'object';
|
||||
onSave: (value: string) => void;
|
||||
}
|
||||
|
||||
function JSONEditorButton({ value, varType, onSave }: JSONEditorButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoutRef = useRef<any>(null);
|
||||
const rootRef = useRef<any>(null);
|
||||
|
||||
// Cleanup editor on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
if (popoutRef.current) {
|
||||
popoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openEditor = () => {
|
||||
if (!buttonRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing editor
|
||||
if (rootRef.current) {
|
||||
rootRef.current.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
|
||||
// Create popup container
|
||||
const popupDiv = document.createElement('div');
|
||||
const root = createRoot(popupDiv);
|
||||
rootRef.current = root;
|
||||
|
||||
// Track current value for save
|
||||
let currentValue = value;
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
currentValue = newValue;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
try {
|
||||
// Parse and validate before saving
|
||||
const parsed = JSON.parse(currentValue);
|
||||
if (varType === 'array' && !Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
if (varType === 'object' && (typeof parsed !== 'object' || Array.isArray(parsed))) {
|
||||
return;
|
||||
}
|
||||
onSave(currentValue);
|
||||
} catch {
|
||||
// Invalid JSON - don't save
|
||||
}
|
||||
|
||||
root.unmount();
|
||||
rootRef.current = null;
|
||||
};
|
||||
|
||||
root.render(
|
||||
<div style={{ padding: '16px', width: '600px', height: '500px', display: 'flex', flexDirection: 'column' }}>
|
||||
<JSONEditor value={value} onChange={handleChange} expectedType={varType} height="100%" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const popout = PopupLayer.instance.showPopout({
|
||||
content: { el: [popupDiv] },
|
||||
attachTo: $(buttonRef.current),
|
||||
position: 'right',
|
||||
onClose: handleClose
|
||||
});
|
||||
|
||||
popoutRef.current = popout;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditor();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
{value || (varType === 'array' ? '[]' : '{}')}
|
||||
</span>
|
||||
<span style={{ marginLeft: '8px', color: 'var(--theme-color-primary)' }}>Edit JSON ➜</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ALL_TYPES = [
|
||||
{ value: 'string' as const, label: 'String' },
|
||||
{ value: 'number' as const, label: 'Number' },
|
||||
{ value: 'boolean' as const, label: 'Boolean' },
|
||||
{ value: 'color' as const, label: 'Color' },
|
||||
{ value: 'array' as const, label: 'Array' },
|
||||
{ value: 'object' as const, label: 'Object' }
|
||||
];
|
||||
|
||||
export function VariablesSection({ variables, onChange }: VariablesSectionProps) {
|
||||
// Local state for optimistic updates
|
||||
const [localVariables, setLocalVariables] = useState<ConfigVariable[]>(variables);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Sync with props when they change externally
|
||||
useEffect(() => {
|
||||
setLocalVariables(variables);
|
||||
}, [variables]);
|
||||
|
||||
// New variable form state
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newType, setNewType] = useState<ConfigType>('string');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
const validateKey = (key: string, excludeIndex?: number): string | null => {
|
||||
if (!key.trim()) {
|
||||
return 'Key is required';
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
return 'Key must start with letter or underscore, and contain only letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (RESERVED_CONFIG_KEYS.includes(key as (typeof RESERVED_CONFIG_KEYS)[number])) {
|
||||
return `"${key}" is a reserved key`;
|
||||
}
|
||||
|
||||
const isDuplicate = localVariables.some((v, index) => v.key === key && index !== excludeIndex);
|
||||
if (isDuplicate) {
|
||||
return `Key "${key}" already exists`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseValue = (type: ConfigType, valueStr: string): unknown => {
|
||||
if (type === 'string') return valueStr;
|
||||
if (type === 'number') {
|
||||
const num = Number(valueStr);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
if (type === 'boolean') return valueStr === 'true';
|
||||
if (type === 'color') return valueStr || '#000000';
|
||||
if (type === 'array') {
|
||||
try {
|
||||
const parsed = JSON.parse(valueStr || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (type === 'object') {
|
||||
try {
|
||||
const parsed = JSON.parse(valueStr || '{}');
|
||||
return typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return valueStr;
|
||||
};
|
||||
|
||||
const serializeValue = (variable: ConfigVariable): string => {
|
||||
if (variable.type === 'string') return String(variable.value || '');
|
||||
if (variable.type === 'number') return String(variable.value || 0);
|
||||
if (variable.type === 'boolean') return String(variable.value);
|
||||
if (variable.type === 'color') return String(variable.value || '#000000');
|
||||
if (variable.type === 'array' || variable.type === 'object') {
|
||||
try {
|
||||
return JSON.stringify(variable.value, null, 2);
|
||||
} catch {
|
||||
return variable.type === 'array' ? '[]' : '{}';
|
||||
}
|
||||
}
|
||||
return String(variable.value || '');
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const keyError = validateKey(newKey);
|
||||
if (keyError) {
|
||||
setError(keyError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON for array/object types
|
||||
if ((newType === 'array' || newType === 'object') && newValue) {
|
||||
try {
|
||||
JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
} catch {
|
||||
setJsonError('Invalid JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newVariable: ConfigVariable = {
|
||||
key: newKey.trim(),
|
||||
type: newType,
|
||||
value: parseValue(newType, newValue),
|
||||
description: newDescription.trim() || undefined,
|
||||
category: newCategory.trim() || undefined
|
||||
};
|
||||
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = [...localVariables, newVariable];
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
|
||||
// Reset form
|
||||
setNewKey('');
|
||||
setNewType('string');
|
||||
setNewValue('');
|
||||
setNewDescription('');
|
||||
setNewCategory('');
|
||||
setError('');
|
||||
setJsonError('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updates: Partial<ConfigVariable>) => {
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = [...localVariables];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
// Update local state immediately (optimistic update)
|
||||
const updated = localVariables.filter((_, i) => i !== index);
|
||||
setLocalVariables(updated);
|
||||
|
||||
// Persist in background
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
setNewKey('');
|
||||
setNewType('string');
|
||||
setNewValue('');
|
||||
setNewDescription('');
|
||||
setNewCategory('');
|
||||
setError('');
|
||||
setJsonError('');
|
||||
};
|
||||
|
||||
const renderValueInput = (type: ConfigType, value: string, onValueChange: (v: string) => void): React.ReactNode => {
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === 'true'}
|
||||
onChange={(e) => onValueChange(e.target.checked ? 'true' : 'false')}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder="0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'color') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={value || '#000000'}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '32px',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PropertyPanelTextInput value={value || '#000000'} onChange={onValueChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'array' || type === 'object') {
|
||||
return (
|
||||
<JSONEditorButton
|
||||
value={value || (type === 'array' ? '[]' : '{}')}
|
||||
varType={type}
|
||||
onSave={(newValue) => {
|
||||
onValueChange(newValue);
|
||||
setJsonError('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PropertyPanelTextInput value={value} onChange={onValueChange} />;
|
||||
};
|
||||
|
||||
// Group variables by category
|
||||
const groupedVariables: { [category: string]: ConfigVariable[] } = {};
|
||||
localVariables.forEach((variable) => {
|
||||
const cat = variable.category || 'Uncategorized';
|
||||
if (!groupedVariables[cat]) {
|
||||
groupedVariables[cat] = [];
|
||||
}
|
||||
groupedVariables[cat].push(variable);
|
||||
});
|
||||
|
||||
const categories = Object.keys(groupedVariables).sort((a, b) => {
|
||||
// Uncategorized always first
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Custom Variables" hasGutter hasVisibleOverflow hasTopDivider>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginBottom: '12px'
|
||||
}}
|
||||
>
|
||||
Define custom config variables accessible via Noodl.Config.get('key')
|
||||
</div>
|
||||
|
||||
{/* Add Variable Button + Clear All */}
|
||||
{!isAdding && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--theme-color-primary)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
{localVariables.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete all ${localVariables.length} variables?`)) {
|
||||
setLocalVariables([]);
|
||||
onChange([]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Variable Form */}
|
||||
{isAdding && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
marginBottom: '12px',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Key *
|
||||
</label>
|
||||
<PropertyPanelTextInput
|
||||
value={newKey}
|
||||
onChange={(value) => {
|
||||
setNewKey(value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => {
|
||||
const newT = e.target.value as ConfigType;
|
||||
setNewType(newT);
|
||||
// Set appropriate default values
|
||||
if (newT === 'boolean') setNewValue('false');
|
||||
else if (newT === 'number') setNewValue('0');
|
||||
else if (newT === 'color') setNewValue('#000000');
|
||||
else if (newT === 'array') setNewValue('[]');
|
||||
else if (newT === 'object') setNewValue('{}');
|
||||
else setNewValue('');
|
||||
setJsonError('');
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{ALL_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput(newType, newValue, setNewValue)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Category (optional)
|
||||
</label>
|
||||
<PropertyPanelTextInput value={newCategory} onChange={setNewCategory} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<PropertyPanelTextInput value={newDescription} onChange={setNewDescription} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-error)',
|
||||
backgroundColor: 'var(--theme-color-error-bg)',
|
||||
border: '1px solid var(--theme-color-error)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jsonError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-error)',
|
||||
backgroundColor: 'var(--theme-color-error-bg)',
|
||||
border: '1px solid var(--theme-color-error)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{jsonError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!!error || !!jsonError}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: error || jsonError ? 'var(--theme-color-bg-3)' : 'var(--theme-color-primary)',
|
||||
color: error || jsonError ? 'var(--theme-color-fg-muted)' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: error || jsonError ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
color: 'var(--theme-color-fg-default)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variables List - Grouped by Category */}
|
||||
{localVariables.length === 0 && !isAdding && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px dashed var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
No variables defined yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.map((category) => (
|
||||
<div key={category} style={{ marginBottom: '16px' }}>
|
||||
{categories.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedVariables[category].map((variable) => {
|
||||
const index = localVariables.indexOf(variable);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
{variable.key}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
border: '1px solid var(--theme-color-border-default)',
|
||||
borderRadius: '3px',
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
{variable.type}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
title="Delete variable"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
minWidth: '28px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--theme-color-fg-muted)'
|
||||
}}
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput(variable.type, serializeValue(variable), (value) =>
|
||||
handleUpdate(index, { value: parseValue(variable.type, value) })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variable.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--theme-color-fg-muted)',
|
||||
fontStyle: 'italic',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
{variable.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ module.exports = merge(
|
||||
merge(shared, {
|
||||
entry: {
|
||||
'./src/editor/index': './src/editor/index.ts',
|
||||
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js',
|
||||
'./src/frames/viewer-frame/index': './src/frames/viewer-frame/index.js'
|
||||
},
|
||||
output: {
|
||||
filename: '[name].bundle.js',
|
||||
@@ -17,7 +17,9 @@ module.exports = merge(
|
||||
},
|
||||
plugins: [
|
||||
new MonacoWebpackPlugin({
|
||||
languages: ['typescript', 'javascript', 'css']
|
||||
// JSON language worker is broken in Electron/CommonJS - use TypeScript for JSON editing
|
||||
languages: ['typescript', 'javascript', 'css'],
|
||||
globalAPI: true
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user