feat: recipe + settings frontend — Recipes and Settings tabs

- RecipesView: level selector (1-4), constraints/allergies tag inputs,
  hard day mode toggle, max missing input, expiry-first pantry extraction,
  recipe cards with collapsible swaps/directions, grocery links, rate
  limit banner
- SettingsView: cooking equipment tag input with quick-add chips, save
  with confirmation feedback
- stores/recipes.ts: Pinia store for recipe state + suggest() action
- stores/settings.ts: Pinia store for cooking_equipment persistence
- api.ts: RecipeRequest/Result/Suggestion types + recipesAPI + settingsAPI
- App.vue: two new tabs (Recipes, Settings), lazy inventory load on tab switch
This commit is contained in:
pyr0ball 2026-03-31 19:20:13 -07:00
parent 13d52a6b2c
commit 1e70b4b1f6
6 changed files with 942 additions and 2 deletions

View file

@ -23,6 +23,18 @@
> >
🧾 Receipts 🧾 Receipts
</button> </button>
<button
:class="['tab', { active: currentTab === 'recipes' }]"
@click="switchTab('recipes')"
>
🍳 Recipes
</button>
<button
:class="['tab', { active: currentTab === 'settings' }]"
@click="switchTab('settings')"
>
Settings
</button>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
@ -33,6 +45,14 @@
<div v-show="currentTab === 'receipts'" class="tab-content"> <div v-show="currentTab === 'receipts'" class="tab-content">
<ReceiptsView /> <ReceiptsView />
</div> </div>
<div v-show="currentTab === 'recipes'" class="tab-content">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content">
<SettingsView />
</div>
</div> </div>
</main> </main>
@ -48,11 +68,20 @@
import { ref } from 'vue' import { ref } from 'vue'
import InventoryList from './components/InventoryList.vue' import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue' import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import { useInventoryStore } from './stores/inventory'
const currentTab = ref<'inventory' | 'receipts'>('inventory') type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
function switchTab(tab: 'inventory' | 'receipts') { const currentTab = ref<Tab>('inventory')
const inventoryStore = useInventoryStore()
async function switchTab(tab: Tab) {
currentTab.value = tab currentTab.value = tab
if (tab === 'recipes' && inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
} }
</script> </script>

View file

@ -0,0 +1,524 @@
<template>
<div class="recipes-view">
<!-- Controls Panel -->
<div class="card mb-controls">
<h2 class="text-xl font-bold mb-md">Find Recipes</h2>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">Creativity Level</label>
<div class="flex flex-wrap gap-sm">
<button
v-for="lvl in levels"
:key="lvl.value"
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
@click="recipesStore.level = lvl.value"
>
{{ lvl.label }}
</button>
</div>
</div>
<!-- Wildcard warning -->
<div v-if="recipesStore.level === 4" class="status-badge status-warning wildcard-warning">
Wildcard mode uses LLM to generate creative recipes with whatever you have. Results may be
unusual.
<label class="flex-start gap-sm mt-xs">
<input type="checkbox" v-model="recipesStore.wildcardConfirmed" />
<span>I understand, go for it</span>
</label>
</div>
<!-- Dietary Constraints Tags -->
<div class="form-group">
<label class="form-label">Dietary Constraints</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.constraints"
:key="tag"
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" aria-label="Remove">×</button>
</span>
</div>
<input
class="form-input"
v-model="constraintInput"
placeholder="e.g. vegetarian, vegan, gluten-free — press Enter or comma"
@keydown="onConstraintKey"
@blur="commitConstraintInput"
/>
</div>
<!-- Allergies Tags -->
<div class="form-group">
<label class="form-label">Allergies (hard exclusions)</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.allergies"
:key="tag"
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" aria-label="Remove">×</button>
</span>
</div>
<input
class="form-input"
v-model="allergyInput"
placeholder="e.g. peanuts, shellfish, dairy — press Enter or comma"
@keydown="onAllergyKey"
@blur="commitAllergyInput"
/>
</div>
<!-- Hard Day Mode -->
<div class="form-group">
<label class="flex-start gap-sm hard-day-toggle">
<input type="checkbox" v-model="recipesStore.hardDayMode" />
<span class="form-label" style="margin-bottom: 0;">Hard Day Mode</span>
</label>
<p v-if="recipesStore.hardDayMode" class="text-sm text-secondary mt-xs">
Only suggests quick, simple recipes based on your saved equipment.
</p>
</div>
<!-- Max Missing -->
<div class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label>
<input
type="number"
class="form-input"
min="0"
max="5"
placeholder="Leave blank for no limit"
:value="recipesStore.maxMissing ?? ''"
@input="onMaxMissingInput"
/>
</div>
<!-- Suggest Button -->
<button
class="btn btn-primary btn-lg w-full"
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
@click="handleSuggest"
>
<span v-if="recipesStore.loading">
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes
</span>
<span v-else>🍳 Suggest Recipes</span>
</button>
<!-- Empty pantry nudge -->
<p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs">
Add items to your pantry first, then tap Suggest to find recipes.
</p>
</div>
<!-- Error -->
<div v-if="recipesStore.error" class="status-badge status-error mb-md">
{{ recipesStore.error }}
</div>
<!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in">
<!-- Rate limit warning -->
<div
v-if="recipesStore.result.rate_limited"
class="status-badge status-warning rate-limit-banner mb-md"
>
You've used your {{ recipesStore.result.rate_limit_count }} free suggestions today. Upgrade for
unlimited.
</div>
<!-- Element gaps -->
<div v-if="recipesStore.result.element_gaps.length > 0" class="card card-warning mb-md">
<p class="text-sm font-semibold">Your pantry is missing some flavor elements:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="gap in recipesStore.result.element_gaps"
:key="gap"
class="status-badge status-warning"
>{{ gap }}</span>
</div>
</div>
<!-- No suggestions -->
<div
v-if="recipesStore.result.suggestions.length === 0"
class="card text-center text-muted"
>
<p>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
</div>
<!-- Recipe Cards -->
<div class="grid-auto mb-md">
<div
v-for="recipe in recipesStore.result.suggestions"
:key="recipe.id"
class="card slide-up"
>
<!-- Header row -->
<div class="flex-between mb-sm">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard 🎲</span>
</div>
</div>
<!-- Notes -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
<!-- Missing ingredients -->
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
<p class="text-sm font-semibold text-warning">You'd need:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.missing_ingredients"
:key="ing"
class="status-badge status-warning"
>{{ ing }}</span>
</div>
</div>
<!-- Grocery links for this recipe's missing ingredients -->
<div v-if="groceryLinksForRecipe(recipe).length > 0" class="mb-sm">
<p class="text-sm font-semibold">Buy online:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<a
v-for="link in groceryLinksForRecipe(recipe)"
:key="link.ingredient + link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="grocery-link status-badge status-info"
>
{{ link.ingredient }} @ {{ link.retailer }}
</a>
</div>
</div>
<!-- Swap candidates collapsible -->
<details v-if="recipe.swap_candidates.length > 0" class="collapsible mb-sm">
<summary class="text-sm font-semibold collapsible-summary">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
<div
v-for="swap in recipe.swap_candidates"
:key="swap.original_name + swap.substitute_name"
class="swap-row text-sm"
>
<span class="font-semibold">{{ swap.original_name }}</span>
<span class="text-muted"> </span>
<span class="font-semibold">{{ swap.substitute_name }}</span>
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
</div>
</div>
</details>
<!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary">
Directions ({{ recipe.directions.length }} steps)
</summary>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</ol>
</details>
</div>
</div>
<!-- Grocery list summary -->
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
<h3 class="text-lg font-bold mb-sm">Shopping List</h3>
<ul class="grocery-list">
<li
v-for="item in recipesStore.result.grocery_list"
:key="item"
class="text-sm grocery-item"
>
{{ item }}
</li>
</ul>
</div>
</div>
<!-- Empty state when no results yet and pantry has items -->
<div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
class="card text-center text-muted"
>
<p class="text-lg">🍳</p>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const levels = [
{ value: 1, label: '1 — From Pantry' },
{ value: 2, label: '2 — Creative Swaps' },
{ value: 3, label: '3 — AI Scaffold' },
{ value: 4, label: '4 — Wildcard 🎲' },
]
// Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => {
const sorted = [...inventoryStore.items]
.filter((item) => item.status === 'available')
.sort((a, b) => {
if (!a.expiration_date && !b.expiration_date) return 0
if (!a.expiration_date) return 1
if (!b.expiration_date) return -1
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
})
return sorted.map((item) => item.product.name)
})
// Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return []
return recipesStore.result.grocery_links.filter((link) =>
recipe.missing_ingredients.includes(link.ingredient)
)
}
// Tag input helpers constraints
function addConstraint(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.constraints.includes(tag)) {
recipesStore.constraints = [...recipesStore.constraints, tag]
}
constraintInput.value = ''
}
function removeConstraint(tag: string) {
recipesStore.constraints = recipesStore.constraints.filter((c) => c !== tag)
}
function onConstraintKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addConstraint(constraintInput.value)
}
}
function commitConstraintInput() {
if (constraintInput.value.trim()) {
addConstraint(constraintInput.value)
}
}
// Tag input helpers allergies
function addAllergy(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.allergies.includes(tag)) {
recipesStore.allergies = [...recipesStore.allergies, tag]
}
allergyInput.value = ''
}
function removeAllergy(tag: string) {
recipesStore.allergies = recipesStore.allergies.filter((a) => a !== tag)
}
function onAllergyKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addAllergy(allergyInput.value)
}
}
function commitAllergyInput() {
if (allergyInput.value.trim()) {
addAllergy(allergyInput.value)
}
}
// Max missing number input
function onMaxMissingInput(e: Event) {
const target = e.target as HTMLInputElement
const val = parseInt(target.value)
recipesStore.maxMissing = isNaN(val) ? null : val
}
// Suggest handler
async function handleSuggest() {
await recipesStore.suggest(pantryItems.value)
}
onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
})
</script>
<style scoped>
.mb-controls {
margin-bottom: var(--spacing-md);
}
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mt-xs {
margin-top: var(--spacing-xs);
}
.ml-xs {
margin-left: var(--spacing-xs);
}
.wildcard-warning {
display: block;
margin-bottom: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
}
.hard-day-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
color: inherit;
opacity: 0.7;
transition: opacity 0.15s;
}
.chip-remove:hover {
opacity: 1;
transform: none;
}
.inline-spinner {
display: inline-block;
vertical-align: middle;
margin-right: var(--spacing-xs);
}
.rate-limit-banner {
display: block;
padding: var(--spacing-sm) var(--spacing-md);
}
.recipe-title {
flex: 1;
margin-right: var(--spacing-sm);
}
.collapsible {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.collapsible-summary {
cursor: pointer;
list-style: none;
padding: var(--spacing-xs) 0;
color: var(--color-primary);
}
.collapsible-summary::-webkit-details-marker {
display: none;
}
.collapsible-summary::before {
content: '▶ ';
font-size: 10px;
}
details[open] .collapsible-summary::before {
content: '▼ ';
}
.swap-row {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.swap-row:last-child {
border-bottom: none;
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
}
.grocery-link {
text-decoration: none;
cursor: pointer;
transition: opacity 0.2s;
}
.grocery-link:hover {
opacity: 0.8;
}
.grocery-list {
padding-left: var(--spacing-lg);
}
.grocery-item {
margin-bottom: var(--spacing-xs);
}
.results-section {
margin-top: var(--spacing-md);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.flex-between {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.recipe-title {
margin-right: 0;
}
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div class="settings-view">
<div class="card">
<h2 class="text-xl font-bold mb-md">Settings</h2>
<!-- Cooking Equipment -->
<section>
<h3 class="text-lg font-semibold mb-xs">Cooking Equipment</h3>
<p class="text-sm text-secondary mb-md">
Tell Kiwi what you have used when Hard Day Mode is on to filter out recipes requiring
equipment you don't own.
</p>
<!-- Current equipment tags -->
<div class="tags-wrap flex flex-wrap gap-xs mb-sm">
<span
v-for="item in settingsStore.cookingEquipment"
:key="item"
class="tag-chip status-badge status-info"
>
{{ item }}
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
</span>
</div>
<!-- Custom input -->
<div class="form-group">
<label class="form-label">Add equipment</label>
<input
class="form-input"
v-model="equipmentInput"
placeholder="Type equipment name, press Enter or comma"
@keydown="onEquipmentKey"
@blur="commitEquipmentInput"
/>
</div>
<!-- Quick-add chips -->
<div class="form-group">
<label class="form-label">Quick-add</label>
<div class="flex flex-wrap gap-xs">
<button
v-for="eq in quickAddOptions"
:key="eq"
:class="['btn', 'btn-sm', 'btn-secondary', { active: settingsStore.cookingEquipment.includes(eq) }]"
@click="toggleEquipment(eq)"
>
{{ eq }}
</button>
</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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
const settingsStore = useSettingsStore()
const equipmentInput = ref('')
const quickAddOptions = [
'Oven',
'Stovetop',
'Microwave',
'Air Fryer',
'Instant Pot',
'Slow Cooker',
'Grill',
'Blender',
]
function addEquipment(value: string) {
const item = value.trim()
if (item && !settingsStore.cookingEquipment.includes(item)) {
settingsStore.cookingEquipment = [...settingsStore.cookingEquipment, item]
}
equipmentInput.value = ''
}
function removeEquipment(item: string) {
settingsStore.cookingEquipment = settingsStore.cookingEquipment.filter((e) => e !== item)
}
function toggleEquipment(item: string) {
if (settingsStore.cookingEquipment.includes(item)) {
removeEquipment(item)
} else {
addEquipment(item)
}
}
function onEquipmentKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addEquipment(equipmentInput.value)
}
}
function commitEquipmentInput() {
if (equipmentInput.value.trim()) {
addEquipment(equipmentInput.value)
}
}
onMounted(async () => {
await settingsStore.load()
})
</script>
<style scoped>
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mb-xs {
margin-bottom: var(--spacing-xs);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
color: inherit;
opacity: 0.7;
transition: opacity 0.15s;
}
.chip-remove:hover {
opacity: 1;
transform: none;
}
</style>

View file

@ -404,4 +404,94 @@ export const exportAPI = {
}, },
} }
// ========== Recipes & Settings Types ==========
export interface SwapCandidate {
original_name: string
substitute_name: string
constraint_label: string
explanation: string
compensation_hints: Record<string, string>[]
}
export interface RecipeSuggestion {
id: number
title: string
match_count: number
element_coverage: Record<string, number>
swap_candidates: SwapCandidate[]
missing_ingredients: string[]
directions: string[]
notes: string
level: number
is_wildcard: boolean
}
export interface GroceryLink {
ingredient: string
retailer: string
url: string
}
export interface RecipeResult {
suggestions: RecipeSuggestion[]
element_gaps: string[]
grocery_list: string[]
grocery_links: GroceryLink[]
rate_limited: boolean
rate_limit_count: number
}
export interface RecipeRequest {
pantry_items: string[]
level: number
constraints: string[]
allergies: string[]
expiry_first: boolean
hard_day_mode: boolean
max_missing: number | null
style_id: string | null
wildcard_confirmed: boolean
}
export interface Staple {
slug: string
name: string
category: string
dietary_tags: string[]
}
// ========== Recipes API ==========
export const recipesAPI = {
async suggest(req: RecipeRequest): Promise<RecipeResult> {
const response = await api.post('/recipes/suggest', req)
return response.data
},
async getRecipe(id: number): Promise<RecipeSuggestion> {
const response = await api.get(`/recipes/${id}`)
return response.data
},
async listStaples(dietary?: string): Promise<Staple[]> {
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
return response.data
},
}
// ========== Settings API ==========
export const settingsAPI = {
async getSetting(key: string): Promise<string | null> {
try {
const response = await api.get(`/settings/${key}`)
return response.data.value
} catch {
return null
}
},
async setSetting(key: string, value: string): Promise<void> {
await api.put(`/settings/${key}`, { value })
},
}
export default api export default api

