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:
Yusuf Suleman
2026-03-28 23:20:40 -05:00
commit d3e250e361
159 changed files with 44797 additions and 0 deletions

View 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}