Added custom json edit to config tab

This commit is contained in:
Richard Osborne
2026-01-08 13:27:38 +01:00
parent 4a1080d547
commit 67b8ddc9c3
53 changed files with 8756 additions and 210 deletions

View File

@@ -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')) {

View File

@@ -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);

View File

@@ -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',

View File

@@ -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') {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&apos;key&apos;)
</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>
);
}

View File

@@ -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
})
]
})