Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import { get, post, patch, del } from '$lib/api/client.ts';
|
||||
import type { Food } from '$lib/api/types.ts';
|
||||
|
||||
let query = $state('');
|
||||
let allFoods = $state<Food[]>([]);
|
||||
let favorites = $state<Food[]>([]);
|
||||
let loading = $state(true);
|
||||
let editFood = $state<Food | null>(null);
|
||||
let showCreate = $state(false);
|
||||
let tab = $state<'all' | 'favorites'>('all');
|
||||
|
||||
// Image picker state
|
||||
let imageQuery = $state('');
|
||||
let imageResults = $state<Array<{url: string; thumbnail: string; title: string}>>([]);
|
||||
let imageSearching = $state(false);
|
||||
let imageSettingUrl = $state('');
|
||||
|
||||
async function searchImages() {
|
||||
if (!imageQuery.trim() || !editFood) return;
|
||||
imageSearching = true;
|
||||
try {
|
||||
const res = await post<{images: Array<{url: string; thumbnail: string; title: string}>}>('/api/images/search', { query: imageQuery + ' food', num: 9 });
|
||||
imageResults = res.images || [];
|
||||
} catch {} finally { imageSearching = false; }
|
||||
}
|
||||
|
||||
async function pickImage(fullUrl: string, thumbnailUrl: string) {
|
||||
if (!editFood) return;
|
||||
imageSettingUrl = fullUrl;
|
||||
const foodId = editFood.id;
|
||||
const urls = [fullUrl, thumbnailUrl];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await post<{success: boolean; image_path?: string}>(`/api/foods/${foodId}/image`, { url });
|
||||
if (res.success && res.image_path) {
|
||||
if (editFood) editFood.image_path = res.image_path;
|
||||
imageResults = [];
|
||||
imageSettingUrl = '';
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
imageSettingUrl = '';
|
||||
}
|
||||
|
||||
let newName = $state('');
|
||||
let newBrand = $state('');
|
||||
let newCal = $state(0);
|
||||
let newProtein = $state(0);
|
||||
let newCarbs = $state(0);
|
||||
let newFat = $state(0);
|
||||
let newUnit = $state('100g');
|
||||
let newServingName = $state('');
|
||||
let newServingAmount = $state(1);
|
||||
|
||||
let filtered = $derived(
|
||||
query.trim()
|
||||
? allFoods.filter(f => f.name.toLowerCase().includes(query.toLowerCase()) || (f.brand || '').toLowerCase().includes(query.toLowerCase()))
|
||||
: allFoods
|
||||
);
|
||||
|
||||
$effect(() => { loadAll(); });
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
try {
|
||||
[allFoods, favorites] = await Promise.all([
|
||||
get<Food[]>('/api/foods'),
|
||||
get<Food[]>('/api/favorites'),
|
||||
]);
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function toggleFavorite(food: Food) {
|
||||
const isFav = favorites.some(f => f.id === food.id);
|
||||
if (isFav) { await del(`/api/favorites/${food.id}`); }
|
||||
else { await post('/api/favorites', { food_id: food.id }); }
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function createFood() {
|
||||
const servings = [{ name: `1 ${newUnit}`, amount_in_base: 1.0, is_default: true }];
|
||||
if (newServingName) servings.push({ name: newServingName, amount_in_base: newServingAmount, is_default: false });
|
||||
await post('/api/foods', {
|
||||
name: newName, brand: newBrand || undefined,
|
||||
calories_per_base: newCal, protein_per_base: newProtein,
|
||||
carbs_per_base: newCarbs, fat_per_base: newFat,
|
||||
base_unit: newUnit, servings,
|
||||
});
|
||||
showCreate = false;
|
||||
newName = ''; newBrand = ''; newCal = 0; newProtein = 0; newCarbs = 0; newFat = 0;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function deleteFood() {
|
||||
if (!editFood) return;
|
||||
if (!confirm(`Delete "${editFood.name}"? This will archive it.`)) return;
|
||||
await del(`/api/foods/${editFood.id}`);
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function updateFood() {
|
||||
if (!editFood) return;
|
||||
await patch(`/api/foods/${editFood.id}`, {
|
||||
name: editFood.name, brand: editFood.brand,
|
||||
calories_per_base: editFood.calories_per_base,
|
||||
protein_per_base: editFood.protein_per_base,
|
||||
carbs_per_base: editFood.carbs_per_base,
|
||||
fat_per_base: editFood.fat_per_base,
|
||||
status: editFood.status,
|
||||
});
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function addAlias(foodId: string) {
|
||||
const alias = prompt('Add alias:');
|
||||
if (alias) {
|
||||
await post(`/api/foods/${foodId}/aliases`, { alias });
|
||||
if (editFood?.id === foodId) editFood = await get<Food>(`/api/foods/${foodId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Food Library</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">{allFoods.length} foods saved</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm gap-2" onclick={() => showCreate = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
New Food
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-4">
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'all'} onclick={() => tab = 'all'}>All ({allFoods.length})</button>
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'favorites'} onclick={() => tab = 'favorites'}>Favorites ({favorites.length})</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'all'}
|
||||
<div class="relative mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
|
||||
<input class="input input-bordered w-full pl-10" placeholder="Filter foods..." bind:value={query} />
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each filtered as food}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{food.name}</span>
|
||||
{#if food.status === 'ai_created'}<span class="badge badge-xs badge-info badge-outline">AI</span>{/if}
|
||||
{#if food.status === 'needs_review'}<span class="badge badge-xs badge-warning">Review</span>{/if}
|
||||
</div>
|
||||
{#if food.brand}<div class="text-xs text-base-content/40">{food.brand}</div>{/if}
|
||||
<div class="flex gap-3 mt-1 text-xs text-base-content/50">
|
||||
<span>{food.calories_per_base} cal</span>
|
||||
<span>{food.protein_per_base}g P</span>
|
||||
<span>{food.carbs_per_base}g C</span>
|
||||
<span>{food.fat_per_base}g F</span>
|
||||
<span class="text-base-content/30">per {food.base_unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)} title="Favorite">
|
||||
{#if favorites.some(f => f.id === food.id)}
|
||||
<span class="text-warning">★</span>
|
||||
{:else}
|
||||
<span class="text-base-content/30">☆</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => { editFood = food; imageResults = []; imageQuery = ''; }} title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">
|
||||
{#if query.trim()}No foods match "{query}"{:else}No foods yet{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each favorites as food}
|
||||
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold">{food.name}</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)}>
|
||||
<span class="text-warning">★</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if favorites.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">No favorites yet</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create food modal -->
|
||||
{#if showCreate}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => showCreate = false}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">New Food</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" placeholder="Food name *" bind:value={newName} />
|
||||
<input class="input input-bordered" placeholder="Brand (optional)" bind:value={newBrand} />
|
||||
<select class="select select-bordered" bind:value={newUnit}>
|
||||
<option value="100g">Per 100g</option>
|
||||
<option value="piece">Per piece</option>
|
||||
<option value="serving">Per serving</option>
|
||||
<option value="scoop">Per scoop</option>
|
||||
<option value="slice">Per slice</option>
|
||||
</select>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" placeholder="Calories" bind:value={newCal} />
|
||||
<input class="input input-bordered" type="number" placeholder="Protein (g)" bind:value={newProtein} />
|
||||
<input class="input input-bordered" type="number" placeholder="Carbs (g)" bind:value={newCarbs} />
|
||||
<input class="input input-bordered" type="number" placeholder="Fat (g)" bind:value={newFat} />
|
||||
</div>
|
||||
<div class="divider text-xs my-0">Optional Serving</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered input-sm" placeholder="e.g. 1 cup (240g)" bind:value={newServingName} />
|
||||
<input class="input input-bordered input-sm" type="number" step="0.01" placeholder="Base multiplier" bind:value={newServingAmount} />
|
||||
</div>
|
||||
<button class="btn btn-primary mt-1" onclick={createFood} disabled={!newName}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => showCreate = false}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit food modal -->
|
||||
{#if editFood}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => editFood = null}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">Edit: {editFood.name}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" bind:value={editFood.name} />
|
||||
<input class="input input-bordered" placeholder="Brand" bind:value={editFood.brand} />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.calories_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.protein_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.carbs_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.fat_per_base} />
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={editFood.status}>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ai_created">AI Created</option>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{#if editFood.aliases}
|
||||
<div class="text-sm font-medium">Aliases</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each editFood.aliases as a}
|
||||
<span class="badge badge-sm badge-outline">{a.alias}</span>
|
||||
{/each}
|
||||
<button class="badge badge-sm badge-primary badge-outline cursor-pointer" onclick={() => addAlias(editFood!.id)}>+ add</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image picker -->
|
||||
<div class="divider text-xs my-1">Image</div>
|
||||
{#if editFood.image_path}
|
||||
<div class="relative rounded-lg overflow-hidden h-32">
|
||||
<img src="/images/{editFood.image_path}" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute bottom-2 right-2">
|
||||
<button class="btn btn-xs btn-ghost bg-base-300/70" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>Change</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-outline w-full" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>
|
||||
Search for image
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if imageResults.length > 0 || imageSearching}
|
||||
<div class="flex gap-1 mt-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search images..." bind:value={imageQuery} onkeydown={(e) => { if (e.key === 'Enter') searchImages(); }} />
|
||||
<button class="btn btn-sm" onclick={searchImages} disabled={imageSearching}>
|
||||
{#if imageSearching}<span class="loading loading-spinner loading-xs"></span>{:else}Search{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 max-h-48 overflow-y-auto">
|
||||
{#each imageResults as img}
|
||||
<button
|
||||
class="relative rounded-lg overflow-hidden h-20 hover:ring-2 hover:ring-primary transition-all {imageSettingUrl === img.url ? 'opacity-50' : ''}"
|
||||
onclick={() => pickImage(img.url, img.thumbnail)}
|
||||
disabled={!!imageSettingUrl}
|
||||
>
|
||||
<img src={img.thumbnail} alt={img.title} class="w-full h-full object-cover" />
|
||||
{#if imageSettingUrl === img.url}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-300/50">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-primary flex-1" onclick={updateFood}>Save</button>
|
||||
<button class="btn btn-error btn-outline" onclick={deleteFood}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => editFood = null}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user