Merge branch 'feature/recipe-ui'
Recipe and Settings tabs complete. 96-module clean build.
This commit is contained in:
commit
9b890f5fde
6 changed files with 942 additions and 2 deletions
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
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
|
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