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:
parent
13d52a6b2c
commit
1e70b4b1f6
6 changed files with 942 additions and 2 deletions
|
|
@ -23,6 +23,18 @@
|
|||
>
|
||||
🧾 Receipts
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: currentTab === 'recipes' }]"
|
||||
@click="switchTab('recipes')"
|
||||
>
|
||||
🍳 Recipes
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: currentTab === 'settings' }]"
|
||||
@click="switchTab('settings')"
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
|
|
@ -33,6 +45,14 @@
|
|||
<div v-show="currentTab === 'receipts'" class="tab-content">
|
||||
<ReceiptsView />
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === 'recipes'" class="tab-content">
|
||||
<RecipesView />
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === 'settings'" class="tab-content">
|
||||
<SettingsView />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -48,11 +68,20 @@
|
|||
import { ref } from 'vue'
|
||||
import InventoryList from './components/InventoryList.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
|
||||
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
||||
await inventoryStore.fetchItems()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
524
frontend/src/components/RecipesView.vue
Normal file
524
frontend/src/components/RecipesView.vue
Normal 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>
|
||||
162
frontend/src/components/SettingsView.vue
Normal file
162
frontend/src/components/SettingsView.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
78
frontend/src/stores/recipes.ts
Normal file
78
frontend/src/stores/recipes.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
57
frontend/src/stores/settings.ts
Normal file
57
frontend/src/stores/settings.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue