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:
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal file
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return typeof window !== 'undefined' ? localStorage.getItem('session_token') : null;
|
||||
}
|
||||
|
||||
export function hasToken(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
|
||||
if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
throw new Error('No token');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> || {})
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('session_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function get<T>(path: string) {
|
||||
return api<T>(path);
|
||||
}
|
||||
|
||||
export function post<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function patch<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function put<T>(path: string, data: unknown) {
|
||||
return api<T>(path, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function del<T>(path: string) {
|
||||
return api<T>(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function today(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function formatDate(d: string): string {
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric'
|
||||
});
|
||||
}
|
||||
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal file
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
telegram_user_id?: string;
|
||||
}
|
||||
|
||||
export interface Food {
|
||||
id: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
barcode?: string;
|
||||
base_unit: string;
|
||||
calories_per_base: number;
|
||||
protein_per_base: number;
|
||||
carbs_per_base: number;
|
||||
fat_per_base: number;
|
||||
status: string;
|
||||
image_path?: string;
|
||||
servings: FoodServing[];
|
||||
aliases?: FoodAlias[];
|
||||
score?: number;
|
||||
match_type?: string;
|
||||
}
|
||||
|
||||
export interface FoodServing {
|
||||
id: string;
|
||||
food_id: string;
|
||||
name: string;
|
||||
amount_in_base: number;
|
||||
is_default: number;
|
||||
}
|
||||
|
||||
export interface FoodAlias {
|
||||
id: string;
|
||||
food_id: string;
|
||||
alias: string;
|
||||
alias_normalized: string;
|
||||
}
|
||||
|
||||
export interface FoodEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_id?: string;
|
||||
meal_type: string;
|
||||
entry_date: string;
|
||||
entry_type: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
serving_description?: string;
|
||||
snapshot_food_name: string;
|
||||
snapshot_serving_label?: string;
|
||||
snapshot_grams?: number;
|
||||
snapshot_calories: number;
|
||||
snapshot_protein: number;
|
||||
snapshot_carbs: number;
|
||||
snapshot_fat: number;
|
||||
source: string;
|
||||
entry_method: string;
|
||||
raw_text?: string;
|
||||
confidence_score?: number;
|
||||
note?: string;
|
||||
food_image_path?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DailyTotals {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
entry_count: number;
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
is_active: number;
|
||||
}
|
||||
|
||||
export interface MealTemplate {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
meal_type?: string;
|
||||
is_favorite: number;
|
||||
items: MealTemplateItem[];
|
||||
}
|
||||
|
||||
export interface MealTemplateItem {
|
||||
id: string;
|
||||
food_id: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
snapshot_food_name: string;
|
||||
snapshot_calories: number;
|
||||
snapshot_protein: number;
|
||||
snapshot_carbs: number;
|
||||
snapshot_fat: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
raw_text: string;
|
||||
proposed_food_id?: string;
|
||||
candidates_json?: string;
|
||||
confidence: number;
|
||||
meal_type?: string;
|
||||
entry_date?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExternalFood {
|
||||
name: string;
|
||||
brand?: string;
|
||||
barcode?: string;
|
||||
calories_per_100g: number;
|
||||
protein_per_100g: number;
|
||||
carbs_per_100g: number;
|
||||
fat_per_100g: number;
|
||||
serving_size_text?: string;
|
||||
serving_grams?: number;
|
||||
source: string;
|
||||
relevance_score?: number;
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
resolution_type: 'matched' | 'confirm' | 'queued' | 'quick_add' | 'ai_estimated' | 'external_match';
|
||||
confidence: number;
|
||||
matched_food?: Food;
|
||||
candidate_foods: Food[];
|
||||
external_results: ExternalFood[];
|
||||
ai_estimate?: Record<string, unknown>;
|
||||
parsed: {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
food_description: string;
|
||||
meal_type?: string;
|
||||
brand?: string;
|
||||
modifiers?: string;
|
||||
exclusions?: string;
|
||||
};
|
||||
raw_text: string;
|
||||
queue_id?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
|
||||
export const MEAL_LABELS: Record<MealType, string> = {
|
||||
breakfast: 'B',
|
||||
lunch: 'L',
|
||||
dinner: 'D',
|
||||
snack: 'S'
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { get, post } from '$lib/api/client.ts';
|
||||
import type { Food, MealType, ResolveResult } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
|
||||
let { date = '', defaultMeal = 'snack' as MealType, onSave = () => {}, onClose = () => {} }:
|
||||
{ date: string; defaultMeal: MealType; onSave: () => void; onClose: () => void } = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<Food[]>([]);
|
||||
let recentFoods = $state<Food[]>([]);
|
||||
let searching = $state(false);
|
||||
let saving = $state(false);
|
||||
let selectedFood = $state<Food | null>(null);
|
||||
let selectedServing = $state('');
|
||||
let quantity = $state(1);
|
||||
let mealType = $state<MealType>(defaultMeal);
|
||||
let mode = $state<'search' | 'quick' | 'ai'>('search');
|
||||
let quickCalories = $state(0);
|
||||
let quickName = $state('Quick add');
|
||||
let aiQuery = $state('');
|
||||
let aiResolving = $state(false);
|
||||
let aiResult = $state<ResolveResult & { snapshot_name_override?: string; note?: string } | null>(null);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
$effect(() => { loadRecent(); });
|
||||
|
||||
async function loadRecent() {
|
||||
try { recentFoods = await get<Food[]>('/api/foods/recent?limit=15'); } catch {}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.trim()) { results = []; return; }
|
||||
searching = true;
|
||||
try { results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=10`); }
|
||||
catch {} finally { searching = false; }
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(doSearch, 300);
|
||||
}
|
||||
|
||||
function selectFood(food: Food) {
|
||||
selectedFood = food;
|
||||
const defaultServing = food.servings?.find(s => s.is_default) || food.servings?.[0];
|
||||
selectedServing = defaultServing?.id || '';
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
async function aiResolve() {
|
||||
if (!aiQuery.trim()) return;
|
||||
aiResolving = true;
|
||||
aiResult = null;
|
||||
try {
|
||||
const result = await post<ResolveResult>('/api/foods/resolve', {
|
||||
raw_phrase: aiQuery,
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
source: 'web',
|
||||
});
|
||||
aiResult = result;
|
||||
// If matched or AI estimated, auto-select the food
|
||||
if ((result.resolution_type === 'matched' || result.resolution_type === 'ai_estimated') && result.matched_food) {
|
||||
selectFood(result.matched_food);
|
||||
quantity = result.parsed?.quantity || 1;
|
||||
}
|
||||
} catch {} finally { aiResolving = false; }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
if (mode === 'quick') {
|
||||
await post('/api/entries', {
|
||||
entry_type: 'quick_add',
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
snapshot_food_name: quickName,
|
||||
snapshot_calories: quickCalories,
|
||||
source: 'web',
|
||||
entry_method: 'quick_add',
|
||||
});
|
||||
} else if (selectedFood) {
|
||||
const entryData: Record<string, unknown> = {
|
||||
food_id: selectedFood.id,
|
||||
meal_type: mealType,
|
||||
entry_date: date,
|
||||
quantity,
|
||||
serving_id: selectedServing || undefined,
|
||||
source: 'web',
|
||||
entry_method: mode === 'ai' ? 'ai_plate' : 'search',
|
||||
raw_text: aiQuery || undefined,
|
||||
};
|
||||
if (aiResult?.snapshot_name_override) {
|
||||
entryData.snapshot_food_name_override = aiResult.snapshot_name_override;
|
||||
}
|
||||
if (aiResult?.note) {
|
||||
entryData.note = aiResult.note;
|
||||
}
|
||||
await post('/api/entries', entryData);
|
||||
}
|
||||
onSave();
|
||||
} catch {} finally { saving = false; }
|
||||
}
|
||||
|
||||
function calcNutrition(food: Food, qty: number, servingId: string) {
|
||||
const serving = food.servings?.find(s => s.id === servingId);
|
||||
const mult = serving ? qty * serving.amount_in_base : qty;
|
||||
return {
|
||||
cal: Math.round(food.calories_per_base * mult),
|
||||
pro: Math.round(food.protein_per_base * mult),
|
||||
carb: Math.round(food.carbs_per_base * mult),
|
||||
fat: Math.round(food.fat_per_base * mult),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal modal-open" role="dialog">
|
||||
<div class="modal-box max-w-lg max-h-[90vh]">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onClose}>X</button>
|
||||
<h3 class="font-bold text-lg mb-3">Add Food</h3>
|
||||
|
||||
<!-- Meal type pills -->
|
||||
<div class="flex gap-1 mb-3">
|
||||
{#each MEAL_TYPES as mt}
|
||||
<button class="btn btn-xs" class:btn-primary={mealType === mt} onclick={() => mealType = mt}>
|
||||
{mt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Mode tabs -->
|
||||
<div role="tablist" class="tabs tabs-boxed mb-3">
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'search'} onclick={() => { mode = 'search'; selectedFood = null; }}>Search</button>
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'ai'} onclick={() => { mode = 'ai'; selectedFood = null; }}>AI Describe</button>
|
||||
<button role="tab" class="tab" class:tab-active={mode === 'quick'} onclick={() => { mode = 'quick'; selectedFood = null; }}>Quick Add</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'quick'}
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered w-full" placeholder="Label (optional)" bind:value={quickName} />
|
||||
<input class="input input-bordered w-full" type="number" placeholder="Calories" bind:value={quickCalories} />
|
||||
<button class="btn btn-primary w-full" onclick={save} disabled={saving || quickCalories <= 0}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Add {quickCalories} cal to {mealType}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if mode === 'ai' && !selectedFood}
|
||||
<!-- AI describe mode -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
placeholder="Describe what you ate, e.g. 2 mince tacos, no sour cream A scoop of vanilla ice cream Homemade smash burger"
|
||||
bind:value={aiQuery}
|
||||
></textarea>
|
||||
<button class="btn btn-primary w-full" onclick={aiResolve} disabled={aiResolving || !aiQuery.trim()}>
|
||||
{#if aiResolving}<span class="loading loading-spinner loading-sm"></span> Estimating...{:else}Estimate with AI{/if}
|
||||
</button>
|
||||
{#if aiResult && aiResult.resolution_type === 'queued'}
|
||||
<div class="alert alert-warning text-sm">
|
||||
<span>Could not estimate. Queued for review.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiResult && aiResult.resolution_type === 'confirm' && aiResult.candidate_foods?.length}
|
||||
<div class="text-sm font-medium">Did you mean one of these?</div>
|
||||
{#each aiResult.candidate_foods as c}
|
||||
<button class="btn btn-sm btn-outline w-full justify-between" onclick={() => selectFood(c)}>
|
||||
<span>{c.name}</span>
|
||||
<span class="text-xs">{c.calories_per_base} cal/{c.base_unit}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedFood}
|
||||
<!-- Selected food detail -->
|
||||
<div class="card bg-base-200 p-3 mb-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-semibold">{selectedFood.name}</div>
|
||||
{#if selectedFood.brand}<div class="text-xs text-base-content/50">{selectedFood.brand}</div>{/if}
|
||||
{#if selectedFood.status === 'ai_created'}<span class="badge badge-xs badge-info">AI estimated</span>{/if}
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" onclick={() => selectedFood = null}>Change</button>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label py-0"><span class="label-text text-xs">Qty</span></label>
|
||||
<input class="input input-bordered input-sm w-full" type="number" min="0.25" step="0.25" bind:value={quantity} />
|
||||
</div>
|
||||
{#if selectedFood.servings?.length > 0}
|
||||
<div class="form-control flex-[2]">
|
||||
<label class="label py-0"><span class="label-text text-xs">Serving</span></label>
|
||||
<select class="select select-bordered select-sm w-full" bind:value={selectedServing}>
|
||||
{#each selectedFood.servings as s}
|
||||
<option value={s.id}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedFood}
|
||||
{@const n = calcNutrition(selectedFood, quantity, selectedServing)}
|
||||
<div class="grid grid-cols-4 gap-2 mt-3 text-center text-sm">
|
||||
<div><div class="font-bold text-primary">{n.cal}</div><div class="text-xs text-base-content/50">cal</div></div>
|
||||
<div><div class="font-bold text-secondary">{n.pro}g</div><div class="text-xs text-base-content/50">protein</div></div>
|
||||
<div><div class="font-bold text-accent">{n.carb}g</div><div class="text-xs text-base-content/50">carbs</div></div>
|
||||
<div><div class="font-bold text-info">{n.fat}g</div><div class="text-xs text-base-content/50">fat</div></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-primary w-full" onclick={save} disabled={saving}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Add to {mealType}
|
||||
</button>
|
||||
|
||||
{:else}
|
||||
<!-- Search mode -->
|
||||
<input
|
||||
class="input input-bordered w-full mb-2"
|
||||
placeholder="Search your foods..."
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
/>
|
||||
{#if searching}
|
||||
<div class="flex justify-center py-4"><span class="loading loading-spinner loading-md"></span></div>
|
||||
{:else}
|
||||
<div class="overflow-y-auto max-h-60">
|
||||
{#if query.trim() && results.length > 0}
|
||||
{#each results as food}
|
||||
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{food.name}</div>
|
||||
{#if food.brand}<span class="text-xs text-base-content/40">{food.brand}</span>{/if}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if query.trim()}
|
||||
<div class="text-center py-4">
|
||||
<div class="text-sm text-base-content/50 mb-2">No matches found</div>
|
||||
<button class="btn btn-sm btn-outline" onclick={() => { mode = 'ai'; aiQuery = query; }}>
|
||||
Try AI estimate for "{query}"
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if recentFoods.length > 0}
|
||||
<div class="text-xs text-base-content/50 mb-1">Recent</div>
|
||||
{/if}
|
||||
{#each recentFoods as food}
|
||||
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{food.name}</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if recentFoods.length === 0}
|
||||
<div class="text-center text-sm text-base-content/50 py-4">No foods yet. Use AI Describe to add your first food.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={onClose}>close</button></form>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { value = 0, goal = 0, label = '', unit = '', color = 'primary' }:
|
||||
{ value: number; goal: number; label: string; unit: string; color: string } = $props();
|
||||
|
||||
let pct = $derived(goal > 0 ? Math.min((value / goal) * 100, 100) : 0);
|
||||
let remaining = $derived(Math.max(goal - value, 0));
|
||||
let over = $derived(value > goal && goal > 0);
|
||||
|
||||
let borderColor = $derived(over ? 'border-l-error' : color === 'primary' ? 'border-l-primary' : color === 'secondary' ? 'border-l-secondary' : color === 'accent' ? 'border-l-accent' : 'border-l-info');
|
||||
let progressClass = $derived(over ? 'progress-error' : color === 'primary' ? 'progress-primary' : color === 'secondary' ? 'progress-secondary' : color === 'accent' ? 'progress-accent' : 'progress-info');
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200 rounded-xl p-4 border-l-4 {borderColor}">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">{label}</div>
|
||||
<div class="flex items-baseline gap-1.5 mt-1">
|
||||
<span class="text-2xl font-bold">{Math.round(value)}</span>
|
||||
<span class="text-sm text-base-content/40">/ {Math.round(goal)}{unit}</span>
|
||||
</div>
|
||||
<progress class="progress w-full h-1.5 mt-2 {progressClass}" value={pct} max="100"></progress>
|
||||
<div class="text-xs mt-1 {over ? 'text-error' : 'text-base-content/40'}">
|
||||
{#if over}{Math.round(value - goal)}{unit} over{:else}{Math.round(remaining)}{unit} left{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
let theme = $state('night');
|
||||
let { user = null, currentPage = '' }: { user?: { display_name: string } | null; currentPage?: string } = $props();
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'night' ? 'light' : 'night';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('session_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
theme = saved;
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mobileOpen = $state(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Dashboard', page: 'dashboard' },
|
||||
{ href: '/foods', label: 'Foods', page: 'foods' },
|
||||
{ href: '/goals', label: 'Goals', page: 'goals' },
|
||||
{ href: '/templates', label: 'Templates', page: 'templates' },
|
||||
{ href: '/admin', label: 'Admin', page: 'admin' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200/80 backdrop-blur-md sticky top-0 z-50 border-b border-base-300">
|
||||
<div class="navbar px-4">
|
||||
<div class="flex-1 flex items-center gap-1">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="btn btn-ghost btn-sm btn-circle sm:hidden" onclick={() => mobileOpen = !mobileOpen}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary hover:opacity-80 transition-opacity mr-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20V10"/>
|
||||
<path d="M18 20V4"/>
|
||||
<path d="M6 20v-4"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Calories</span>
|
||||
</a>
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="btn btn-ghost btn-sm hidden sm:inline-flex" class:btn-active={currentPage === item.page}>{item.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<!-- Theme toggle -->
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" checked={theme === 'light'} onchange={toggleTheme} />
|
||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
|
||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
|
||||
</label>
|
||||
<!-- User dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1">
|
||||
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<span class="hidden sm:inline text-sm">{user?.display_name || 'User'}</span>
|
||||
</div>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300 mt-2">
|
||||
<li><button onclick={logout}>Logout</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if mobileOpen}
|
||||
<div class="sm:hidden border-t border-base-300 px-4 py-2">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="block py-2 px-3 rounded-lg hover:bg-base-300 transition-colors {currentPage === item.page ? 'text-primary font-semibold' : 'text-base-content'}" onclick={() => mobileOpen = false}>{item.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user