diff --git a/admin/package.json b/admin/package.json index cd5cb440907..2ca53ae5f3f 100644 --- a/admin/package.json +++ b/admin/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-switch": "^1.2.6", "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9", + "jsonc-parser": "^3.3.1", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4" }, diff --git a/admin/src/App.css b/admin/src/App.css index e69de29bb2d..8b31b48b02b 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -0,0 +1,275 @@ +/* Raw textarea (kept dark to signal "this is code") */ +textarea.settings { + font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace; + font-size: 14px; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; + width: 100%; + height: 500px; + padding: 15px; + background-color: #1e1e1e; + color: #d4d4d4; + line-height: 1.5; + border: 1px solid #333; + resize: vertical; +} +textarea.settings:focus { + outline: 2px solid #007acc; + outline-offset: -1px; +} + +.settings-button-bar { + display: flex; + flex-shrink: 0; + gap: 10px; + margin-top: 15px; +} + +.settings-links { + display: flex; + gap: 20px; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #ddd; +} + +/* --- mode toggle --- */ +.settings-mode-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid #ccc; + border-radius: 6px; + overflow: hidden; + margin-bottom: 16px; + background: #fff; +} +.settings-mode-toggle button { + padding: 6px 14px; + border: 0; + background: transparent; + color: #555; + font: inherit; + cursor: pointer; +} +.settings-mode-toggle button.active { + background: var(--etherpad-color, #0f775b); + color: #fff; +} + +/* --- form (light, two-column) --- */ +.settings-form { + font-family: inherit; + font-size: 14px; + color: #333; +} + +.settings-section { + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 18px; + overflow: hidden; +} +.settings-section-header { + padding: 14px 18px; + border-bottom: 1px solid #eee; + background: #fafafa; +} +.settings-section-header h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #222; + letter-spacing: 0.01em; + text-transform: uppercase; +} +.settings-section-header p { + margin: 4px 0 0; + font-size: 13px; + color: #666; + white-space: pre-wrap; +} +.settings-section-body { + padding: 4px 0; +} + +/* Two-column row: label | control, with help below spanning column 2. + * Single-column on narrow widths. */ +.settings-row { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 6px 18px; + padding: 10px 18px; + align-items: center; + border-top: 1px solid #f4f4f4; +} +.settings-row:first-child { + border-top: 0; +} +.settings-row-label { + font-weight: 600; + color: #333; + word-break: break-word; +} +.settings-row-control { + min-width: 0; +} +.settings-row-help { + grid-column: 2; + margin: 2px 0 0; + font-size: 12.5px; + color: #666; + white-space: pre-wrap; + line-height: 1.4; +} + +@media (max-width: 600px) { + .settings-row { + grid-template-columns: 1fr; + } + .settings-row-help { + grid-column: 1; + } +} + +/* --- nested subsections (objects/arrays inside a section) --- */ +.settings-subsection { + grid-column: 1 / -1; + margin: 8px 18px; + border-left: 3px solid #e2e2e2; + padding-left: 14px; +} +.settings-subsection-header { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; +} +.settings-subsection-title { + font-weight: 600; + color: #444; + font-size: 13.5px; + text-transform: uppercase; + letter-spacing: 0.01em; +} +.settings-subsection-help { + color: #777; + font-size: 12.5px; + white-space: pre-wrap; +} +.settings-subsection-body .settings-row { + padding-left: 0; + padding-right: 0; +} + +/* --- leaf widgets (light) --- */ +.settings-widget-string, +.settings-widget-number { + width: 100%; + background: #fff; + color: #222; + border: 1px solid #ccc; + border-radius: 4px; + padding: 6px 10px; + font-family: inherit; + font-size: inherit; +} +.settings-widget-string:focus, +.settings-widget-number:focus { + outline: none; + border-color: var(--etherpad-color, #0f775b); + box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15); +} +.settings-widget-number.invalid { + border-color: #ce5050; +} +.settings-widget-null { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #f0f0f0; + color: #888; + font-style: italic; + font-size: 12.5px; +} +.settings-widget-env { + display: inline-flex; + align-items: center; + gap: 6px; + background: #f4f8ff; + color: #335; + border: 1px dashed #88a; + border-radius: 12px; + padding: 2px 10px; + font-size: 13px; + cursor: help; +} +.settings-widget-env-icon { + font-style: normal; + color: #557; +} +.settings-widget-env-name { + font-family: "Fira Code", monospace; + font-weight: 600; +} +.settings-widget-env code { + background: transparent; + color: #804; + font-family: "Fira Code", monospace; +} + +/* Radix switch (boolean) */ +.settings-widget-boolean { + appearance: none; + width: 36px; + height: 20px; + border-radius: 999px; + background: #ccc; + border: 0; + position: relative; + cursor: pointer; + transition: background 120ms ease; + padding: 0; +} +.settings-widget-boolean[data-state="checked"] { + background: var(--etherpad-color, #0f775b); +} +.settings-widget-boolean-thumb { + display: block; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transform: translateX(2px); + transition: transform 120ms ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); +} +.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb { + transform: translateX(18px); +} + +/* --- parse error --- */ +.settings-parse-error { + border: 1px solid #d99; + background: #fff5f5; + color: #842; + padding: 14px 18px; + border-radius: 6px; +} +.settings-parse-error-detail { + margin: 8px 0; + white-space: pre-wrap; + font-family: "Fira Code", monospace; + font-size: 12.5px; +} +.settings-parse-error button { + margin-top: 4px; + background: var(--etherpad-color, #0f775b); + color: #fff; + border: 0; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font: inherit; +} diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 27d5a2ae367..18a41c4d888 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -77,8 +77,14 @@ export const App = () => { useStore.getState().setShowLoading(false); }); - settingSocket.on('saveprogress', (status) => { - console.log(status) + settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => { + const {setToastState} = useStore.getState(); + if (status === 'saved') { + setToastState({open: true, title: t('admin_settings.toast.saved'), success: true}); + } else { + const detail = payload?.message ?? ''; + setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); + } }) return () => { diff --git a/admin/src/components/IconButton.tsx b/admin/src/components/IconButton.tsx index b73396c827d..2ea3c902dc7 100644 --- a/admin/src/components/IconButton.tsx +++ b/admin/src/components/IconButton.tsx @@ -1,17 +1,14 @@ -import {FC, JSX, ReactElement} from "react"; +import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react"; -export type IconButtonProps = { - style?: React.CSSProperties, +export type IconButtonProps = Omit, 'title' | 'onClick'> & { icon: JSX.Element, title: string|ReactElement, onClick: ()=>void, - className?: string, - disabled?: boolean } -export const IconButton:FC = ({icon,className,onClick,title, disabled, style})=>{ - return -} + +); diff --git a/admin/src/components/settings/FormView.tsx b/admin/src/components/settings/FormView.tsx new file mode 100644 index 00000000000..ee6b80b929d --- /dev/null +++ b/admin/src/components/settings/FormView.tsx @@ -0,0 +1,143 @@ +import { parseTree, type JSONPath, type Node, type ParseError } from 'jsonc-parser'; +import { useStore } from '../../store/store'; +import { useTranslation } from 'react-i18next'; +import { editJsonc } from './jsoncEdit'; +import { JsoncNode } from './JsoncNode'; +import { ParseErrorBanner } from './ParseErrorBanner'; +import { extractAdjacentComments } from './comments'; +import { lookupTemplateComment } from './templateComments'; +import { labelAndHelp } from './labels'; + +type Props = { + onSwitchToRaw: () => void; +}; + +// Parser-error token labels are kept in English — they are technical tokens +// matching the jsonc-parser error enum, not user-facing prose. +const ParseErrorMessage: Record = { + 1: 'Invalid symbol', + 2: 'Invalid number format', + 3: 'Property name expected', + 4: 'Value expected', + 5: 'Colon expected', + 6: 'Comma expected', + 7: 'Closing brace expected', + 8: 'Closing bracket expected', + 9: 'End of file expected', + 16: 'Unexpected end of comment', + 17: 'Unexpected end of string', + 18: 'Unexpected end of number', + 19: 'Invalid unicode', + 20: 'Invalid escape character', + 21: 'Invalid character', +}; + +const formatErrors = (errors: ParseError[]): string => + errors.length === 0 + ? '' + : errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n'); + +const Section = ({ title, description, children }: { + title: string; + description?: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+); + +const propertyKey = (prop: Node): string => + prop.type === 'property' && prop.children?.[0]?.type === 'string' + ? String(prop.children[0].value) + : ''; + +const propertyComment = (prop: Node, text: string, key: string): string | null => { + const valueNode = prop.children?.[1]; + if (!valueNode) return null; + const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length); + return live.leading || lookupTemplateComment([key]); +}; + +export const FormView = ({ onSwitchToRaw }: Props) => { + const { t } = useTranslation(); + const rawText = useStore(s => s.settings); + + // While settings haven't loaded yet, show an empty busy placeholder so we + // don't flash a parse-error banner for the undefined→'' empty-string case. + if (rawText === undefined) { + return
; + } + + const text = rawText; + + const errors: ParseError[] = []; + const tree = parseTree(text, errors, { allowTrailingComma: true }); + + // Always read the latest text from the store instead of closing over the + // render-time snapshot, so rapid sequential edits don't clobber each other. + const onEdit = (path: JSONPath, value: unknown) => { + const current = useStore.getState().settings ?? ''; + useStore.getState().setSettings(editJsonc(current, path, value)); + }; + + if (!tree || errors.length > 0 || tree.type !== 'object') { + return ; + } + + const generalProps: Node[] = []; + const sectionProps: Node[] = []; + for (const prop of tree.children ?? []) { + if (prop.type !== 'property' || !prop.children?.[1]) continue; + const valueType = prop.children[1].type; + if (valueType === 'object' || valueType === 'array') sectionProps.push(prop); + else generalProps.push(prop); + } + + return ( +
+ {generalProps.length > 0 && ( +
+ {generalProps.map((prop) => { + const propKey = + prop.children?.[0]?.type === 'string' + ? String(prop.children[0].value) + : String(prop.offset); + return ( + + ); + })} +
+ )} + {sectionProps.map((prop) => { + const key = propertyKey(prop); + const { label, help } = labelAndHelp(propertyComment(prop, text, key), key); + const sectionKey = + prop.children?.[0]?.type === 'string' + ? String(prop.children[0].value) + : String(prop.offset); + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/admin/src/components/settings/JsoncNode.tsx b/admin/src/components/settings/JsoncNode.tsx new file mode 100644 index 00000000000..79349f9a411 --- /dev/null +++ b/admin/src/components/settings/JsoncNode.tsx @@ -0,0 +1,147 @@ +import type { JSONPath, Node } from 'jsonc-parser'; +import { getNodePath } from 'jsonc-parser'; +import { extractAdjacentComments } from './comments'; +import { matchEnvPlaceholder } from './envPill'; +import { lookupTemplateComment } from './templateComments'; +import { labelAndHelp } from './labels'; +import { StringInput } from './widgets/StringInput'; +import { NumberInput } from './widgets/NumberInput'; +import { BooleanToggle } from './widgets/BooleanToggle'; +import { NullChip } from './widgets/NullChip'; +import { EnvPill } from './widgets/EnvPill'; + +type Props = { + /** The value node (not the property node). */ + node: Node; + /** The property node, when this value is the value-side of `"key": value`. */ + property?: Node; + text: string; + onEdit: (path: JSONPath, value: unknown) => void; + /** + * When true, this group's own label/header is suppressed because a + * containing Section already rendered it. The group's children still + * render. Used for top-level object/array sections in FormView. + */ + suppressOwnHeader?: boolean; +}; + +const propertyKey = (property: Node | undefined): string => { + if (!property || property.type !== 'property') return ''; + const k = property.children?.[0]; + return k?.type === 'string' ? String(k.value) : ''; +}; + +const renderLeaf = ( + node: Node, + path: JSONPath, + text: string, + onEdit: (path: JSONPath, value: unknown) => void, +) => { + if (node.type === 'string') { + const raw = text.slice(node.offset, node.offset + node.length); + const env = matchEnvPlaceholder(raw); + if (env) return ; + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'number') { + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'boolean') { + return ( + onEdit(path, v)} + /> + ); + } + if (node.type === 'null') { + return ; + } + return null; +}; + +export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => { + const path = getNodePath(node); + const key = propertyKey(property); + + const anchor = property ?? node; + const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length); + const comment = fileComments.leading || (property ? lookupTemplateComment(path) : null) || ''; + const { label, help } = labelAndHelp(comment, key); + + const rowId = `settings-row-${path.join('.') || 'root'}`; + const helpId = help ? `${rowId}-help` : undefined; + + // ---- Object / array groups ---- + if (node.type === 'object' || node.type === 'array') { + const children = (node.children ?? []).map((child) => { + // For object: child is a property node, drill into its value node. + // For array: child is a value node directly. + if (node.type === 'object') { + const valueNode = child.children?.[1]; + if (!valueNode) return null; + // Use the property key string as stable key; fall back to byte offset. + const propKey = + child.children?.[0]?.type === 'string' + ? String(child.children[0].value) + : String(child.offset); + return ( + + ); + } + // Array element: use unique byte offset as stable key. + return ; + }); + + if (suppressOwnHeader || !property) { + // Render children flat — the containing Section provides the label. + return <>{children}; + } + + // Nested group within a section: render as a sub-section with its own + // heading, indented under its parent. + return ( +
+
+ {label} + {help && {help}} +
+
{children}
+
+ ); + } + + // ---- Leaf row ---- + return ( +
+ +
+ {renderLeaf(node, path, text, onEdit)} +
+ {help && ( +

{help}

+ )} +
+ ); +}; diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx new file mode 100644 index 00000000000..0f87f62ce84 --- /dev/null +++ b/admin/src/components/settings/ModeToggle.tsx @@ -0,0 +1,36 @@ +import { Trans, useTranslation } from 'react-i18next'; + +export type Mode = 'form' | 'raw'; + +type Props = { + mode: Mode; + onChange: (mode: Mode) => void; +}; + +export const ModeToggle = ({ mode, onChange }: Props) => { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +}; diff --git a/admin/src/components/settings/ParseErrorBanner.tsx b/admin/src/components/settings/ParseErrorBanner.tsx new file mode 100644 index 00000000000..2a63333f80d --- /dev/null +++ b/admin/src/components/settings/ParseErrorBanner.tsx @@ -0,0 +1,16 @@ +import { Trans } from 'react-i18next'; + +type Props = { + message: string; + onSwitchToRaw: () => void; +}; + +export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => ( +
+ +
{message}
+ +
+); diff --git a/admin/src/components/settings/comments.ts b/admin/src/components/settings/comments.ts new file mode 100644 index 00000000000..7c24b77d27f --- /dev/null +++ b/admin/src/components/settings/comments.ts @@ -0,0 +1,89 @@ +// admin/src/components/settings/comments.ts +// +// Given the source text and a property's `keyOffset` (jsonc-parser's +// Node.offset for the property node), extract: +// - `leading`: the contiguous run of `/* */` or `//` comments +// immediately above the key. At most one blank line is allowed +// between the comment block and the key. +// - `trailing`: a single `// ...` or `/* ... */` on the same line +// as the value, after any trailing comma. + +export type AdjacentComments = { + leading: string; + trailing: string; +}; + +const LINE_BREAK = /\r?\n/; + +const stripCommentMarkers = (raw: string): string => { + // raw is a concatenation of comment tokens separated by newlines. + // Drop /* */ and // markers and trim each line. + return raw + .split(LINE_BREAK) + .map(line => line + .replace(/^\s*\/\*+/, '') + .replace(/\*+\/\s*$/, '') + .replace(/^\s*\*\s?/, '') + .replace(/^\s*\/\/\s?/, '') + .trim()) + .filter(line => line.length > 0) + .join(' '); +}; + +const findLeading = (text: string, keyOffset: number): string => { + // Walk backwards from keyOffset to the start of the line containing it. + const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1; + let cursor = lineStart; + let blankLineSeen = false; + const collected: string[] = []; + + while (cursor > 0) { + // Look at the previous line. + const prevLineEnd = cursor - 1; // the '\n' before our cursor's line + const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1; + const line = text.slice(prevLineStart, prevLineEnd); + const trimmed = line.trim(); + + if (trimmed === '') { + if (blankLineSeen) break; + blankLineSeen = true; + cursor = prevLineStart; + continue; + } + + const isComment = + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.endsWith('*/'); + + if (!isComment) break; + + collected.unshift(line); + cursor = prevLineStart; + } + + return stripCommentMarkers(collected.join('\n')); +}; + +const findTrailing = (text: string, valueOffset: number, valueLength: number): string => { + // Trailing comments only exist on the same line as the value. If there's + // no newline after the value the file has no line structure (e.g. minified + // settings.json) and `//` inside any later string literal would otherwise + // be matched as a comment. + const lineEnd = text.indexOf('\n', valueOffset + valueLength); + if (lineEnd === -1) return ''; + const slice = text.slice(valueOffset + valueLength, lineEnd); + const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice); + return m ? stripCommentMarkers(m[1]) : ''; +}; + +export const extractAdjacentComments = ( + text: string, + keyOffset: number, + valueOffset: number, + valueLength: number, +): AdjacentComments => ({ + leading: findLeading(text, keyOffset), + trailing: findTrailing(text, valueOffset, valueLength), +}); diff --git a/admin/src/components/settings/envPill.ts b/admin/src/components/settings/envPill.ts new file mode 100644 index 00000000000..2e31f9c51aa --- /dev/null +++ b/admin/src/components/settings/envPill.ts @@ -0,0 +1,21 @@ +// admin/src/components/settings/envPill.ts +// +// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw +// slice of a string node. The slice INCLUDES the surrounding quotes, +// because jsonc-parser exposes node.offset/length over the whole literal. + +export type EnvPlaceholder = { + variable: string; + defaultValue: string | null; +}; + +const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/; + +export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => { + const m = RE.exec(rawSlice); + if (!m) return null; + return { + variable: m[1], + defaultValue: m[2] ?? null, + }; +}; diff --git a/admin/src/components/settings/jsoncEdit.ts b/admin/src/components/settings/jsoncEdit.ts new file mode 100644 index 00000000000..f69303accf2 --- /dev/null +++ b/admin/src/components/settings/jsoncEdit.ts @@ -0,0 +1,11 @@ +// admin/src/components/settings/jsoncEdit.ts +import { applyEdits, modify, type JSONPath } from 'jsonc-parser'; + +const FORMATTING = { + formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const }, +}; + +export const editJsonc = (text: string, path: JSONPath, value: unknown): string => { + const edits = modify(text, path, value, FORMATTING); + return edits.length === 0 ? text : applyEdits(text, edits); +}; diff --git a/admin/src/components/settings/labels.ts b/admin/src/components/settings/labels.ts new file mode 100644 index 00000000000..afdae3de11f --- /dev/null +++ b/admin/src/components/settings/labels.ts @@ -0,0 +1,46 @@ +// Pretty-label derivation. The first sentence of a key's documentation +// comment is its label; the rest stays in the help-text slot. When no +// comment exists, fall back to a humanized key name (camelCase → "Camel +// case"). + +const SENTENCE_END = /[.!?](\s|$)/; + +const humanize = (key: string): string => { + if (!key) return key; + // Split camelCase / PascalCase / snake_case / kebab-case + const words = key + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .trim() + .split(/\s+/); + if (words.length === 0) return key; + return words[0].charAt(0).toUpperCase() + words[0].slice(1) + + (words.length > 1 ? ' ' + words.slice(1).join(' ') : ''); +}; + +const splitFirstSentence = (text: string): { head: string; rest: string } => { + const trimmed = text.trim(); + const m = SENTENCE_END.exec(trimmed); + if (!m) return { head: trimmed, rest: '' }; + const cut = m.index + 1; // include the punctuation + return { + head: trimmed.slice(0, cut).trim(), + rest: trimmed.slice(cut).trim(), + }; +}; + +export const labelAndHelp = ( + comment: string | null | undefined, + key: string, +): { label: string; help: string } => { + if (!comment || !comment.trim()) { + return { label: humanize(key), help: '' }; + } + const { head, rest } = splitFirstSentence(comment); + return { + label: head || humanize(key), + help: rest, + }; +}; diff --git a/admin/src/components/settings/templateComments.ts b/admin/src/components/settings/templateComments.ts new file mode 100644 index 00000000000..230ce589c1b --- /dev/null +++ b/admin/src/components/settings/templateComments.ts @@ -0,0 +1,50 @@ +// Build a fallback path → comment map from `settings.json.template`. The live +// settings.json is per-developer and often lacks comments; the template is the +// authoritative source of per-key documentation. + +import { parseTree, type JSONPath, type Node } from 'jsonc-parser'; +import { extractAdjacentComments } from './comments'; + +// Injected by Vite at build time from settings.json.template (see vite.config.ts). +// Inlining at config time avoids widening the dev server's filesystem allowlist +// to the repo root, which would expose settings.json/credentials.json over the +// dev server. +declare const __SETTINGS_TEMPLATE__: string; +const templateText: string = __SETTINGS_TEMPLATE__; + +const pathKey = (path: JSONPath): string => path.map(String).join('.'); + +const buildMap = (text: string): Map => { + const map = new Map(); + const tree = parseTree(text, [], { allowTrailingComma: true }); + if (!tree) return map; + + const walk = (node: Node, path: JSONPath) => { + if (node.type === 'object') { + for (const prop of node.children ?? []) { + if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue; + const keyNode = prop.children[0]; + const valueNode = prop.children[1]; + if (keyNode.type !== 'string') continue; + const childPath = [...path, String(keyNode.value)]; + const { leading, trailing } = extractAdjacentComments( + text, prop.offset, valueNode.offset, valueNode.length, + ); + if (leading || trailing) { + map.set(pathKey(childPath), [leading, trailing].filter(Boolean).join(' — ')); + } + walk(valueNode, childPath); + } + } else if (node.type === 'array') { + (node.children ?? []).forEach((child, i) => walk(child, [...path, i])); + } + }; + + walk(tree, []); + return map; +}; + +const templateMap = buildMap(templateText); + +export const lookupTemplateComment = (path: JSONPath): string | null => + templateMap.get(pathKey(path)) ?? null; diff --git a/admin/src/components/settings/widgets/BooleanToggle.tsx b/admin/src/components/settings/widgets/BooleanToggle.tsx new file mode 100644 index 00000000000..d2d91fadfdc --- /dev/null +++ b/admin/src/components/settings/widgets/BooleanToggle.tsx @@ -0,0 +1,20 @@ +import * as Switch from '@radix-ui/react-switch'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: boolean; + path: JSONPath; + onChange: (next: boolean) => void; +}; + +export const BooleanToggle = ({ value, path, onChange }: Props) => ( + + + +); diff --git a/admin/src/components/settings/widgets/EnvPill.tsx b/admin/src/components/settings/widgets/EnvPill.tsx new file mode 100644 index 00000000000..5e56ac165d8 --- /dev/null +++ b/admin/src/components/settings/widgets/EnvPill.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import type { JSONPath } from 'jsonc-parser'; +import type { EnvPlaceholder } from '../envPill'; + +type Props = { + placeholder: EnvPlaceholder; + path: JSONPath; +}; + +export const EnvPill = ({ placeholder, path }: Props) => { + const { t } = useTranslation(); + return ( + + + {placeholder.variable} + {placeholder.defaultValue !== null && ( + + {' '}default: {placeholder.defaultValue} + + )} + + ); +}; diff --git a/admin/src/components/settings/widgets/NullChip.tsx b/admin/src/components/settings/widgets/NullChip.tsx new file mode 100644 index 00000000000..9c705fcdd49 --- /dev/null +++ b/admin/src/components/settings/widgets/NullChip.tsx @@ -0,0 +1,10 @@ +import type { JSONPath } from 'jsonc-parser'; + +type Props = { path: JSONPath }; + +export const NullChip = ({ path }: Props) => ( + null +); diff --git a/admin/src/components/settings/widgets/NumberInput.tsx b/admin/src/components/settings/widgets/NumberInput.tsx new file mode 100644 index 00000000000..d339e97a80e --- /dev/null +++ b/admin/src/components/settings/widgets/NumberInput.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: number; + path: JSONPath; + onChange: (next: number) => void; +}; + +export const NumberInput = ({ value, path, onChange }: Props) => { + const [draft, setDraft] = useState(String(value)); + const [invalid, setInvalid] = useState(false); + const focusedRef = useRef(false); + + // Sync draft when the prop value changes (e.g. after a server round-trip + // canonicalises the number) — but only when the input is not focused so we + // don't stomp on the user while they are typing. + useEffect(() => { + if (!focusedRef.current) { + setDraft(String(value)); + setInvalid(false); + } + }, [value]); + + return ( + { focusedRef.current = true; }} + onBlur={() => { focusedRef.current = false; }} + onChange={e => { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next.trim() !== '' && Number.isFinite(parsed)) { + setInvalid(false); + onChange(parsed); + } else { + setInvalid(true); + } + }} + /> + ); +}; diff --git a/admin/src/components/settings/widgets/StringInput.tsx b/admin/src/components/settings/widgets/StringInput.tsx new file mode 100644 index 00000000000..dd3efe51591 --- /dev/null +++ b/admin/src/components/settings/widgets/StringInput.tsx @@ -0,0 +1,19 @@ +import type { JSONPath } from 'jsonc-parser'; + +type Props = { + value: string; + path: JSONPath; + onChange: (next: string) => void; +}; + +export const StringInput = ({ value, path, onChange }: Props) => ( + onChange(e.target.value)} + /> +); diff --git a/admin/src/index.css b/admin/src/index.css index 64eae3ccc4f..a6cf68332d0 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -294,16 +294,9 @@ td, th { display: flex; flex-direction: column; gap: 20px; - height: 100%; + min-height: 100%; } -.settings { - flex-grow: max(1, 1); - outline: none; - width: 100%; - resize: none; - font-family: monospace; -} #response { display: inline; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 6c2f9bf333c..7511b8e399f 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,50 +1,123 @@ -import {useStore} from "../store/store.ts"; -import {isJSONClean, cleanComments} from "../utils/utils.ts"; -import {Trans} from "react-i18next"; -import {IconButton} from "../components/IconButton.tsx"; -import {RotateCw, Save} from "lucide-react"; - -export const SettingsPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) - const settings = cleanComments(useStore(state=>state.settings)) - - return
-

-