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:
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal file
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user