feat(settings): autosave on change, remove Save buttons (closes #128)
Each setting now saves via a debounced (600ms) individual API call when its value changes. A hydration guard (_hydrated flag + nextTick) prevents watchers from firing during the initial load() fetch, ensuring the first API round-trip does not generate spurious write calls. Removed: five explicit Save buttons across Equipment, Sensory, Units, Shopping Region, and Recipe Search Layout sections. Added: "Changes save automatically." subtitle + fixed bottom-right toast that appears for 2s after any successful save, with enter/leave transitions that respect prefers-reduced-motion via the theme. The full save() and saveSensory() actions are kept as internal fallbacks.
This commit is contained in:
parent
0ef57618bf
commit
30f5620fd5
2 changed files with 86 additions and 81 deletions
|
|
@ -2,6 +2,7 @@
|
|||
<div class="settings-view">
|
||||
<div class="card">
|
||||
<h2 class="section-title text-xl mb-md">Settings</h2>
|
||||
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
|
||||
|
||||
<!-- Cooking Equipment -->
|
||||
<section>
|
||||
|
|
@ -50,18 +51,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save button -->
|
||||
<div class="flex-start gap-sm">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sensory Preferences -->
|
||||
|
|
@ -134,17 +123,6 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.saveSensory()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">Saved!</span>
|
||||
<span v-else>Save sensory preferences</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Units -->
|
||||
|
|
@ -169,17 +147,6 @@
|
|||
Imperial (oz, cups, °F)
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-start gap-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Shopping Locale -->
|
||||
|
|
@ -220,17 +187,6 @@
|
|||
<option value="br">Brazil (BRL R$)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Time-First Layout -->
|
||||
|
|
@ -258,17 +214,6 @@
|
|||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Sharing (cloud only) -->
|
||||
|
|
@ -393,6 +338,12 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="autosave-fade">
|
||||
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
|
||||
✓ Saved
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -871,4 +822,32 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
|||
border-color: var(--color-border, #e0e0e0);
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
/* ── Autosave toast ──────────────────────────────────────────────────────── */
|
||||
|
||||
.autosave-toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e0e0e0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-success, #4a8c40);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.autosave-fade-enter-active,
|
||||
.autosave-fade-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.autosave-fade-enter-from,
|
||||
.autosave-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
/**
|
||||
* Settings Store
|
||||
*
|
||||
* Manages user settings (cooking equipment, preferences) using Pinia.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { settingsAPI } from '../services/api'
|
||||
import type { UnitSystem } from '../utils/units'
|
||||
import type { SensoryPreferences } from '../services/api'
|
||||
|
|
@ -13,8 +7,12 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
|||
|
||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
||||
|
||||
function debounce(fn: () => void, ms: number): () => void {
|
||||
let t: ReturnType<typeof setTimeout>
|
||||
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// State
|
||||
const cookingEquipment = ref<string[]>([])
|
||||
const unitSystem = ref<UnitSystem>('metric')
|
||||
const shoppingLocale = ref<string>('us')
|
||||
|
|
@ -23,7 +21,40 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
const loading = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
// Actions
|
||||
// Prevents autosave watchers from firing during initial load hydration.
|
||||
// Set to true after nextTick() at the end of load() — by that point all
|
||||
// watcher jobs queued by the hydration assignments have already flushed.
|
||||
let _hydrated = false
|
||||
|
||||
function _flash() {
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
|
||||
async function _saveKey(key: string, value: string): Promise<void> {
|
||||
if (!_hydrated) return
|
||||
try {
|
||||
await settingsAPI.setSetting(key, value)
|
||||
_flash()
|
||||
} catch (err: unknown) {
|
||||
console.error('Autosave failed for key:', key, err)
|
||||
}
|
||||
}
|
||||
|
||||
const _autosave = {
|
||||
equipment: debounce(() => _saveKey('cooking_equipment', JSON.stringify(cookingEquipment.value)), 600),
|
||||
unit: debounce(() => _saveKey('unit_system', unitSystem.value), 600),
|
||||
locale: debounce(() => _saveKey('shopping_locale', shoppingLocale.value), 600),
|
||||
sensory: debounce(() => _saveKey('sensory_preferences', JSON.stringify(sensoryPreferences.value)), 600),
|
||||
layout: debounce(() => _saveKey('time_first_layout', timeFirstLayout.value), 600),
|
||||
}
|
||||
|
||||
watch(cookingEquipment, _autosave.equipment, { deep: true })
|
||||
watch(unitSystem, _autosave.unit)
|
||||
watch(shoppingLocale, _autosave.locale)
|
||||
watch(sensoryPreferences, _autosave.sensory, { deep: true })
|
||||
watch(timeFirstLayout, _autosave.layout)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -58,8 +89,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// Yield past the watcher flush triggered by hydration assignments above.
|
||||
// After nextTick, any pending watcher jobs from this load() have already
|
||||
// run (and been ignored by _hydrated guard), so user-driven changes from
|
||||
// here forward will correctly trigger autosave.
|
||||
await nextTick()
|
||||
_hydrated = true
|
||||
}
|
||||
|
||||
// Kept for explicit full-save scenarios (e.g. fallback, tests).
|
||||
async function save() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -70,10 +108,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
||||
])
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
_flash()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save settings:', err)
|
||||
} finally {
|
||||
|
|
@ -81,24 +116,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Kept for backward compat; autosave handles sensory changes now.
|
||||
async function saveSensory() {
|
||||
loading.value = true
|
||||
try {
|
||||
await settingsAPI.setSetting(
|
||||
'sensory_preferences',
|
||||
JSON.stringify(sensoryPreferences.value),
|
||||
)
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
|
||||
_flash()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save sensory preferences:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
cookingEquipment,
|
||||
unitSystem,
|
||||
shoppingLocale,
|
||||
|
|
@ -106,8 +134,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
timeFirstLayout,
|
||||
loading,
|
||||
saved,
|
||||
|
||||
// Actions
|
||||
load,
|
||||
save,
|
||||
saveSensory,
|
||||
|
|
|
|||
Loading…
Reference in a new issue