TICKET-008 (frontend): Foods library view — search, pagination, edit, delete, restore
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import Dashboard from './components/Dashboard.svelte'
|
||||
import FoodSearch from './components/FoodSearch.svelte'
|
||||
import FoodEditor from './components/FoodEditor.svelte'
|
||||
import FoodLibrary from './components/FoodLibrary.svelte'
|
||||
import BarcodeScanner from './components/BarcodeScanner.svelte'
|
||||
|
||||
// Health check — every async view handles loading/error states (spec §8.2 rule 4)
|
||||
@@ -233,6 +234,9 @@
|
||||
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
|
||||
📷 Scan
|
||||
</button>
|
||||
<button type="button" class="target-btn" onclick={() => appView.current = 'foods'}>
|
||||
🍔 Foods
|
||||
</button>
|
||||
<button type="button" class="target-btn" onclick={openTargetForm}>
|
||||
🎯 Target
|
||||
</button>
|
||||
@@ -337,6 +341,9 @@
|
||||
{:else if appView.current === 'createFood'}
|
||||
<FoodEditor />
|
||||
|
||||
{:else if appView.current === 'foods'}
|
||||
<FoodLibrary />
|
||||
|
||||
{:else if appView.current === 'editMeal'}
|
||||
<FoodEditor mealId={mealEdit.foodId} />
|
||||
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
let saving = $state(false)
|
||||
let error = $state(null)
|
||||
|
||||
// Edit mode: an existing food (with id) was passed in — update, don't create.
|
||||
let isEdit = $derived(!!food?.id)
|
||||
|
||||
// ── Pre-fill from food prop ─────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (food) {
|
||||
@@ -176,12 +179,21 @@
|
||||
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
||||
serving_name: servingName.trim() || null,
|
||||
source,
|
||||
is_meal: false,
|
||||
is_meal: food?.is_meal ?? false,
|
||||
}
|
||||
// Include barcode when present
|
||||
if (barcode.trim()) payload.barcode = barcode.trim()
|
||||
else payload.barcode = null
|
||||
|
||||
if (isEdit) {
|
||||
const saved = await api.updateFood(food.id, payload)
|
||||
// Nutrition edits affect historical log rendering — refresh the day
|
||||
await refreshDayData()
|
||||
if (onSaved) onSaved(saved)
|
||||
else appView.current = 'dashboard'
|
||||
return
|
||||
}
|
||||
|
||||
const saved = await api.createFood(payload)
|
||||
// After save, offer to log the new food
|
||||
justSaved = { food: saved }
|
||||
@@ -224,10 +236,10 @@
|
||||
|
||||
function goBack() {
|
||||
reset()
|
||||
if (mealId) {
|
||||
appView.current = 'dashboard'
|
||||
} else if (onCancel) {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else if (mealId) {
|
||||
appView.current = 'dashboard'
|
||||
} else if (onSaved) {
|
||||
// Came from scan flow with no cancel — go to dashboard
|
||||
appView.current = 'dashboard'
|
||||
@@ -355,7 +367,7 @@
|
||||
|
||||
{:else}
|
||||
<!-- Creation/edit form -->
|
||||
<h3>{isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
|
||||
<h3>{isEdit ? 'Edit food' : isPrefilled ? 'Confirm & edit food' : 'Create food'}</h3>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
@@ -436,7 +448,7 @@
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save food'}
|
||||
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Save food'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={goBack} disabled={saving}>
|
||||
Cancel
|
||||
|
||||
@@ -1,5 +1,341 @@
|
||||
<script>
|
||||
// FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7)
|
||||
// FoodLibrary — Browsable/searchable/paginated food list with edit/delete/restore (spec §4.7).
|
||||
// Uses GET /api/foods with limit/offset; include_deleted toggle shows soft-deleted
|
||||
// foods (visually distinct, restorable). Edit reuses FoodEditor; meals open the
|
||||
// meal component editor instead (TICKET-007).
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../lib/api.js'
|
||||
import { appView, refreshDayData } from '../lib/stores.svelte.js'
|
||||
import { formatKcal } from '../lib/format.js'
|
||||
import FoodEditor from './FoodEditor.svelte'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
let foods = $state([])
|
||||
let loading = $state(true)
|
||||
let error = $state(null)
|
||||
let query = $state('')
|
||||
let offset = $state(0)
|
||||
let showDeleted = $state(false)
|
||||
let hasMore = $state(false)
|
||||
|
||||
// Edit state: which food is open in FoodEditor (null = list view)
|
||||
let editingFood = $state(null) // regular food edit
|
||||
let editingMealId = $state(null) // meal component editing
|
||||
|
||||
// Per-row action state
|
||||
let confirmingDeleteId = $state(null)
|
||||
let actionBusyId = $state(null)
|
||||
let actionError = $state(null)
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
// Fetch one extra row to know whether a next page exists
|
||||
const rows = await api.listFoods({
|
||||
q: query,
|
||||
limit: PAGE_SIZE + 1,
|
||||
offset,
|
||||
includeDeleted: showDeleted,
|
||||
})
|
||||
hasMore = rows.length > PAGE_SIZE
|
||||
foods = rows.slice(0, PAGE_SIZE)
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
foods = []
|
||||
hasMore = false
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load only — subsequent loads are triggered explicitly
|
||||
// (search submit, pagination, toggle) to avoid re-loading on every keystroke.
|
||||
onMount(load)
|
||||
|
||||
function search(e) {
|
||||
e.preventDefault()
|
||||
offset = 0
|
||||
load()
|
||||
}
|
||||
|
||||
function toggleDeleted() {
|
||||
showDeleted = !showDeleted
|
||||
offset = 0
|
||||
load()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
offset = Math.max(0, offset - PAGE_SIZE)
|
||||
load()
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (!hasMore) return
|
||||
offset += PAGE_SIZE
|
||||
load()
|
||||
}
|
||||
|
||||
function openEdit(food) {
|
||||
actionError = null
|
||||
if (food.is_meal) {
|
||||
editingMealId = food.id
|
||||
} else {
|
||||
editingFood = food
|
||||
}
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
editingFood = null
|
||||
editingMealId = null
|
||||
await refreshDayData() // food/meal edits affect log rendering + summary
|
||||
load()
|
||||
}
|
||||
|
||||
async function doDelete(food) {
|
||||
actionBusyId = food.id
|
||||
actionError = null
|
||||
try {
|
||||
await api.deleteFood(food.id)
|
||||
confirmingDeleteId = null
|
||||
await refreshDayData()
|
||||
await load()
|
||||
} catch (e) {
|
||||
actionError = e.message
|
||||
} finally {
|
||||
actionBusyId = null
|
||||
}
|
||||
}
|
||||
|
||||
async function doRestore(food) {
|
||||
actionBusyId = food.id
|
||||
actionError = null
|
||||
try {
|
||||
await api.restoreFood(food.id)
|
||||
await refreshDayData()
|
||||
await load()
|
||||
} catch (e) {
|
||||
actionError = e.message
|
||||
} finally {
|
||||
actionBusyId = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Display kcal for a row: per 100g or per item depending on unit type. */
|
||||
function rowKcal(food) {
|
||||
if (food.is_meal) return 'meal'
|
||||
if (food.calories_per_unit == null) return '—'
|
||||
const unit = food.unit_type === 'count' ? '/item' : '/100g'
|
||||
return `${formatKcal(Math.round(food.calories_per_unit))}${unit}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<p>FoodLibrary (placeholder)</p>
|
||||
<div class="food-library">
|
||||
{#if editingMealId}
|
||||
<FoodEditor mealId={editingMealId} onCancel={closeEditor} />
|
||||
{:else if editingFood}
|
||||
<FoodEditor food={editingFood} onSaved={closeEditor} onCancel={closeEditor} />
|
||||
{:else}
|
||||
<button type="button" class="back-btn" onclick={() => appView.current = 'dashboard'}>
|
||||
← Dashboard
|
||||
</button>
|
||||
<h2>Foods</h2>
|
||||
|
||||
<form class="search-bar" onsubmit={search}>
|
||||
<input type="text" bind:value={query} placeholder="Search name or brand…" />
|
||||
<button type="submit" disabled={loading}>Search</button>
|
||||
</form>
|
||||
|
||||
<label class="deleted-toggle">
|
||||
<input type="checkbox" checked={showDeleted} onchange={toggleDeleted} />
|
||||
Show deleted
|
||||
</label>
|
||||
|
||||
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Loading foods…</p>
|
||||
{:else if error}
|
||||
<p class="err" role="alert">{error}</p>
|
||||
{:else if foods.length === 0}
|
||||
<p class="status empty">No foods found.</p>
|
||||
{:else}
|
||||
<ul class="food-list">
|
||||
{#each foods as food (food.id)}
|
||||
<li class="food-row" class:deleted={food.deleted_at}>
|
||||
<div class="food-info">
|
||||
<span class="food-name">
|
||||
{food.name}
|
||||
{#if food.is_meal}<span class="meal-badge">meal</span>{/if}
|
||||
{#if food.deleted_at}<span class="deleted-badge">deleted</span>{/if}
|
||||
</span>
|
||||
{#if food.brand}<span class="food-brand">{food.brand}</span>{/if}
|
||||
<span class="food-kcal">{rowKcal(food)}</span>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
{#if food.deleted_at}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
onclick={() => doRestore(food)}
|
||||
disabled={actionBusyId === food.id}
|
||||
>
|
||||
{actionBusyId === food.id ? '…' : 'Restore'}
|
||||
</button>
|
||||
{:else if confirmingDeleteId === food.id}
|
||||
<span class="confirm-text">Delete?</span>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => doDelete(food)}
|
||||
disabled={actionBusyId === food.id}
|
||||
>
|
||||
{actionBusyId === food.id ? '…' : 'Yes'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => confirmingDeleteId = null}>No</button>
|
||||
{:else}
|
||||
<button type="button" class="secondary" onclick={() => openEdit(food)}>Edit</button>
|
||||
<button type="button" class="danger" onclick={() => confirmingDeleteId = food.id}>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="pagination">
|
||||
<button type="button" class="secondary" onclick={prevPage} disabled={offset === 0}>
|
||||
← Prev
|
||||
</button>
|
||||
<span class="page-info">Showing {offset + 1}–{offset + foods.length}</span>
|
||||
<button type="button" class="secondary" onclick={nextPage} disabled={!hasMore}>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.food-library { }
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link, #2563eb);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h2 { margin: 0 0 0.75rem; font-size: 1.25rem; }
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font: inherit;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.deleted-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status { color: var(--text-muted, #6b7280); }
|
||||
.status.empty { font-style: italic; }
|
||||
.err { color: #dc2626; font-size: 0.85rem; }
|
||||
|
||||
.food-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.food-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.6rem;
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid var(--border, #e5e7eb);
|
||||
}
|
||||
.food-row.deleted {
|
||||
opacity: 0.6;
|
||||
background: var(--bg-muted, #f9fafb);
|
||||
}
|
||||
.food-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.food-name { font-weight: 600; }
|
||||
.food-brand { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
|
||||
.food-kcal { color: var(--text-muted, #6b7280); font-size: 0.85rem; }
|
||||
|
||||
.meal-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.deleted-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.confirm-text { font-size: 0.85rem; color: var(--text, #111827); }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.page-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg, #fff);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button.secondary { background: var(--bg-muted, #f3f4f6); }
|
||||
button.danger { background: #fecaca; border-color: #ef4444; color: #991b1b; }
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,10 @@ export const api = {
|
||||
health: () => request('/api/health'),
|
||||
// Foods (spec §3.1)
|
||||
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
|
||||
listFoods: ({ q = '', limit = 20, offset = 0, includeDeleted = false } = {}) =>
|
||||
request(`/api/foods?q=${encodeURIComponent(q)}&limit=${limit}&offset=${offset}&include_deleted=${includeDeleted}`),
|
||||
deleteFood: (id) => request(`/api/foods/${id}`, { method: 'DELETE' }),
|
||||
restoreFood: (id) => request(`/api/foods/${id}/restore`, { method: 'POST' }),
|
||||
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
|
||||
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
||||
getFood: (id) => request(`/api/foods/${id}`),
|
||||
|
||||
Reference in New Issue
Block a user