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="settings-view">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title text-xl mb-md">Settings</h2>
|
<h2 class="section-title text-xl mb-md">Settings</h2>
|
||||||
|
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
|
||||||
|
|
||||||
<!-- Cooking Equipment -->
|
<!-- Cooking Equipment -->
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -50,18 +51,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Sensory Preferences -->
|
<!-- Sensory Preferences -->
|
||||||
|
|
@ -134,17 +123,6 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Units -->
|
<!-- Units -->
|
||||||
|
|
@ -169,17 +147,6 @@
|
||||||
Imperial (oz, cups, °F)
|
Imperial (oz, cups, °F)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Shopping Locale -->
|
<!-- Shopping Locale -->
|
||||||
|
|
@ -220,17 +187,6 @@
|
||||||
<option value="br">Brazil (BRL R$)</option>
|
<option value="br">Brazil (BRL R$)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Time-First Layout -->
|
<!-- Time-First Layout -->
|
||||||
|
|
@ -258,17 +214,6 @@
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Data Sharing (cloud only) -->
|
<!-- Data Sharing (cloud only) -->
|
||||||
|
|
@ -393,6 +338,12 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Transition name="autosave-fade">
|
||||||
|
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
|
||||||
|
✓ Saved
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -871,4 +822,32 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
||||||
border-color: var(--color-border, #e0e0e0);
|
border-color: var(--color-border, #e0e0e0);
|
||||||
color: var(--color-text-secondary, #888);
|
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>
|
</style>
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
/**
|
|
||||||
* Settings Store
|
|
||||||
*
|
|
||||||
* Manages user settings (cooking equipment, preferences) using Pinia.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { settingsAPI } from '../services/api'
|
import { settingsAPI } from '../services/api'
|
||||||
import type { UnitSystem } from '../utils/units'
|
import type { UnitSystem } from '../utils/units'
|
||||||
import type { SensoryPreferences } from '../services/api'
|
import type { SensoryPreferences } from '../services/api'
|
||||||
|
|
@ -13,8 +7,12 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
||||||
|
|
||||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
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', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// State
|
|
||||||
const cookingEquipment = ref<string[]>([])
|
const cookingEquipment = ref<string[]>([])
|
||||||
const unitSystem = ref<UnitSystem>('metric')
|
const unitSystem = ref<UnitSystem>('metric')
|
||||||
const shoppingLocale = ref<string>('us')
|
const shoppingLocale = ref<string>('us')
|
||||||
|
|
@ -23,7 +21,40 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = 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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,8 +89,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
async function save() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,10 +108,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
||||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
||||||
])
|
])
|
||||||
saved.value = true
|
_flash()
|
||||||
setTimeout(() => {
|
|
||||||
saved.value = false
|
|
||||||
}, 2000)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save settings:', err)
|
console.error('Failed to save settings:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -81,24 +116,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for backward compat; autosave handles sensory changes now.
|
||||||
async function saveSensory() {
|
async function saveSensory() {
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
await settingsAPI.setSetting(
|
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
|
||||||
'sensory_preferences',
|
_flash()
|
||||||
JSON.stringify(sensoryPreferences.value),
|
|
||||||
)
|
|
||||||
saved.value = true
|
|
||||||
setTimeout(() => { saved.value = false }, 2000)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save sensory preferences:', err)
|
console.error('Failed to save sensory preferences:', err)
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
cookingEquipment,
|
cookingEquipment,
|
||||||
unitSystem,
|
unitSystem,
|
||||||
shoppingLocale,
|
shoppingLocale,
|
||||||
|
|
@ -106,8 +134,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
timeFirstLayout,
|
timeFirstLayout,
|
||||||
loading,
|
loading,
|
||||||
saved,
|
saved,
|
||||||
|
|
||||||
// Actions
|
|
||||||
load,
|
load,
|
||||||
save,
|
save,
|
||||||
saveSensory,
|
saveSensory,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue