diff --git a/ui/package-lock.json b/ui/package-lock.json index c1ca30d1..22a7295e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,8 +9,9 @@ "version": "0.0.0", "hasInstallScript": true, "dependencies": { - "@cameleer/design-system": "^0.1.42", + "@cameleer/design-system": "^0.1.43", "@tanstack/react-query": "^5.90.21", + "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", "react": "^19.2.4", @@ -23,6 +24,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.58.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -279,9 +281,9 @@ } }, "node_modules/@cameleer/design-system": { - "version": "0.1.42", - "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.42/design-system-0.1.42.tgz", - "integrity": "sha512-Cyy+2HsbBPLKRZaSGFxMUvIwI+g8ocdjcojFTGgtq5vrpE/8IYJLgxdtM9+eDoF2Zewk7MrBzfpNqjEYlQO3ng==", + "version": "0.1.43", + "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.43/design-system-0.1.43.tgz", + "integrity": "sha512-eDCCFaS7DSRFRoWUWL1t256mdvIpL8XNbgRMgOWpZY7PWd162T1NESuzDn8PUAphC4jXtdLoHqQ+kihd+AA/hg==", "dependencies": { "lucide-react": "^1.7.0", "react": "^19.0.0", @@ -1137,6 +1139,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1581,7 +1590,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/balanced-match": { @@ -2501,7 +2509,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/ui/package.json b/ui/package.json index 4950d849..653c8c23 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,8 +15,9 @@ "postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer3-logo.svg','public/favicon.svg')\"" }, "dependencies": { - "@cameleer/design-system": "^0.1.42", + "@cameleer/design-system": "^0.1.43", "@tanstack/react-query": "^5.90.21", + "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", "react": "^19.2.4", @@ -29,6 +30,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.58.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/ui/src/components/EnvEditor.module.css b/ui/src/components/EnvEditor.module.css new file mode 100644 index 00000000..61b8e4e9 --- /dev/null +++ b/ui/src/components/EnvEditor.module.css @@ -0,0 +1,91 @@ +.wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.actions { + margin-left: auto; + display: flex; + gap: 6px; +} + +.tableBody { + display: flex; + flex-direction: column; + gap: 4px; +} + +.row { + display: flex; + gap: 8px; + align-items: center; +} + +.rowKey { + width: 220px; + flex-shrink: 0; +} + +.rowValue { + flex: 1; +} + +.deleteBtn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 4px; + border-radius: var(--radius-sm); + transition: color 0.1s; +} + +.deleteBtn:hover { + color: var(--error); +} + +.deleteBtn:disabled { + opacity: 0.3; + cursor: default; +} + +.textEditor { + width: 100%; + min-height: 180px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + resize: vertical; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + tab-size: 2; +} + +.textEditor:focus { + border-color: var(--amber); + box-shadow: 0 0 0 3px var(--amber-bg); +} + +.importZone { + margin-top: 4px; +} + +.empty { + padding: 24px; + text-align: center; + color: var(--text-faint); + font-size: 12px; +} diff --git a/ui/src/components/EnvEditor.tsx b/ui/src/components/EnvEditor.tsx new file mode 100644 index 00000000..7a19d209 --- /dev/null +++ b/ui/src/components/EnvEditor.tsx @@ -0,0 +1,245 @@ +import { useState, useMemo, useCallback, useRef } from 'react'; +import { Button, Input, SegmentedTabs, FileInput } from '@cameleer/design-system'; +import type { FileInputHandle } from '@cameleer/design-system'; +import { Plus, Copy, Trash2, FileUp } from 'lucide-react'; +import styles from './EnvEditor.module.css'; +import { + type EnvEntry, + type Format, + parseText, + toText, + detectFormat, +} from './env-convert'; + +interface EnvEditorProps { + value: EnvEntry[]; + onChange: (entries: EnvEntry[]) => void; + disabled?: boolean; +} + +const FORMAT_TABS = [ + { label: 'Table', value: 'table' as const }, + { label: 'Properties', value: 'properties' as const }, + { label: 'YAML', value: 'yaml' as const }, + { label: '.env', value: 'env' as const }, +]; + +type ViewMode = 'table' | Format; + +export function EnvEditor({ value, onChange, disabled }: EnvEditorProps) { + const [view, setView] = useState('table'); + const [textDraft, setTextDraft] = useState(null); + const [showImport, setShowImport] = useState(false); + const fileRef = useRef(null); + + // When switching views, convert between formats + const handleViewChange = useCallback( + (next: string) => { + const nextView = next as ViewMode; + + // If leaving a text view, commit the draft + if (textDraft !== null && view !== 'table') { + const parsed = parseText(textDraft, view as Format); + onChange(parsed); + } + + // If entering a text view, generate text from current entries + if (nextView !== 'table') { + setTextDraft(toText(value, nextView as Format)); + } else { + setTextDraft(null); + } + + setView(nextView); + }, + [view, textDraft, value, onChange], + ); + + // Text for current format (memoized from entries when no draft) + const displayText = useMemo(() => { + if (textDraft !== null) return textDraft; + if (view === 'table') return ''; + return toText(value, view as Format); + }, [textDraft, value, view]); + + // Commit text on blur + const handleTextBlur = useCallback(() => { + if (textDraft !== null && view !== 'table') { + const parsed = parseText(textDraft, view as Format); + onChange(parsed); + } + }, [textDraft, view, onChange]); + + // Table row handlers + const updateRow = useCallback( + (i: number, field: 'key' | 'value', val: string) => { + const next = [...value]; + next[i] = { ...next[i], [field]: val }; + onChange(next); + }, + [value, onChange], + ); + + const deleteRow = useCallback( + (i: number) => onChange(value.filter((_, j) => j !== i)), + [value, onChange], + ); + + const addRow = useCallback( + () => onChange([...value, { key: '', value: '' }]), + [value, onChange], + ); + + // Copy to clipboard + const handleCopy = useCallback(() => { + const text = view === 'table' ? toText(value, 'env') : displayText; + navigator.clipboard.writeText(text); + }, [view, value, displayText]); + + // Clear all + const handleClear = useCallback(() => { + onChange([]); + setTextDraft(view !== 'table' ? '' : null); + }, [view, onChange]); + + // File import + const handleImportFile = useCallback(() => { + const file = fileRef.current?.file; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + const format = detectFormat(text); + const parsed = parseText(text, format); + // Merge with existing (dedupe by key, new values win) + const existing = new Map(value.map((e) => [e.key, e])); + for (const entry of parsed) { + existing.set(entry.key, entry); + } + const merged = Array.from(existing.values()); + onChange(merged); + fileRef.current?.clear(); + setShowImport(false); + // If in text view, update the draft + if (view !== 'table') { + setTextDraft(toText(merged, view as Format)); + } + }; + reader.readAsText(file); + }, [value, onChange, view]); + + return ( +
+
+ +
+ + + +
+
+ + {showImport && ( +
+
+
+ } + /> +
+ +
+
+ )} + + {view === 'table' ? ( +
+ {value.map((entry, i) => ( +
+ updateRow(i, 'key', e.target.value)} + className={styles.rowKey} + placeholder="KEY" + /> + updateRow(i, 'value', e.target.value)} + className={styles.rowValue} + placeholder="value" + /> + +
+ ))} + {value.length === 0 && !disabled && ( +
+ No variables configured. Add one or import from a file. +
+ )} + {!disabled && ( + + )} +
+ ) : ( +