Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@
let canUndo = $derived(past.length > 0);
let canRedo = $derived(future.length > 0);

// Shallow equality for flat string records — much cheaper than JSON.stringify on every tick.
function shallowEq(a: Record<string, string>, b: Record<string, string>): boolean {
const ak = Object.keys(a);
if (ak.length !== Object.keys(b).length) return false;
return ak.every((k) => a[k] === b[k]);
}

// Save state — hasPendingChanges is derived so undo/redo update it automatically.
let lastSavedSnapshot = $state(JSON.stringify(overrides));
let lastSavedOverrides = $state<Record<string, string>>({ ...overrides });
let saveState = $state<'idle' | 'saving' | 'saved'>('idle');
let hasPendingChanges = $derived(JSON.stringify(overrides) !== lastSavedSnapshot);
let hasPendingChanges = $derived(!shallowEq(overrides, lastSavedOverrides));
let saveStateTimer: ReturnType<typeof setTimeout> | null = null;

// Live CSS preview on every change — actual persistence only on explicit save.
Expand All @@ -60,7 +67,7 @@
function setOverrides(updater: ((prev: Record<string, string>) => Record<string, string>) | Record<string, string>) {
const prev = overrides;
const next = typeof updater === "function" ? updater(prev) : updater;
if (JSON.stringify(prev) !== JSON.stringify(next)) {
if (!shallowEq(prev, next)) {
past = [...past.slice(-49), prev];
future = [];
if (saveState === 'saved') saveState = 'idle';
Expand All @@ -70,13 +77,13 @@

async function handleSave() {
if (!hasPendingChanges || saveState === 'saving') return;
const snapshot = JSON.stringify(overrides);
const snapshot = { ...overrides };
saveState = 'saving';
try {
await saveOverrides(overrides);
// Only mark clean if overrides haven't changed since save started.
if (JSON.stringify(overrides) === snapshot) {
lastSavedSnapshot = snapshot;
if (shallowEq(overrides, snapshot)) {
lastSavedOverrides = snapshot;
saveState = 'saved';
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
Expand Down
120 changes: 92 additions & 28 deletions configurator/src/components/shell/StudioHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { Undo2, Redo2, Trash2, Share2, FolderOpen, Check, Save, Loader2 } from 'lucide-svelte';

const version = typeof __SLASHED_VERSION__ !== "undefined" ? __SLASHED_VERSION__ : "";
Expand All @@ -18,6 +19,15 @@
} = $props();

let shareFeedback = $state(false);
let showResetConfirm = $state(false);
let resetCancelBtn = $state<HTMLButtonElement | null>(null);
let resetConfirmBtn = $state<HTMLButtonElement | null>(null);

$effect(() => {
if (showResetConfirm) {
tick().then(() => resetCancelBtn?.focus());
}
});

async function handleShare() {
try {
Expand All @@ -28,6 +38,19 @@
// ignore
}
}

function handleResetAllClick() {
showResetConfirm = true;
}

function confirmReset() {
showResetConfirm = false;
onResetAll();
}

function cancelReset() {
showResetConfirm = false;
}
</script>

<header class="h-[52px] bg-[#0d0d14] border-b border-white/8 flex items-center px-3 gap-3 shrink-0 z-20">
Expand Down Expand Up @@ -60,6 +83,31 @@
<div class="flex-1"></div>

<div class="flex items-center gap-1.5">
<button
onclick={onSave}
disabled={!hasPendingChanges || saveState === 'saving'}
title={hasPendingChanges ? "Save changes (Ctrl+S)" : "No unsaved changes"}
class={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-colors cursor-pointer",
saveState === 'saved'
? "bg-emerald-900/40 border border-emerald-500/30 text-emerald-300"
: hasPendingChanges
? "bg-emerald-600 hover:bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "bg-white/5 text-slate-500 disabled:opacity-40 disabled:pointer-events-none",
].join(' ')}
>
{#if saveState === 'saving'}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Saving…
{:else if saveState === 'saved'}
<Check class="w-3.5 h-3.5" />
Saved
{:else}
<Save class="w-3.5 h-3.5" />
Save
{/if}
</button>

<button
onclick={onUndo}
disabled={!canUndo}
Expand Down Expand Up @@ -100,39 +148,55 @@
</button>

<button
onclick={onResetAll}
onclick={handleResetAllClick}
disabled={overridesCount === 0}
title="Reset all overrides"
class="p-1.5 rounded-lg text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 disabled:opacity-25 disabled:pointer-events-none transition-all cursor-pointer"
>
<Trash2 class="w-3.5 h-3.5" />
</button>

<div class="w-px h-4 bg-white/10 mx-1"></div>

<button
onclick={onSave}
disabled={!hasPendingChanges || saveState === 'saving'}
title={hasPendingChanges ? "Save changes (Ctrl+S)" : "No unsaved changes"}
class={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all cursor-pointer",
saveState === 'saved'
? "bg-emerald-900/40 border border-emerald-500/30 text-emerald-300"
: hasPendingChanges
? "bg-emerald-600 hover:bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "bg-white/5 text-slate-500 disabled:opacity-40 disabled:pointer-events-none",
].join(' ')}
>
{#if saveState === 'saving'}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Saving…
{:else if saveState === 'saved'}
<Check class="w-3.5 h-3.5" />
Saved
{:else}
<Save class="w-3.5 h-3.5" />
Save
{/if}
</button>
</div>
</header>

{#if showResetConfirm}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="reset-confirm-title"
onclick={(e) => { if (e.target === e.currentTarget) cancelReset(); }}
onkeydown={(e) => {
if (e.key === 'Escape') { e.stopPropagation(); cancelReset(); }
if (e.key === 'Tab') {
e.preventDefault();
const focused = document.activeElement;
if (focused === resetCancelBtn) resetConfirmBtn?.focus();
else resetCancelBtn?.focus();
}
}}
>
<div class="bg-[#1a1a2e] border border-white/10 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl">
<h3 id="reset-confirm-title" class="text-white font-bold text-sm mb-2">Reset all overrides?</h3>
<p class="text-slate-400 text-xs mb-5">
This will clear all {overridesCount} customisation{overridesCount !== 1 ? 's' : ''}.
You can still undo this before saving.
</p>
<div class="flex gap-2 justify-end">
<button
bind:this={resetCancelBtn}
onclick={cancelReset}
class="px-3 py-1.5 text-xs rounded-lg bg-white/8 text-slate-300 hover:bg-white/12 transition-colors cursor-pointer"
>
Cancel
</button>
<button
bind:this={resetConfirmBtn}
onclick={confirmReset}
class="px-3 py-1.5 text-xs rounded-lg bg-rose-600 hover:bg-rose-500 text-white font-bold transition-colors cursor-pointer"
>
Reset all
</button>
</div>
</div>
</div>
{/if}
Loading