|
23 | 23 | let error = $state(''); |
24 | 24 | let editingConfig = $state<any>(null); |
25 | 25 | let initialSnapshot = $state(''); |
| 26 | + let rawMode = $state(false); |
| 27 | + let rawJson = $state(''); |
26 | 28 |
|
27 | 29 | function captureSnapshot(): string { |
28 | 30 | return JSON.stringify({ |
|
282 | 284 | } |
283 | 285 | }); |
284 | 286 |
|
| 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 | +
|
285 | 359 | 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(); |
289 | 378 | } |
290 | 379 | saving = true; |
291 | 380 | error = ''; |
292 | 381 | const url = slug ? `/api/configs/${slug}` : '/api/configs'; |
293 | 382 | const method = slug ? 'PUT' : 'POST'; |
294 | 383 | try { |
295 | | - const existingSnapshot = editingConfig?.snapshot || null; |
296 | | - const updatedSnapshot = |
297 | | - existingSnapshot !== null || macosPrefs.length > 0 |
298 | | - ? { ...(existingSnapshot || {}), macos_prefs: macosPrefs } |
299 | | - : null; |
300 | 384 | const response = await fetch(url, { |
301 | 385 | method, |
302 | 386 | 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), |
313 | 388 | }); |
314 | 389 | const text = await response.text(); |
315 | 390 | let result; |
|
342 | 417 | </div> |
343 | 418 | {:else} |
344 | 419 | <div class="editor"> |
345 | | - <SectionNav {sections} /> |
| 420 | + {#if !rawMode} |
| 421 | + <SectionNav {sections} /> |
| 422 | + {/if} |
346 | 423 |
|
347 | 424 | <header class="editor-header"> |
348 | 425 | <button class="back-btn" onclick={() => goto('/dashboard')}> |
|
352 | 429 | Back |
353 | 430 | </button> |
354 | 431 | <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> |
358 | 448 | {#if error} |
359 | 449 | <span class="header-error">{error}</span> |
360 | 450 | {/if} |
|
364 | 454 | </div> |
365 | 455 | </header> |
366 | 456 |
|
| 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 | + |
367 | 470 | <div class="editor-body"> |
368 | 471 | <!-- IDENTITY --> |
369 | 472 | <section class="section" id="identity"> |
|
466 | 569 | </div> |
467 | 570 | </section> |
468 | 571 | </div> |
| 572 | + {/if} |
469 | 573 | </div> |
470 | 574 | {/if} |
471 | 575 |
|
|
620 | 724 | color: var(--text-muted); |
621 | 725 | } |
622 | 726 |
|
| 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 | +
|
623 | 786 | /* Sections */ |
624 | 787 | .editor-body { |
625 | 788 | display: flex; |
|
0 commit comments