m4: settings-UI polish (dirty tracking + load-error state)
Save is disabled unless something actually changed (dirty = current values vs. the last loaded/saved snapshot); "Saved." clears the moment you edit again, and a quiet "No unsaved changes" shows at rest. A failed load now renders an explicit error + Retry instead of a blank form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import BaseButton from "../components/BaseButton.vue";
|
import BaseButton from "../components/BaseButton.vue";
|
||||||
@@ -17,11 +17,18 @@ interface SettingItem {
|
|||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
|
|
||||||
const items = ref<SettingItem[]>([]);
|
const items = ref<SettingItem[]>([]);
|
||||||
|
const original = ref<Record<string, string | boolean | number>>({});
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const saved = ref(false);
|
const saved = ref(false);
|
||||||
|
|
||||||
|
// Unsaved-changes tracking: compare current values to the last loaded/saved snapshot.
|
||||||
|
const dirty = computed(() => items.value.some((it) => it.value !== original.value[it.key]));
|
||||||
|
watch(dirty, (d) => {
|
||||||
|
if (d) saved.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
const map = new Map<string, SettingItem[]>();
|
const map = new Map<string, SettingItem[]>();
|
||||||
for (const it of items.value) {
|
for (const it of items.value) {
|
||||||
@@ -32,11 +39,17 @@ const groups = computed(() => {
|
|||||||
return Array.from(map, ([name, entries]) => ({ name, entries }));
|
return Array.from(map, ([name, entries]) => ({ name, entries }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function snapshot(list: SettingItem[]) {
|
||||||
|
original.value = Object.fromEntries(list.map((it) => [it.key, it.value]));
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
|
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
|
||||||
items.value = res.settings;
|
items.value = res.settings;
|
||||||
|
snapshot(res.settings);
|
||||||
} catch {
|
} catch {
|
||||||
error.value = "Could not load settings.";
|
error.value = "Could not load settings.";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,6 +66,7 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
|
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
|
||||||
items.value = res.settings;
|
items.value = res.settings;
|
||||||
|
snapshot(res.settings);
|
||||||
saved.value = true;
|
saved.value = true;
|
||||||
await config.reload(); // header/site name reflects changes immediately
|
await config.reload(); // header/site name reflects changes immediately
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,6 +101,18 @@ onMounted(load);
|
|||||||
|
|
||||||
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
|
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="error && !items.length" class="py-20 text-center">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load settings</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||||
|
@click="load"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
|
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
|
||||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||||
@@ -127,8 +153,9 @@ onMounted(load);
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<BaseButton type="submit" :loading="saving">Save changes</BaseButton>
|
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
|
||||||
<span v-if="saved" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||||
|
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
|
||||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user