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,171 @@
<script lang="ts">
import { get, post } from '$lib/api/client.ts';
import type { QueueItem, Food } from '$lib/api/types.ts';
let queue = $state<QueueItem[]>([]);
let loading = $state(true);
let tab = $state<'queue' | 'merge'>('queue');
let mergeSourceQuery = $state('');
let mergeTargetQuery = $state('');
let mergeSource = $state<Food | null>(null);
let mergeTarget = $state<Food | null>(null);
let mergeSourceResults = $state<Food[]>([]);
let mergeTargetResults = $state<Food[]>([]);
let merging = $state(false);
$effect(() => { loadQueue(); });
async function loadQueue() {
loading = true;
try { queue = await get<QueueItem[]>('/api/resolution-queue'); }
catch {} finally { loading = false; }
}
function parseCandidates(json: string | undefined): Array<{ food_id: string; name: string; score: number }> {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
async function resolveItem(queueId: string, action: string, foodId?: string) {
await post(`/api/resolution-queue/${queueId}/resolve`, { action, food_id: foodId });
loadQueue();
}
async function searchMerge(query: string, which: 'source' | 'target') {
if (!query.trim()) return;
const results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=5`);
if (which === 'source') mergeSourceResults = results;
else mergeTargetResults = results;
}
async function doMerge() {
if (!mergeSource || !mergeTarget) return;
if (!confirm(`Merge "${mergeSource.name}" into "${mergeTarget.name}"? Source will be archived.`)) return;
merging = true;
try {
await post('/api/foods/merge', { source_id: mergeSource.id, target_id: mergeTarget.id });
mergeSource = null; mergeTarget = null;
mergeSourceQuery = ''; mergeTargetQuery = '';
alert('Merged successfully');
} catch {} finally { merging = false; }
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="mb-6">
<h1 class="text-3xl font-bold">Admin</h1>
<p class="text-base-content/50 text-sm mt-0.5">Review queue and manage duplicates</p>
</div>
<div role="tablist" class="tabs tabs-boxed mb-6">
<button role="tab" class="tab gap-2" class:tab-active={tab === 'queue'} onclick={() => tab = 'queue'}>
Review Queue
{#if queue.length > 0}<span class="badge badge-sm badge-warning">{queue.length}</span>{/if}
</button>
<button role="tab" class="tab" class:tab-active={tab === 'merge'} onclick={() => tab = 'merge'}>Merge Foods</button>
</div>
{#if tab === 'queue'}
{#if loading}
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
{:else if queue.length === 0}
<div class="text-center py-16">
<div class="p-4 rounded-2xl bg-success/15 inline-block mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="text-base-content/50">No items to review</div>
<div class="text-sm text-base-content/30 mt-1">All caught up!</div>
</div>
{:else}
<div class="space-y-3">
{#each queue as item}
{@const candidates = parseCandidates(item.candidates_json)}
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-5 shadow-sm card-hover">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-bold text-lg">"{item.raw_text}"</div>
<div class="flex gap-2 mt-1 text-xs text-base-content/40">
{#if item.source}<span class="badge badge-xs badge-ghost">{item.source}</span>{/if}
<span>confidence: {(item.confidence * 100).toFixed(0)}%</span>
{#if item.meal_type}<span>{item.meal_type}</span>{/if}
{#if item.entry_date}<span>{item.entry_date}</span>{/if}
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick={() => resolveItem(item.id, 'dismissed')}>Dismiss</button>
</div>
{#if candidates.length > 0}
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-2">Match to existing</div>
<div class="space-y-1.5">
{#each candidates as c}
<button
class="btn btn-sm btn-outline w-full justify-between"
onclick={() => resolveItem(item.id, 'matched', c.food_id)}
>
<span>{c.name}</span>
<span class="badge badge-sm badge-ghost">{(c.score * 100).toFixed(0)}%</span>
</button>
{/each}
</div>
{:else}
<div class="text-sm text-base-content/30">No candidates — create a new food manually from the Foods page</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<div class="rounded-xl border border-base-300 bg-base-200/50 p-6 shadow-md">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-xl bg-secondary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor"><path d="M8 5a1 1 0 100 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8zM12 15a1 1 0 100-2H6.414l1.293-1.293a1 1 0 10-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L6.414 15H12z"/></svg>
</div>
<div>
<h2 class="font-bold text-lg">Merge Duplicate Foods</h2>
<p class="text-xs text-base-content/40">Source gets archived. Entries and aliases move to target.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<div class="text-xs text-error font-medium uppercase tracking-wide mb-2">Source (will be archived)</div>
<div class="flex gap-1 mb-2">
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeSourceQuery} />
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeSourceQuery, 'source')}>Go</button>
</div>
{#if mergeSource}
<div class="rounded-lg border border-error/30 bg-error/5 p-3 text-sm font-medium">{mergeSource.name}</div>
{/if}
{#each mergeSourceResults as f}
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeSource = f; mergeSourceResults = []; }}>
{f.name}
</button>
{/each}
</div>
<div>
<div class="text-xs text-success font-medium uppercase tracking-wide mb-2">Target (will keep)</div>
<div class="flex gap-1 mb-2">
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeTargetQuery} />
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeTargetQuery, 'target')}>Go</button>
</div>
{#if mergeTarget}
<div class="rounded-lg border border-success/30 bg-success/5 p-3 text-sm font-medium">{mergeTarget.name}</div>
{/if}
{#each mergeTargetResults as f}
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeTarget = f; mergeTargetResults = []; }}>
{f.name}
</button>
{/each}
</div>
</div>
<button class="btn btn-warning w-full mt-6 gap-2" onclick={doMerge} disabled={!mergeSource || !mergeTarget || merging}>
{#if merging}<span class="loading loading-spinner loading-sm"></span>{/if}
Merge Foods
</button>
</div>
{/if}
</div>