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 Dashboard from './components/Dashboard.svelte'
|
||||||
import FoodSearch from './components/FoodSearch.svelte'
|
import FoodSearch from './components/FoodSearch.svelte'
|
||||||
import FoodEditor from './components/FoodEditor.svelte'
|
import FoodEditor from './components/FoodEditor.svelte'
|
||||||
|
import FoodLibrary from './components/FoodLibrary.svelte'
|
||||||
import BarcodeScanner from './components/BarcodeScanner.svelte'
|
import BarcodeScanner from './components/BarcodeScanner.svelte'
|
||||||
|
|
||||||
// Health check — every async view handles loading/error states (spec §8.2 rule 4)
|
// 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' }}>
|
<button type="button" class="fab scan-fab" onclick={() => { resetScanFlow(); appView.current = 'scan' }}>
|
||||||
📷 Scan
|
📷 Scan
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="target-btn" onclick={() => appView.current = 'foods'}>
|
||||||
|
🍔 Foods
|
||||||
|
</button>
|
||||||
<button type="button" class="target-btn" onclick={openTargetForm}>
|
<button type="button" class="target-btn" onclick={openTargetForm}>
|
||||||
🎯 Target
|
🎯 Target
|
||||||
</button>
|
</button>
|
||||||
@@ -337,6 +341,9 @@
|
|||||||
{:else if appView.current === 'createFood'}
|
{:else if appView.current === 'createFood'}
|
||||||
<FoodEditor />
|
<FoodEditor />
|
||||||
|
|
||||||
|
{:else if appView.current === 'foods'}
|
||||||
|
<FoodLibrary />
|
||||||
|
|
||||||
{:else if appView.current === 'editMeal'}
|
{:else if appView.current === 'editMeal'}
|
||||||
<FoodEditor mealId={mealEdit.foodId} />
|
<FoodEditor mealId={mealEdit.foodId} />
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@
|
|||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
let error = $state(null)
|
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 ─────────────────────────────────────────────
|
// ── Pre-fill from food prop ─────────────────────────────────────────────
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (food) {
|
if (food) {
|
||||||
@@ -176,12 +179,21 @@
|
|||||||
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
serving_size_g: servingSizeG ? parseFloat(servingSizeG) : null,
|
||||||
serving_name: servingName.trim() || null,
|
serving_name: servingName.trim() || null,
|
||||||
source,
|
source,
|
||||||
is_meal: false,
|
is_meal: food?.is_meal ?? false,
|
||||||
}
|
}
|
||||||
// Include barcode when present
|
// Include barcode when present
|
||||||
if (barcode.trim()) payload.barcode = barcode.trim()
|
if (barcode.trim()) payload.barcode = barcode.trim()
|
||||||
else payload.barcode = null
|
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)
|
const saved = await api.createFood(payload)
|
||||||
// After save, offer to log the new food
|
// After save, offer to log the new food
|
||||||
justSaved = { food: saved }
|
justSaved = { food: saved }
|
||||||
@@ -224,10 +236,10 @@
|
|||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
reset()
|
reset()
|
||||||
if (mealId) {
|
if (onCancel) {
|
||||||
appView.current = 'dashboard'
|
|
||||||
} else if (onCancel) {
|
|
||||||
onCancel()
|
onCancel()
|
||||||
|
} else if (mealId) {
|
||||||
|
appView.current = 'dashboard'
|
||||||
} else if (onSaved) {
|
} else if (onSaved) {
|
||||||
// Came from scan flow with no cancel — go to dashboard
|
// Came from scan flow with no cancel — go to dashboard
|
||||||
appView.current = 'dashboard'
|
appView.current = 'dashboard'
|
||||||
@@ -355,7 +367,7 @@
|
|||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Creation/edit form -->
|
<!-- 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}>
|
<form onsubmit={handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
@@ -436,7 +448,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" disabled={saving}>
|
<button type="submit" disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save food'}
|
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Save food'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="secondary" onclick={goBack} disabled={saving}>
|
<button type="button" class="secondary" onclick={goBack} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,5 +1,341 @@
|
|||||||
<script>
|
<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>
|
</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'),
|
health: () => request('/api/health'),
|
||||||
// Foods (spec §3.1)
|
// Foods (spec §3.1)
|
||||||
searchFoods: (q) => request(`/api/foods?q=${encodeURIComponent(q)}`),
|
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)}`),
|
searchFoodsByBarcode: (barcode) => request(`/api/foods?barcode=${encodeURIComponent(barcode)}`),
|
||||||
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
recentFoods: (limit = 10) => request(`/api/foods/recent?limit=${limit}`),
|
||||||
getFood: (id) => request(`/api/foods/${id}`),
|
getFood: (id) => request(`/api/foods/${id}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user