Skip to content

Commit 2422bed

Browse files
committed
feat(editor): add raw JSON editing mode
Toggle between the visual editor and a raw JSON textarea to directly edit the full config payload. Switching modes serializes/deserializes state bidirectionally. Save works in both modes.
1 parent 74c7009 commit 2422bed

1 file changed

Lines changed: 185 additions & 22 deletions

File tree

src/lib/components/ConfigEditor.svelte

Lines changed: 185 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
let error = $state('');
2424
let editingConfig = $state<any>(null);
2525
let initialSnapshot = $state('');
26+
let rawMode = $state(false);
27+
let rawJson = $state('');
2628
2729
function captureSnapshot(): string {
2830
return JSON.stringify({
@@ -282,34 +284,107 @@
282284
}
283285
});
284286
287+
function buildPayload() {
288+
const existingSnapshot = editingConfig?.snapshot || null;
289+
const updatedSnapshot =
290+
existingSnapshot !== null || macosPrefs.length > 0
291+
? { ...(existingSnapshot || {}), macos_prefs: macosPrefs }
292+
: null;
293+
return {
294+
...formData,
295+
alias: formData.alias.trim() || null,
296+
packages: Array.from(selectedPackages.entries()).map(([name, type]) => ({
297+
name,
298+
type,
299+
desc: packageDescs.get(name) || '',
300+
})),
301+
snapshot: updatedSnapshot,
302+
};
303+
}
304+
305+
function applyParsed(parsed: any) {
306+
formData = {
307+
name: parsed.name || '',
308+
description: parsed.description || '',
309+
base_preset: parsed.base_preset || 'developer',
310+
visibility: parsed.visibility || 'unlisted',
311+
alias: parsed.alias || '',
312+
custom_script: parsed.custom_script || '',
313+
dotfiles_repo: parsed.dotfiles_repo || '',
314+
};
315+
const newMap = new Map<string, string>();
316+
const newDescs = new Map<string, string>();
317+
for (const pkg of parsed.packages || []) {
318+
if (typeof pkg === 'string') {
319+
newMap.set(pkg, 'formula');
320+
} else {
321+
newMap.set(pkg.name, pkg.type || 'formula');
322+
if (pkg.desc) newDescs.set(pkg.name, pkg.desc);
323+
}
324+
}
325+
selectedPackages = newMap;
326+
packageDescs = newDescs;
327+
macosPrefs = Array.isArray(parsed.snapshot?.macos_prefs)
328+
? parsed.snapshot.macos_prefs.map((p: any) => {
329+
const type = p.type || '';
330+
return {
331+
domain: p.domain || '',
332+
key: p.key || '',
333+
type,
334+
value: normalizePrefValue(type, String(p.value ?? '')),
335+
desc: p.desc || '',
336+
};
337+
})
338+
: [];
339+
initExpandedCats();
340+
}
341+
342+
function enterRawMode() {
343+
rawJson = JSON.stringify(buildPayload(), null, 2);
344+
rawMode = true;
345+
error = '';
346+
}
347+
348+
function exitRawMode() {
349+
try {
350+
const parsed = JSON.parse(rawJson);
351+
applyParsed(parsed);
352+
rawMode = false;
353+
error = '';
354+
} catch {
355+
error = 'Invalid JSON — fix before switching back';
356+
}
357+
}
358+
285359
async function save() {
286-
if (!formData.name) {
287-
error = 'Name is required';
288-
return;
360+
let payload: ReturnType<typeof buildPayload> | any;
361+
if (rawMode) {
362+
try {
363+
payload = JSON.parse(rawJson);
364+
} catch {
365+
error = 'Invalid JSON';
366+
return;
367+
}
368+
if (!payload.name) {
369+
error = 'Name is required';
370+
return;
371+
}
372+
} else {
373+
if (!formData.name) {
374+
error = 'Name is required';
375+
return;
376+
}
377+
payload = buildPayload();
289378
}
290379
saving = true;
291380
error = '';
292381
const url = slug ? `/api/configs/${slug}` : '/api/configs';
293382
const method = slug ? 'PUT' : 'POST';
294383
try {
295-
const existingSnapshot = editingConfig?.snapshot || null;
296-
const updatedSnapshot =
297-
existingSnapshot !== null || macosPrefs.length > 0
298-
? { ...(existingSnapshot || {}), macos_prefs: macosPrefs }
299-
: null;
300384
const response = await fetch(url, {
301385
method,
302386
headers: { 'Content-Type': 'application/json' },
303-
body: JSON.stringify({
304-
...formData,
305-
alias: formData.alias.trim() || null,
306-
packages: Array.from(selectedPackages.entries()).map(([name, type]) => ({
307-
name,
308-
type,
309-
desc: packageDescs.get(name) || '',
310-
})),
311-
snapshot: updatedSnapshot,
312-
}),
387+
body: JSON.stringify(payload),
313388
});
314389
const text = await response.text();
315390
let result;
@@ -342,7 +417,9 @@
342417
</div>
343418
{:else}
344419
<div class="editor">
345-
<SectionNav {sections} />
420+
{#if !rawMode}
421+
<SectionNav {sections} />
422+
{/if}
346423

347424
<header class="editor-header">
348425
<button class="back-btn" onclick={() => goto('/dashboard')}>
@@ -352,9 +429,22 @@
352429
Back
353430
</button>
354431
<div class="header-right">
355-
<div class="dna-preview">
356-
<PackageDna {packages} />
357-
</div>
432+
{#if !rawMode}
433+
<div class="dna-preview">
434+
<PackageDna {packages} />
435+
</div>
436+
{/if}
437+
<button
438+
class="raw-btn"
439+
class:raw-btn-active={rawMode}
440+
onclick={() => rawMode ? exitRawMode() : enterRawMode()}
441+
title={rawMode ? 'Switch to visual editor' : 'Edit raw JSON'}
442+
>
443+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
444+
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
445+
</svg>
446+
{rawMode ? 'Visual' : 'Raw'}
447+
</button>
358448
{#if error}
359449
<span class="header-error">{error}</span>
360450
{/if}
@@ -364,6 +454,19 @@
364454
</div>
365455
</header>
366456

457+
{#if rawMode}
458+
<div class="raw-editor">
459+
<div class="raw-hint">Edit the config JSON directly. Switch back to Visual to see your changes reflected in the form.</div>
460+
<textarea
461+
class="raw-textarea"
462+
bind:value={rawJson}
463+
spellcheck="false"
464+
autocomplete="off"
465+
autocapitalize="off"
466+
></textarea>
467+
</div>
468+
{:else}
469+
367470
<div class="editor-body">
368471
<!-- IDENTITY -->
369472
<section class="section" id="identity">
@@ -466,6 +569,7 @@
466569
</div>
467570
</section>
468571
</div>
572+
{/if}
469573
</div>
470574
{/if}
471575

@@ -620,6 +724,65 @@
620724
color: var(--text-muted);
621725
}
622726
727+
.raw-btn {
728+
display: flex;
729+
align-items: center;
730+
gap: 6px;
731+
padding: 8px 14px;
732+
background: none;
733+
border: 1px solid var(--border);
734+
border-radius: 8px;
735+
color: var(--text-muted);
736+
font-size: 0.82rem;
737+
font-weight: 600;
738+
font-family: inherit;
739+
cursor: pointer;
740+
transition: all 0.15s;
741+
}
742+
743+
.raw-btn:hover {
744+
border-color: var(--text-muted);
745+
color: var(--text-primary);
746+
}
747+
748+
.raw-btn-active {
749+
border-color: var(--accent);
750+
color: var(--accent);
751+
}
752+
753+
.raw-editor {
754+
display: flex;
755+
flex-direction: column;
756+
gap: 12px;
757+
padding-bottom: 100px;
758+
}
759+
760+
.raw-hint {
761+
font-size: 0.80rem;
762+
color: var(--text-muted);
763+
}
764+
765+
.raw-textarea {
766+
width: 100%;
767+
min-height: 60vh;
768+
padding: 16px;
769+
background: var(--bg-secondary, #111);
770+
color: var(--text-primary);
771+
border: 1px solid var(--border);
772+
border-radius: 10px;
773+
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
774+
font-size: 0.82rem;
775+
line-height: 1.6;
776+
resize: vertical;
777+
outline: none;
778+
box-sizing: border-box;
779+
transition: border-color 0.15s;
780+
}
781+
782+
.raw-textarea:focus {
783+
border-color: var(--accent);
784+
}
785+
623786
/* Sections */
624787
.editor-body {
625788
display: flex;

0 commit comments

Comments
 (0)