View file

@ -0,0 +1,78 @@
/**
* Recipes Store
*
* Manages recipe suggestion state and request parameters using Pinia.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeRequest } from '../services/api'
export const useRecipesStore = defineStore('recipes', () => {
// State
const result = ref<RecipeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const level = ref(1)
const constraints = ref<string[]>([])
const allergies = ref<string[]>([])
const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null)
const wildcardConfirmed = ref(false)
// Actions
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
const req: RecipeRequest = {
pantry_items: pantryItems,
level: level.value,
constraints: constraints.value,
allergies: allergies.value,
expiry_first: true,
hard_day_mode: hardDayMode.value,
max_missing: maxMissing.value,
style_id: styleId.value,
wildcard_confirmed: wildcardConfirmed.value,
}
try {
result.value = await recipesAPI.suggest(req)
} catch (err: unknown) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'Failed to get recipe suggestions'
}
console.error('Error fetching recipe suggestions:', err)
} finally {
loading.value = false
}
}
function clearResult() {
result.value = null
error.value = null
wildcardConfirmed.value = false
}
return {
// State
result,
loading,
error,
level,
constraints,
allergies,
hardDayMode,
maxMissing,
styleId,
wildcardConfirmed,
// Actions
suggest,
clearResult,
}
})

View file

@ -0,0 +1,57 @@
/**
* Settings Store
*
* Manages user settings (cooking equipment, preferences) using Pinia.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { settingsAPI } from '../services/api'
export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const loading = ref(false)
const saved = ref(false)
// Actions
async function load() {
loading.value = true
try {
const raw = await settingsAPI.getSetting('cooking_equipment')
if (raw) {
cookingEquipment.value = JSON.parse(raw)
}
} catch (err: unknown) {
console.error('Failed to load settings:', err)
} finally {
loading.value = false
}
}
async function save() {
loading.value = true
try {
await settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value))
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} catch (err: unknown) {
console.error('Failed to save settings:', err)
} finally {
loading.value = false
}
}
return {
// State
cookingEquipment,
loading,
saved,
// Actions
load,
save,
}
})