kiwi/frontend/src/components/RecipeDetailPanel.vue
pyr0ball 95e76edaea
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Release / release (push) Waiting to run
feat(community): complete Layer A subcategory tagging (#118)
- RecipeBrowserPanel: fix onTagSearchInput using '_all' domain slug
  (backend validates domain — was silently returning empty results)
- RecipeDetailPanel: fetch and display accepted community category tags
  on recipe open; accepted tags shown with accent chip + checkmark,
  pending tags shown in muted style
- browserAPI.listRecipeTags() was already in api.ts but not consumed —
  now wired into RecipeDetailPanel onMounted as a background fetch
2026-04-25 23:31:30 -07:00

1693 lines
48 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Teleport to="body">
<!-- Backdrop click outside to close -->
<div class="detail-overlay" @click.self="$emit('close')">
<div ref="dialogRef" class="detail-panel" role="dialog" aria-modal="true" :aria-label="recipe.title" tabindex="-1">
<!-- Sticky header -->
<div class="detail-header">
<div class="header-badges">
<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 class="header-row">
<h2 class="detail-title">{{ recipe.title }}</h2>
<div class="header-actions flex gap-sm">
<button
class="btn btn-secondary btn-save"
:class="{ 'btn-saved': isSaved }"
@click="showSaveModal = true"
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
<!-- Cook mode toggle -->
<button
v-if="recipe.directions.length > 0"
class="btn btn-cook"
:class="{ 'btn-cook--active': cookModeActive }"
@click="cookModeActive ? exitCookMode() : enterCookMode()"
:aria-label="cookModeActive ? 'Exit cook mode' : 'Enter cook mode'"
:aria-pressed="cookModeActive"
>{{ cookModeActive ? '✕ Exit' : 'Cook' }}</button>
<button class="btn-close" @click="$emit('close')" aria-label="Close panel"></button>
</div>
</div>
<p v-if="recipe.notes" class="detail-notes">{{ recipe.notes }}</p>
<a
v-if="recipe.source_url"
:href="recipe.source_url"
target="_blank"
rel="noopener noreferrer"
class="source-link"
>View original </a>
</div>
<!-- Cook mode bar: progress + step counter -->
<div v-if="cookModeActive" class="cook-mode-bar" role="status" :aria-label="`Step ${cookStep + 1} of ${cookStepCount}`">
<div class="cook-progress-track">
<div
class="cook-progress-fill"
:style="{ width: `${cookProgress * 100}%` }"
></div>
</div>
<span class="cook-step-counter">Step {{ cookStep + 1 }} of {{ cookStepCount }}</span>
</div>
<!-- Normal scrollable body -->
<div v-if="!cookModeActive" class="detail-body">
<!-- Serving multiplier -->
<div class="serving-scale-row">
<span class="serving-scale-label text-sm text-muted">Scale:</span>
<div class="serving-scale-btns" role="group" aria-label="Serving multiplier">
<button
v-for="n in [1, 2, 3, 4]"
:key="n"
:class="['scale-btn', { active: servingScale === n }]"
:aria-pressed="servingScale === n"
@click="servingScale = n"
>{{ n }}×</button>
</div>
</div>
<!-- Ingredients: have vs. need in a two-column layout -->
<details open class="ingredients-collapsible">
<summary class="ingredients-collapsible-summary">
Ingredients
<span class="ingr-summary-counts">
<span v-if="recipe.matched_ingredients?.length" class="ingr-count ingr-count-have">{{ recipe.matched_ingredients.length }} </span>
<span v-if="recipe.missing_ingredients?.length" class="ingr-count ingr-count-need">{{ recipe.missing_ingredients.length }} needed</span>
</span>
</summary>
<div class="ingredients-grid">
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
<h3 class="col-label col-label-have">From your pantry</h3>
<ul class="ingredient-list">
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
<span class="ing-icon ing-icon-have"></span>
<span>{{ scaleIngredient(ing, servingScale) }}</span>
</li>
</ul>
</div>
<div v-if="recipe.missing_ingredients?.length > 0" class="ingredient-col ingredient-col-need">
<div class="col-header-row">
<h3 class="col-label col-label-need">Still needed</h3>
<div class="col-header-actions">
<button class="share-btn" @click="shareList" :title="shareCopied ? 'Copied!' : 'Copy / share list'">
{{ shareCopied ? '✓ Copied' : 'Share' }}
</button>
</div>
</div>
<ul class="ingredient-list">
<li v-for="ing in recipe.missing_ingredients" :key="ing" class="ing-row">
<label class="ing-check-label">
<input
type="checkbox"
class="ing-check"
:checked="checkedIngredients.has(ing)"
@change="toggleIngredient(ing)"
/>
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
</label>
<a
v-if="groceryLinkFor(ing)"
:href="groceryLinkFor(ing)!.url"
target="_blank"
rel="noopener noreferrer"
class="buy-link"
>Buy </a>
</li>
</ul>
<button
v-if="recipe.missing_ingredients.length > 1"
class="select-all-btn"
@click="toggleSelectAll"
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
</div>
</div>
</details>
<!-- Time & effort summary cards -->
<div v-if="recipe.time_effort" class="effort-summary">
<div class="effort-card effort-card-active">
<span class="effort-label">Active</span>
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.active_min) }}</span>
</div>
<div v-if="recipe.time_effort.passive_min > 0" class="effort-card effort-card-passive">
<span class="effort-label">Hands-off</span>
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.passive_min) }}</span>
</div>
<div class="effort-card effort-card-total">
<span class="effort-label">Total</span>
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.total_min) }}</span>
</div>
<div class="effort-level-badge" :class="'effort-' + recipe.time_effort.effort_label">
{{ recipe.time_effort.effort_label }}
</div>
</div>
<!-- Equipment chips -->
<div v-if="recipe.time_effort?.equipment?.length" class="equipment-chips">
<span
v-for="eq in recipe.time_effort.equipment"
:key="eq"
class="equipment-chip"
>{{ EQUIPMENT_ICONS[eq] ?? '🍴' }} {{ eq }}</span>
</div>
<!-- Swap candidates -->
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
<summary class="detail-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>
<!-- Nutrition panel -->
<div v-if="recipe.nutrition" class="detail-section">
<h3 class="section-label">Nutrition</h3>
<div class="nutrition-chips">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">🔥 {{ Math.round(recipe.nutrition.calories) }} kcal</span>
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat</span>
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
🍽 {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">~ estimated</span>
</div>
</div>
<!-- Prep notes -->
<div v-if="recipe.prep_notes.length > 0" class="detail-section">
<h3 class="section-label">Before you start</h3>
<ul class="prep-list">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-item">{{ note }}</li>
</ul>
</div>
<!-- Directions (annotated) -->
<details open v-if="recipe.directions.length > 0" class="steps-collapsible">
<summary class="steps-collapsible-summary">
Steps <span class="steps-count">({{ recipe.directions.length }})</span>
</summary>
<ol class="directions-list directions-list-annotated">
<li
v-for="(step, i) in recipe.directions"
:key="i"
class="text-sm direction-step direction-step-annotated"
:class="{ 'step-passive': stepAnalysis(i)?.is_passive }"
>
<div class="step-badge-row">
<span v-if="stepAnalysis(i)?.is_passive" class="step-type-badge step-type-wait">Wait</span>
<span v-else-if="stepAnalysis(i)" class="step-type-badge step-type-active">Active</span>
</div>
<p class="step-text">{{ step }}</p>
<p v-if="passiveHint(stepAnalysis(i))" class="step-passive-hint">{{ passiveHint(stepAnalysis(i)) }}</p>
</li>
</ol>
</details>
<!-- Community tags accepted location tags from other users -->
<div v-if="communityTags.length > 0" class="detail-section community-tags-section">
<h3 class="section-label">Community categories</h3>
<div class="community-tags-list">
<span
v-for="tag in communityTags"
:key="tag.id"
class="community-tag-chip"
:class="{ 'community-tag-chip--accepted': tag.accepted }"
:title="tag.accepted ? 'Confirmed by the community' : 'Pending confirmation'"
>
{{ tag.domain }} {{ tag.category }}<template v-if="tag.subcategory"> {{ tag.subcategory }}</template>
<span v-if="tag.accepted" class="community-tag-check" aria-label="Confirmed"></span>
</span>
</div>
</div>
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
<div style="height: var(--spacing-xl)" />
</div>
<!-- Cook mode: single-step view -->
<div
v-else
class="detail-body cook-step-view"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
>
<div class="cook-step-label">STEP {{ cookStep + 1 }}</div>
<div v-if="currentStepAnalysis" class="cook-step-badge-row">
<span
class="cook-step-badge"
:class="currentStepAnalysis.is_passive ? 'cook-badge--wait' : 'cook-badge--active'"
>{{ currentStepAnalysis.is_passive ? 'Wait' : 'Active' }}</span>
</div>
<p class="cook-step-text">{{ recipe.directions[cookStep] }}</p>
<p
v-if="currentStepAnalysis?.detected_minutes != null"
class="cook-step-hint"
>~{{ currentStepAnalysis.detected_minutes }} min hands-off</p>
<div class="cook-nav">
<button
class="btn cook-nav-prev"
:class="{ 'cook-nav--disabled': cookStep === 0 }"
:disabled="cookStep === 0"
:aria-label="cookStep === 0 ? 'No previous step' : 'Previous step'"
@click="prevStep"
> Prev</button>
<button
class="btn cook-nav-next"
:class="{ 'cook-nav--done': isLastStep }"
:aria-label="isLastStep ? 'Done cooking' : 'Next step'"
@click="nextStep"
>{{ isLastStep ? 'Done ✓' : 'Next →' }}</button>
</div>
</div>
<!-- Sticky footer -->
<div class="detail-footer">
<div v-if="cookDone" class="cook-success">
<span class="cook-success-icon"></span>
Enjoy your meal! Recipe dismissed from suggestions.
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
<!-- Leftover shelf-life section -->
<div v-if="leftoversLoading" class="leftovers-panel text-sm text-secondary mt-sm">
Working out storage info
</div>
<div v-else-if="leftovers && !leftoversDismissed" class="leftovers-panel mt-sm">
<div class="leftovers-header flex-between">
<span class="text-sm font-semibold">Leftovers</span>
<button class="btn-icon btn-xs" @click="leftoversDismissed = true" aria-label="Dismiss storage info"></button>
</div>
<div class="leftovers-grid mt-xs">
<div class="leftovers-cell">
<span class="leftovers-icon"></span>
<span class="text-sm">Fridge: <strong>{{ leftovers.fridge_days }} day{{ leftovers.fridge_days !== 1 ? 's' : '' }}</strong></span>
</div>
<div v-if="leftovers.freeze_days !== null" class="leftovers-cell">
<span class="leftovers-icon">🧊</span>
<span class="text-sm">Freezer: <strong>{{ leftovers.freeze_days }} day{{ leftovers.freeze_days !== 1 ? 's' : '' }}</strong></span>
</div>
</div>
<p v-if="leftovers.freeze_by_day" class="text-xs text-secondary mt-xs">
Freeze by day {{ leftovers.freeze_by_day }} for best results.
</p>
<p class="text-xs text-secondary mt-xs">{{ leftovers.storage_advice }}</p>
</div>
</div>
<template v-else>
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
<button
:class="['btn-bookmark-panel', { active: recipesStore.isBookmarked(recipe.id) }]"
@click="recipesStore.toggleBookmark(recipe)"
:aria-label="recipesStore.isBookmarked(recipe.id) ? `Remove bookmark: ${recipe.title}` : `Bookmark: ${recipe.title}`"
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
<template v-if="checkedCount > 0">
<div class="add-pantry-col">
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs"> Added to pantry!</p>
<div class="grocery-actions">
<button
class="btn btn-primary flex-1"
@click="copyGroceryList"
>{{ groceryCopied ? '✓ Copied!' : `📋 Grocery list (${checkedCount})` }}</button>
<button
class="btn btn-secondary"
:disabled="addingToPantry"
@click="addToPantry"
:title="`Add ${checkedCount} item${checkedCount !== 1 ? 's' : ''} to your pantry`"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>+ Pantry</span>
</button>
</div>
</div>
</template>
<button v-else class="btn btn-primary flex-1" @click="handleCook">
I cooked this
</button>
</template>
</div>
</div>
</div>
</Teleport>
<SaveRecipeModal
v-if="showSaveModal"
:recipe-id="recipe.id"
:recipe-title="recipe.title"
@close="showSaveModal = false"
@saved="showSaveModal = false"
@unsave="savedStore.unsave(recipe.id); showSaveModal = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { inventoryAPI, recipesAPI, browserAPI } from '../services/api'
import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue'
const dialogRef = ref<HTMLElement | null>(null)
let previousFocus: HTMLElement | null = null
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (cookModeActive.value) {
if (e.key === 'ArrowRight') {
e.preventDefault()
nextStep()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
prevStep()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
const focusable = dialogRef.value?.querySelector<HTMLElement>(
'button:not([disabled]), [href], input'
)
;(focusable ?? dialogRef.value)?.focus()
})
// Load community tags in the background — non-critical, silently skip on error
browserAPI.listRecipeTags(props.recipe.id).then((tags) => {
communityTags.value = tags
}).catch(() => {
// Community tags are supplemental; silently skip on error
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
const recipesStore = useRecipesStore()
const savedStore = useSavedRecipesStore()
const props = defineProps<{
recipe: RecipeSuggestion
groceryLinks: GroceryLink[]
}>()
const emit = defineEmits<{
close: []
cooked: [recipe: RecipeSuggestion]
}>()
const showSaveModal = ref(false)
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
const cookDone = ref(false)
// ── Community tags ────────────────────────────────────────
type CommunityTag = { id: number; domain: string; category: string; subcategory: string | null; pseudonym: string; upvotes: number; accepted: boolean }
const communityTags = ref<CommunityTag[]>([])
// ── Leftover shelf-life ────────────────────────────────────
type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }
const leftovers = ref<LeftoversData | null>(null)
const leftoversLoading = ref(false)
const leftoversDismissed = ref(false)
// ── Cook mode ─────────────────────────────────────────────
const cookModeActive = ref(false)
const cookStep = ref(0) // 0-indexed
function enterCookMode() {
cookModeActive.value = true
cookStep.value = 0
}
function exitCookMode() {
cookModeActive.value = false
cookStep.value = 0
}
function nextStep() {
const lastIdx = props.recipe.directions.length - 1
if (cookStep.value < lastIdx) {
cookStep.value++
} else {
handleCook()
exitCookMode()
}
}
function prevStep() {
if (cookStep.value > 0) cookStep.value--
}
// Reads step_analyses from kiwi#50 time_effort — null-safe
const currentStepAnalysis = computed(() => {
return props.recipe.time_effort?.step_analyses?.[cookStep.value] ?? null
})
const cookStepCount = computed(() => props.recipe.directions.length)
const isLastStep = computed(() => cookStep.value === cookStepCount.value - 1)
const cookProgress = computed(() =>
cookStepCount.value > 1 ? cookStep.value / (cookStepCount.value - 1) : 1
)
// Touch state for swipe navigation
const touchStartX = ref(0)
const touchStartY = ref(0)
function onTouchStart(e: TouchEvent) {
touchStartX.value = e.changedTouches[0]!.clientX
touchStartY.value = e.changedTouches[0]!.clientY
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0]!.clientX - touchStartX.value
const dy = e.changedTouches[0]!.clientY - touchStartY.value
// Require predominantly horizontal gesture
if (Math.abs(dx) >= 40 && Math.abs(dy) < 80) {
if (dx < 0) {
nextStep() // swipe left → next
} else {
prevStep() // swipe right → prev
}
}
}
const shareCopied = ref(false)
// Serving scale multiplier: 1×, 2×, 3×, 4×
const servingScale = ref(1)
/**
* Scale a freeform ingredient string by a multiplier.
* Handles integers, decimals, and simple fractions (1/2, 1/4, 3/4, etc.).
* Ranges like "2-3" are scaled on both ends.
* Returns the original string unchanged if no leading number is found.
*/
function scaleIngredient(ing: string, scale: number): string {
if (scale === 1) return ing
// Match an optional leading fraction OR decimal OR integer,
// optionally followed by a space and another fraction (mixed number like "1 1/2")
const numPat = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)`
const rangePat = new RegExp(`^${numPat}(?:\\s*-\\s*${numPat})?`)
const m = ing.match(rangePat)
if (!m) return ing
function parseFrac(s: string): number {
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
if (mixed) return parseInt(mixed[1]!) + parseInt(mixed[2]!) / parseInt(mixed[3]!)
const frac = s.match(/^(\d+)\/(\d+)$/)
if (frac) return parseInt(frac[1]!) / parseInt(frac[2]!)
return parseFloat(s)
}
function fmtNum(n: number): string {
// Try to express as a simple fraction for common baking values
const fracs: [number, string][] = [
[0.125, '1/8'], [0.25, '1/4'], [0.333, '1/3'], [0.5, '1/2'],
[0.667, '2/3'], [0.75, '3/4'],
]
for (const [val, str] of fracs) {
if (Math.abs(n - Math.round(n / val) * val) < 0.01 && n < 1) return str
}
// Mixed numbers
const whole = Math.floor(n)
const remainder = n - whole
if (whole > 0 && remainder > 0.05) {
for (const [val, str] of fracs) {
if (Math.abs(remainder - val) < 0.05) return `${whole} ${str}`
}
}
// Round to reasonable precision
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
}
const low = parseFrac(m[1]!)
const scaledLow = fmtNum(low * scale)
let scaled: string
if (m[2] !== undefined) {
const high = parseFrac(m[2])
scaled = `${scaledLow}-${fmtNum(high * scale)}`
} else {
scaled = scaledLow
}
return scaled + ing.slice(m[0].length)
}
// Time & effort helpers
function formatDetailMin(minutes: number): string {
if (minutes < 60) return `${minutes} min`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m === 0 ? `${h} hr` : `${h} hr ${m} min`
}
const EQUIPMENT_ICONS: Record<string, string> = {
oven: '♨',
stovetop: '🔥',
blender: '⚡',
'food processor': '⚡',
microwave: '📡',
grill: '🔥',
'slow cooker': '⏲',
'instant pot': '⏲',
mixer: '🌀',
skillet: '🍳',
'cast iron': '🍳',
wok: '🍳',
}
function stepAnalysis(i: number): StepAnalysis | null {
return props.recipe.time_effort?.step_analyses?.[i] ?? null
}
function passiveHint(analysis: StepAnalysis | null): string {
if (!analysis?.is_passive) return ''
if (analysis.detected_minutes) return `~${analysis.detected_minutes} min hands-off`
return 'Hands-off time'
}
// Shopping: add purchased ingredients to pantry
const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false)
const addedToPantry = ref(false)
const addError = ref<string | null>(null)
const groceryCopied = ref(false)
const checkedCount = computed(() => checkedIngredients.value.size)
function toggleIngredient(name: string) {
const next = new Set(checkedIngredients.value)
if (next.has(name)) {
next.delete(name)
} else {
next.add(name)
}
checkedIngredients.value = next
}
function toggleSelectAll() {
if (checkedIngredients.value.size === props.recipe.missing_ingredients.length) {
checkedIngredients.value = new Set()
} else {
checkedIngredients.value = new Set(props.recipe.missing_ingredients)
}
}
async function addToPantry() {
if (!checkedIngredients.value.size || addingToPantry.value) return
addingToPantry.value = true
addError.value = null
try {
const items = [...checkedIngredients.value].map((name) => ({ name, location: 'pantry' }))
const result = await inventoryAPI.bulkAddByName(items)
if (result.failed > 0 && result.added === 0) {
addError.value = 'Failed to add items. Please try again.'
} else {
addedToPantry.value = true
checkedIngredients.value = new Set()
}
} catch {
addError.value = 'Could not reach the pantry. Please try again.'
} finally {
addingToPantry.value = false
}
}
async function shareList() {
const items = props.recipe.missing_ingredients
if (!items?.length) return
const text = `Shopping list for ${props.recipe.title}:\n${items.map((i) => `${i}`).join('\n')}`
if (navigator.share) {
await navigator.share({ title: `Shopping list: ${props.recipe.title}`, text })
} else {
await navigator.clipboard.writeText(text)
shareCopied.value = true
setTimeout(() => { shareCopied.value = false }, 2000)
}
}
async function copyGroceryList() {
const items = [...checkedIngredients.value]
if (!items.length) return
const text = `Shopping list for ${props.recipe.title}:\n${items.map((i) => `${i}`).join('\n')}`
if (navigator.share) {
await navigator.share({ title: `Shopping list: ${props.recipe.title}`, text })
} else {
await navigator.clipboard.writeText(text)
groceryCopied.value = true
setTimeout(() => { groceryCopied.value = false }, 2000)
}
}
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
const needle = ingredient.toLowerCase()
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
}
async function handleCook() {
recipesStore.logCook(props.recipe.id, props.recipe.title)
cookDone.value = true
emit('cooked', props.recipe)
if (props.recipe.id) {
leftoversLoading.value = true
try {
leftovers.value = await recipesAPI.getLeftovers(props.recipe.id)
} catch {
// Silently skip — shelf life is supplemental info, not critical
} finally {
leftoversLoading.value = false
}
}
}
</script>
<style scoped>
/* ── Overlay / bottom-sheet shell ──────────────────────── */
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 400; /* above bottom-nav (200) and app-header (100) */
display: flex;
align-items: flex-end;
}
.detail-panel {
width: 100%;
max-height: 92dvh;
background: var(--color-bg-card);
border-radius: var(--radius-lg, 12px) var(--radius-lg, 12px) 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
}
/* Centered modal on wider screens */
@media (min-width: 640px) {
.detail-overlay {
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.detail-panel {
max-width: 680px;
max-height: 85dvh;
border-radius: var(--radius-lg, 12px);
}
}
/* ── Header ─────────────────────────────────────────────── */
.detail-header {
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.header-badges {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
margin-bottom: var(--spacing-xs);
}
.header-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.detail-title {
font-size: var(--font-size-lg);
font-weight: 700;
flex: 1;
line-height: 1.3;
color: var(--color-text-primary);
}
.header-actions {
align-items: center;
flex-shrink: 0;
}
.btn-save {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
}
.btn-saved {
color: var(--color-warning);
border-color: var(--color-warning);
}
/* ── Cook mode button ───────────────────────────────────── */
.btn-cook {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(232, 168, 32, 0.15);
border: 1px solid rgba(232, 168, 32, 0.3);
color: #f0bc48;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.btn-cook:hover {
background: rgba(232, 168, 32, 0.25);
border-color: rgba(232, 168, 32, 0.5);
}
.btn-cook--active {
background: rgba(232, 168, 32, 0.22);
border-color: rgba(232, 168, 32, 0.5);
}
@media (max-width: 380px) {
.btn-cook {
padding: 2px 8px;
font-size: var(--font-size-xs);
}
}
.btn-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 16px;
color: var(--color-text-muted);
border-radius: var(--radius-sm, 4px);
flex-shrink: 0;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.btn-close:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.detail-notes {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--spacing-xs);
line-height: 1.5;
}
/* ── Scrollable body ────────────────────────────────────── */
.detail-body {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
-webkit-overflow-scrolling: touch;
}
/* ── Serving scale row ──────────────────────────────────── */
.serving-scale-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.serving-scale-label {
white-space: nowrap;
}
.serving-scale-btns {
display: flex;
gap: var(--spacing-xs);
}
.scale-btn {
padding: 2px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.scale-btn.active {
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border-color: var(--color-primary);
}
/* ── Ingredients grid ───────────────────────────────────── */
.ingredients-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
/* Stack single column if only have or only need */
.ingredients-grid:has(.ingredient-col:only-child) {
grid-template-columns: 1fr;
}
@media (max-width: 420px) {
.ingredients-grid {
grid-template-columns: 1fr;
}
}
.ingredient-col {
padding: var(--spacing-sm);
border-radius: var(--radius-md, 8px);
}
.ingredient-col-have {
background: var(--color-success-bg, #dcfce7);
}
.ingredient-col-need {
background: var(--color-warning-bg, #fef9c3);
}
.col-label {
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--spacing-xs);
}
.col-label-have {
color: var(--color-success, #16a34a);
}
.col-label-need {
color: var(--color-warning, #ca8a04);
}
.ingredient-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ing-row {
display: flex;
align-items: baseline;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
line-height: 1.4;
}
.ing-icon {
font-size: 11px;
flex-shrink: 0;
font-weight: 700;
}
.ing-icon-have {
color: var(--color-success, #16a34a);
}
.ing-icon-need {
color: var(--color-warning, #ca8a04);
}
.ing-name {
flex: 1;
}
.source-link {
display: inline-block;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-decoration: none;
margin-top: var(--spacing-xs);
}
.source-link:hover {
color: var(--color-primary);
text-decoration: underline;
}
.col-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
}
.col-header-row .col-label {
margin-bottom: 0;
}
.col-header-actions {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
/* Ingredient checkboxes */
.ing-check-label {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
}
.ing-check {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-warning, #ca8a04);
}
.select-all-btn {
background: transparent;
border: none;
color: var(--color-warning, #ca8a04);
font-size: var(--font-size-xs);
cursor: pointer;
padding: var(--spacing-xs) 0;
text-decoration: underline;
display: block;
margin-top: var(--spacing-xs);
}
.select-all-btn:hover {
opacity: 0.8;
transform: none;
}
/* Add to pantry footer state */
.add-pantry-col {
display: flex;
flex-direction: column;
flex: 1;
gap: 2px;
}
.grocery-actions {
display: flex;
gap: var(--spacing-xs);
align-items: stretch;
}
.add-error {
color: var(--color-error, #dc2626);
}
.add-success {
color: var(--color-success, #16a34a);
font-weight: 600;
}
.btn-accent {
background: var(--color-success, #16a34a);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accent:hover {
opacity: 0.9;
transform: none;
}
.btn-accent:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.share-btn {
background: transparent;
border: 1px solid var(--color-warning, #ca8a04);
color: var(--color-warning, #ca8a04);
border-radius: var(--radius-sm, 4px);
font-size: var(--font-size-xs);
padding: 2px 8px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.share-btn:hover {
background: var(--color-warning-bg);
}
.buy-link {
font-size: var(--font-size-xs);
color: var(--color-primary);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.buy-link:hover {
text-decoration: underline;
}
/* ── Generic detail sections ────────────────────────────── */
.detail-section {
margin-bottom: var(--spacing-md);
}
.section-label {
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
/* ── Collapsible swaps ──────────────────────────────────── */
.detail-collapsible {
border-top: 1px solid var(--color-border);
padding: var(--spacing-sm) 0;
margin-bottom: var(--spacing-md);
}
.detail-collapsible-summary {
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
list-style: none;
color: var(--color-primary);
}
.detail-collapsible-summary::-webkit-details-marker {
display: none;
}
.swap-row {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.swap-row:last-child {
border-bottom: none;
}
/* ── Nutrition ──────────────────────────────────────────── */
.nutrition-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.nutrition-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
background: var(--color-bg-secondary, #f5f5f5);
color: var(--color-text-secondary);
white-space: nowrap;
}
.nutrition-chip-sugar {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.nutrition-chip-servings {
background: var(--color-info-bg);
color: var(--color-info-light);
}
.nutrition-chip-estimated {
font-style: italic;
opacity: 0.7;
}
/* ── Prep + Directions ──────────────────────────────────── */
.prep-list {
padding-left: var(--spacing-lg);
list-style-type: disc;
}
.prep-item {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-sm);
line-height: 1.6;
}
/* ── Ingredients collapsible ────────────────────────────── */
.ingredients-collapsible {
margin-bottom: var(--spacing-md);
}
.ingredients-collapsible-summary {
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
color: var(--color-text-primary);
}
.ingredients-collapsible-summary::-webkit-details-marker {
display: none;
}
.ingredients-collapsible-summary::before {
content: '\25B6';
font-size: 10px;
color: var(--color-text-muted);
transition: transform 0.15s;
display: inline-block;
}
details[open].ingredients-collapsible .ingredients-collapsible-summary::before {
transform: rotate(90deg);
}
.ingr-summary-counts {
display: flex;
gap: var(--spacing-xs);
margin-left: auto;
}
.ingr-count {
font-size: var(--font-size-xs);
padding: 1px 6px;
border-radius: var(--radius-pill);
}
.ingr-count-have {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.ingr-count-need {
background: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
/* ── Effort summary cards ───────────────────────────────── */
.effort-summary {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
align-items: center;
margin-bottom: var(--spacing-sm);
}
.effort-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-md, 8px);
min-width: 64px;
}
.effort-card-active {
background: var(--color-success-bg, #dcfce7);
}
.effort-card-passive {
background: var(--color-info-bg, #dbeafe);
}
.effort-card-total {
background: var(--color-bg-secondary, #f5f5f5);
}
.effort-label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.effort-value {
font-size: var(--font-size-sm);
font-weight: 700;
color: var(--color-text-primary);
}
.effort-level-badge {
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: capitalize;
padding: 2px 10px;
border-radius: var(--radius-pill);
margin-left: auto;
}
.effort-quick {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.effort-moderate {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info-light, #2563eb);
}
.effort-involved {
background: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
/* ── Equipment chips ────────────────────────────────────── */
.equipment-chips {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.equipment-chip {
font-size: var(--font-size-xs);
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--color-bg-secondary, #f5f5f5);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
/* ── Steps collapsible ──────────────────────────────────── */
.steps-collapsible {
margin-bottom: var(--spacing-md);
}
.steps-collapsible-summary {
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
list-style: none;
padding: var(--spacing-xs) 0;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.steps-collapsible-summary::-webkit-details-marker {
display: none;
}
.steps-collapsible-summary::before {
content: '\25B6';
font-size: 10px;
color: var(--color-text-muted);
transition: transform 0.15s;
display: inline-block;
}
details[open].steps-collapsible .steps-collapsible-summary::before {
transform: rotate(90deg);
}
.steps-count {
color: var(--color-text-muted);
font-weight: 400;
}
.directions-list-annotated {
padding-left: var(--spacing-md);
}
.direction-step-annotated {
margin-bottom: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius-sm, 4px);
border-left: 3px solid var(--color-border);
}
.step-passive {
border-left-color: var(--color-info-light, #60a5fa);
background: var(--color-info-bg, #dbeafe);
}
.step-badge-row {
margin-bottom: 4px;
}
.step-type-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 1px 6px;
border-radius: var(--radius-pill);
}
.step-type-active {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.step-type-wait {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info-light, #2563eb);
}
.step-text {
margin: 0;
line-height: 1.6;
}
.step-passive-hint {
margin: 4px 0 0;
font-size: var(--font-size-xs);
color: var(--color-info-light, #2563eb);
font-style: italic;
}
/* ── Cook mode bar ──────────────────────────────────────── */
.cook-mode-bar {
flex-shrink: 0;
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 4px;
}
.cook-progress-track {
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.cook-progress-fill {
height: 100%;
border-radius: 2px;
background: #f0bc48;
transition: width 0.25s ease;
}
.cook-step-counter {
font-size: 11px;
color: rgba(255, 248, 235, 0.38);
letter-spacing: 0.02em;
}
/* ── Cook mode step view ────────────────────────────────── */
.cook-step-view {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-md);
gap: var(--spacing-sm);
}
.cook-step-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 248, 235, 0.35);
}
.cook-step-badge-row {
display: flex;
gap: var(--spacing-xs);
}
.cook-step-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.03em;
}
.cook-badge--active {
background: rgba(232, 168, 32, 0.18);
color: #f0bc48;
border: 1px solid rgba(232, 168, 32, 0.35);
}
.cook-badge--wait {
background: rgba(96, 165, 250, 0.15);
color: #93c5fd;
border: 1px solid rgba(96, 165, 250, 0.3);
}
.cook-step-text {
font-size: 15px;
font-weight: 500;
color: rgba(255, 248, 235, 0.92);
line-height: 1.5;
margin: 0;
}
.cook-step-hint {
font-size: 11px;
color: rgba(255, 248, 235, 0.38);
margin: 0;
}
/* ── Cook mode navigation ───────────────────────────────── */
.cook-nav {
display: flex;
gap: var(--spacing-sm);
margin-top: auto;
padding-top: var(--spacing-md);
}
.cook-nav-prev {
flex: 1;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-md, 8px);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.cook-nav--disabled {
opacity: 0.35;
pointer-events: none;
cursor: default;
}
.cook-nav-next {
flex: 2;
background: rgba(232, 168, 32, 0.18);
border: 1px solid rgba(232, 168, 32, 0.4);
color: #f0bc48;
border-radius: var(--radius-md, 8px);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.cook-nav-next:hover {
background: rgba(232, 168, 32, 0.28);
}
.cook-nav--done {
background: rgba(127, 192, 115, 0.18);
border-color: rgba(127, 192, 115, 0.4);
color: #7fc073;
}
.cook-nav--done:hover {
background: rgba(127, 192, 115, 0.28);
}
/* ── Sticky footer ──────────────────────────────────────── */
.detail-footer {
padding: var(--spacing-md);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--spacing-sm);
align-items: center;
background: var(--color-bg-card);
flex-shrink: 0;
}
.btn-bookmark-panel {
background: var(--color-bg-secondary, #f5f5f5);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
padding: 6px 12px;
font-size: 16px;
line-height: 1;
color: var(--color-text-muted);
flex-shrink: 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-bookmark-panel:hover,
.btn-bookmark-panel.active {
color: var(--color-warning, #ca8a04);
background: var(--color-warning-bg, #fef9c3);
border-color: var(--color-warning, #ca8a04);
transform: none;
}
.cook-success {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
text-align: center;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-success, #16a34a);
gap: var(--spacing-xs);
}
.cook-success-icon {
font-size: 24px;
display: block;
}
.inline-spinner {
display: inline-block;
vertical-align: middle;
margin-right: var(--spacing-xs);
}
.mt-xs {
margin-top: var(--spacing-xs);
}
.ml-xs {
margin-left: var(--spacing-xs);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.leftovers-panel {
background: var(--color-surface-alt, var(--color-surface));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
text-align: left;
}
.leftovers-header {
align-items: center;
}
.leftovers-grid {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.leftovers-cell {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.leftovers-icon {
font-size: 1rem;
line-height: 1;
}
/* ── Community tags section ──────────────────────────────── */
.community-tags-section {
padding-top: var(--spacing-sm);
}
.community-tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.community-tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px var(--spacing-sm);
border-radius: var(--radius-pill, 999px);
font-size: var(--font-size-xs, 0.72rem);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
white-space: nowrap;
}
.community-tag-chip--accepted {
background: rgba(124, 111, 205, 0.12);
color: var(--color-accent, #7c6fcd);
border-color: rgba(124, 111, 205, 0.3);
}
.community-tag-check {
font-size: 0.65rem;
opacity: 0.8;
}
</style>