chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -0,0 +1 @@
@import 'tailwindcss';

View file

@ -0,0 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
// Authentication handled via Mana Middleware (client-side)
}
interface PageData {
// Page data types
}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,9 @@
import { type Handle } from '@sveltejs/kit';
/**
* Server hooks for Nutriphi Web
* Authentication is handled client-side via Mana Middleware
*/
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
};

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import type { FoodItem } from '$lib/types/meal';
interface Props {
items: FoodItem[];
}
let { items }: Props = $props();
function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
protein: 'Protein',
vegetable: 'Gemüse',
grain: 'Getreide',
fruit: 'Obst',
dairy: 'Milchprodukt',
fat: 'Fett',
processed: 'Verarbeitet',
beverage: 'Getränk',
};
return labels[category] || category;
}
function getCategoryColor(category: string): string {
const colors: Record<string, string> = {
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
};
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
}
</script>
{#if items.length === 0}
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
{:else}
<div class="space-y-2">
{#each items as item (item.id)}
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
<div class="flex items-center gap-3">
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
{getCategoryLabel(item.category)}
</span>
<div>
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
</div>
</div>
<div class="text-right">
{#if item.calories}
<p class="font-semibold text-gray-900 dark:text-white">
{Math.round(item.calories)} kcal
</p>
{/if}
{#if item.confidence}
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,110 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
interface Props {
meal: Meal;
onclick?: () => void;
}
let { meal, onclick }: Props = $props();
function getMealTypeLabel(type: string): string {
const labels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};
return labels[type] || type;
}
const healthColor = $derived(() => {
if (!meal.health_score) return 'text-gray-400';
if (meal.health_score >= 8) return 'text-green-500';
if (meal.health_score >= 6) return 'text-yellow-500';
if (meal.health_score >= 4) return 'text-orange-500';
return 'text-red-500';
});
function formatDate(timestamp: string): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<button
{onclick}
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
>
{#if meal.photo_url}
<img
src={meal.photo_url}
alt={getMealTypeLabel(meal.meal_type)}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
{/if}
<!-- Overlay -->
<div
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4"
>
<div class="flex items-end justify-between">
<div>
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
<p class="text-sm text-gray-300">
{formatDate(meal.timestamp)}{formatTime(meal.timestamp)}
</p>
</div>
<div class="text-right">
{#if meal.total_calories}
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
<p class="text-xs text-gray-300">kcal</p>
{/if}
</div>
</div>
{#if meal.health_score}
<div class="mt-2 flex items-center gap-1">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
<div
class="h-full rounded-full {meal.health_score >= 7
? 'bg-green-500'
: meal.health_score >= 5
? 'bg-yellow-500'
: 'bg-red-500'}"
style="width: {meal.health_score * 10}%"
></div>
</div>
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
</div>
{/if}
</div>
<!-- Analysis Status Badge -->
{#if meal.analysis_status === 'pending'}
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white"
>
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
Analysiert...
</div>
{:else if meal.analysis_status === 'failed'}
<div
class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white"
>
Fehler
</div>
{/if}
</button>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import type { Meal, MealType } from '$lib/types/meal';
import { mealsStore } from '$lib/stores/meals.svelte';
interface Props {
meal: Meal;
isOpen: boolean;
onClose: () => void;
}
let { meal, isOpen, onClose }: Props = $props();
// Form state - initialized from meal
let mealType = $state<MealType>(meal.meal_type);
let userNotes = $state(meal.user_notes || '');
let userRating = $state(meal.user_rating || 0);
let isSaving = $state(false);
// Reset form when meal changes
$effect(() => {
mealType = meal.meal_type;
userNotes = meal.user_notes || '';
userRating = meal.user_rating || 0;
});
const mealTypes: { value: MealType; label: string }[] = [
{ value: 'breakfast', label: 'Frühstück' },
{ value: 'lunch', label: 'Mittagessen' },
{ value: 'dinner', label: 'Abendessen' },
{ value: 'snack', label: 'Snack' },
];
async function handleSave() {
isSaving = true;
try {
await mealsStore.updateMeal(meal.id, {
meal_type: mealType,
user_notes: userNotes || undefined,
user_rating: userRating || undefined,
});
onClose();
} catch (err) {
console.error('Failed to save meal:', err);
} finally {
isSaving = false;
}
}
function handleRatingClick(rating: number) {
userRating = userRating === rating ? 0 : rating;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal -->
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Schließen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4">
<!-- Meal Type -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Art der Mahlzeit
</label>
<div class="grid grid-cols-2 gap-2">
{#each mealTypes as type}
<button
onclick={() => (mealType = type.value)}
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType ===
type.value
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{type.label}
</button>
{/each}
</div>
</div>
<!-- Rating -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bewertung
</label>
<div class="flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<button
onclick={() => handleRatingClick(star)}
class="text-2xl transition-transform hover:scale-110 {star <= userRating
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'}"
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
>
</button>
{/each}
</div>
</div>
<!-- Notes -->
<div>
<label
for="notes"
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Notizen
</label>
<textarea
id="notes"
bind:value={userNotes}
rows="3"
placeholder="Notizen zu dieser Mahlzeit..."
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex gap-3">
<button
onclick={onClose}
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={isSaving}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
import MealCard from './MealCard.svelte';
interface Props {
meals: Meal[];
onMealClick: (meal: Meal) => void;
}
let { meals, onMealClick }: Props = $props();
</script>
{#if meals.length === 0}
<div class="flex h-64 flex-col items-center justify-center text-center">
<div class="mb-4 text-6xl">🥗</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
<p class="text-gray-600 dark:text-gray-400">Erfasse deine erste Mahlzeit mit einem Foto</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each meals as meal (meal.id)}
<MealCard {meal} onclick={() => onMealClick(meal)} />
{/each}
</div>
{/if}

View file

@ -0,0 +1,162 @@
<script lang="ts">
import type { Meal } from '$lib/types/meal';
interface Props {
meal: Meal;
showDetailed?: boolean;
}
let { meal, showDetailed = false }: Props = $props();
const healthColor = $derived(() => {
if (!meal.health_score) return 'bg-gray-400';
if (meal.health_score >= 8) return 'bg-green-500';
if (meal.health_score >= 6) return 'bg-yellow-500';
if (meal.health_score >= 4) return 'bg-orange-500';
return 'bg-red-500';
});
const healthLabel = $derived(() => {
if (!meal.health_category) return '';
const labels: Record<string, string> = {
very_healthy: 'Sehr gesund',
healthy: 'Gesund',
moderate: 'Moderat',
unhealthy: 'Ungesund',
};
return labels[meal.health_category] || '';
});
</script>
<div class="space-y-4">
<!-- Calories Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{meal.total_calories ? Math.round(meal.total_calories) : '—'} kcal
</p>
{#if healthLabel()}
<p class="text-sm text-gray-600 dark:text-gray-400">{healthLabel()}</p>
{/if}
</div>
{#if meal.health_score}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full {healthColor()}"></div>
<span class="text-xl font-semibold text-gray-900 dark:text-white">
{meal.health_score}/10
</span>
</div>
{/if}
</div>
<!-- Macro Pills -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-xl bg-blue-50 p-3 text-center dark:bg-blue-900/20">
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Protein</p>
</div>
<div class="rounded-xl bg-green-50 p-3 text-center dark:bg-green-900/20">
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Carbs</p>
</div>
<div class="rounded-xl bg-orange-50 p-3 text-center dark:bg-orange-900/20">
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : '—'}g
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">Fett</p>
</div>
</div>
<!-- Detailed Progress Bars -->
{#if showDetailed}
<div class="space-y-3 pt-2">
<!-- Protein -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Protein</span>
<span class="font-medium text-blue-600 dark:text-blue-400">
{meal.total_protein ? Math.round(meal.total_protein) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-blue-500 transition-all"
style="width: {Math.min(((meal.total_protein || 0) / 50) * 100, 100)}%"
></div>
</div>
</div>
<!-- Carbs -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Kohlenhydrate</span>
<span class="font-medium text-green-600 dark:text-green-400">
{meal.total_carbs ? Math.round(meal.total_carbs) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-green-500 transition-all"
style="width: {Math.min(((meal.total_carbs || 0) / 100) * 100, 100)}%"
></div>
</div>
</div>
<!-- Fat -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Fett</span>
<span class="font-medium text-orange-600 dark:text-orange-400">
{meal.total_fat ? Math.round(meal.total_fat) : 0}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-orange-500 transition-all"
style="width: {Math.min(((meal.total_fat || 0) / 65) * 100, 100)}%"
></div>
</div>
</div>
<!-- Fiber -->
{#if meal.total_fiber !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Ballaststoffe</span>
<span class="font-medium text-purple-600 dark:text-purple-400">
{Math.round(meal.total_fiber)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-purple-500 transition-all"
style="width: {Math.min((meal.total_fiber / 25) * 100, 100)}%"
></div>
</div>
</div>
{/if}
<!-- Sugar -->
{#if meal.total_sugar !== undefined}
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Zucker</span>
<span class="font-medium text-pink-600 dark:text-pink-400">
{Math.round(meal.total_sugar)}g
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-pink-500 transition-all"
style="width: {Math.min((meal.total_sugar / 50) * 100, 100)}%"
></div>
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,42 @@
/**
* Environment configuration helper for Nutriphi Web
* Provides type-safe access to environment variables
*/
import { env as dynamicEnv } from '$env/dynamic/public';
export const env = {
// Middleware APIs
middleware: {
nutriphiUrl: dynamicEnv.PUBLIC_NUTRIPHI_MIDDLEWARE_URL ?? 'https://api.manacore.de',
appId: dynamicEnv.PUBLIC_MIDDLEWARE_APP_ID ?? 'nutriphi',
},
// Backend API
backend: {
url: dynamicEnv.PUBLIC_BACKEND_URL ?? 'http://localhost:3002',
},
// OAuth
oauth: {
googleClientId: dynamicEnv.PUBLIC_GOOGLE_CLIENT_ID ?? '',
appleClientId: dynamicEnv.PUBLIC_APPLE_CLIENT_ID ?? '',
appleRedirectUri: dynamicEnv.PUBLIC_APPLE_REDIRECT_URI ?? '',
},
} as const;
// Helper to check if optional features are enabled
export const features = {
hasGoogleAuth: !!env.oauth.googleClientId,
hasAppleAuth: !!env.oauth.appleClientId && !!env.oauth.appleRedirectUri,
} as const;
// Log environment configuration on startup (useful for debugging deployment issues)
if (typeof window !== 'undefined') {
console.log('Nutriphi Environment Configuration:', {
middleware: !!env.middleware.nutriphiUrl ? 'Configured' : 'Missing',
backend: !!env.backend.url ? 'Configured' : 'Missing',
googleOAuth: features.hasGoogleAuth ? 'Enabled' : 'Disabled',
appleOAuth: features.hasAppleAuth ? 'Enabled' : 'Disabled',
});
}

View file

@ -0,0 +1,116 @@
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
export interface NutritionAnalysis {
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
servingSize: string;
confidence: number;
ingredients?: string[];
healthTips?: string[];
}
export interface Meal {
id: string;
user_id: string;
food_name: string;
image_url?: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
sodium: number;
serving_size: string;
meal_type?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface DailySummary {
date: string;
totalCalories: number;
totalProtein: number;
totalCarbohydrates: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
totalSodium: number;
mealCount: number;
}
class ApiService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/image', {
method: 'POST',
body: JSON.stringify({ imageBase64 }),
});
}
async analyzeText(description: string): Promise<NutritionAnalysis> {
return this.request('/meals/analyze/text', {
method: 'POST',
body: JSON.stringify({ description }),
});
}
async getMeals(userId: string, date?: string): Promise<Meal[]> {
const params = date ? `?date=${date}` : '';
return this.request(`/meals/user/${userId}${params}`);
}
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
return this.request(`/meals/user/${userId}/summary?date=${date}`);
}
async createMeal(meal: Partial<Meal> & { userId: string }): Promise<Meal> {
return this.request('/meals', {
method: 'POST',
body: JSON.stringify(meal),
});
}
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
return this.request(`/meals/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async deleteMeal(id: string): Promise<void> {
await this.request(`/meals/${id}`, {
method: 'DELETE',
});
}
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
return this.request('/health');
}
}
export const api = new ApiService();

View file

@ -0,0 +1,411 @@
/**
* Authentication service for Nutriphi Web
* Uses Mana middleware for authentication
*/
import { env } from '$lib/config/env';
const MIDDLEWARE_URL = env.middleware.nutriphiUrl;
const APP_ID = env.middleware.appId;
// Storage keys for tokens
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
/**
* Get device information for authentication
*/
function getDeviceInfo() {
return {
deviceId: getBrowserFingerprint(),
deviceName: getBrowserName(),
deviceType: 'web',
platform: 'web',
};
}
/**
* Generate a browser fingerprint for device identification
*/
function getBrowserFingerprint(): string {
const ua = navigator.userAgent;
const screen = `${window.screen.width}x${window.screen.height}`;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const lang = navigator.language;
const data = `${ua}|${screen}|${timezone}|${lang}`;
return btoa(data).slice(0, 32);
}
/**
* Get browser name
*/
function getBrowserName(): string {
const ua = navigator.userAgent;
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Edge')) return 'Edge';
return 'Unknown Browser';
}
/**
* Decode JWT token
*/
function decodeToken(token: string) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
/**
* Check if token is expired
*/
function isTokenExpired(token: string): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
}
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
}
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication service
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
const { appToken, refreshToken } = await response.json();
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
const responseData = await response.json();
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
const { appToken, refreshToken } = responseData;
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
/**
* Refresh authentication tokens
*/
async refreshTokens(currentRefreshToken: string): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
};

View file

@ -0,0 +1,201 @@
/**
* Export Service for Nutriphi Web
* Generates CSV and PDF exports of meal data
*/
import type { Meal } from '$lib/types/meal';
import type { ExportOptions } from '$lib/types/stats';
import type { NutritionGoal, DailyProgress } from '$lib/types/goal';
/**
* Format date for display
*/
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/**
* Get meal type label in German
*/
function getMealTypeLabel(type: string): string {
const labels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};
return labels[type] || type;
}
/**
* Export meals to CSV
*/
export function exportToCSV(meals: Meal[]): Blob {
const headers = [
'Datum',
'Uhrzeit',
'Mahlzeit',
'Kalorien (kcal)',
'Protein (g)',
'Kohlenhydrate (g)',
'Fett (g)',
'Ballaststoffe (g)',
'Zucker (g)',
'Health Score',
'Notizen',
];
const rows = meals.map((meal) => {
const date = new Date(meal.timestamp);
return [
formatDate(meal.timestamp),
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
getMealTypeLabel(meal.meal_type),
meal.total_calories ? Math.round(meal.total_calories).toString() : '',
meal.total_protein ? Math.round(meal.total_protein).toString() : '',
meal.total_carbs ? Math.round(meal.total_carbs).toString() : '',
meal.total_fat ? Math.round(meal.total_fat).toString() : '',
meal.total_fiber ? Math.round(meal.total_fiber).toString() : '',
meal.total_sugar ? Math.round(meal.total_sugar).toString() : '',
meal.health_score?.toString() || '',
meal.user_notes?.replace(/"/g, '""') || '',
];
});
const csvContent = [
headers.join(';'),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(';')),
].join('\n');
// Add BOM for Excel compatibility with UTF-8
const BOM = '\uFEFF';
return new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
}
/**
* Export daily summaries to CSV
*/
export function exportSummaryToCSV(summaries: Array<{ date: string; meals: Meal[] }>): Blob {
const headers = [
'Datum',
'Mahlzeiten',
'Kalorien (kcal)',
'Protein (g)',
'Kohlenhydrate (g)',
'Fett (g)',
'Durchschn. Health Score',
];
const rows = summaries.map(({ date, meals }) => {
const totalCalories = meals.reduce((sum, m) => sum + (m.total_calories || 0), 0);
const totalProtein = meals.reduce((sum, m) => sum + (m.total_protein || 0), 0);
const totalCarbs = meals.reduce((sum, m) => sum + (m.total_carbs || 0), 0);
const totalFat = meals.reduce((sum, m) => sum + (m.total_fat || 0), 0);
const healthScores = meals
.filter((m) => m.health_score !== undefined)
.map((m) => m.health_score!);
const avgHealth =
healthScores.length > 0
? (healthScores.reduce((a, b) => a + b, 0) / healthScores.length).toFixed(1)
: '';
return [
formatDate(date),
meals.length.toString(),
Math.round(totalCalories).toString(),
Math.round(totalProtein).toString(),
Math.round(totalCarbs).toString(),
Math.round(totalFat).toString(),
avgHealth,
];
});
const csvContent = [
headers.join(';'),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(';')),
].join('\n');
const BOM = '\uFEFF';
return new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
}
/**
* Export goals to CSV
*/
export function exportGoalsToCSV(goals: NutritionGoal): Blob {
const content = `Nutriphi Ernährungsziele
Erstellt am: ${formatDate(new Date().toISOString())}
Ziel;Wert
Kalorien (kcal/Tag);${goals.calories_target}
Protein (g/Tag);${goals.protein_target}
Kohlenhydrate (g/Tag);${goals.carbs_target}
Fett (g/Tag);${goals.fat_target}
${goals.fiber_target ? `Ballaststoffe (g/Tag);${goals.fiber_target}` : ''}
${goals.sugar_limit ? `Zucker-Limit (g/Tag);${goals.sugar_limit}` : ''}
`;
const BOM = '\uFEFF';
return new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' });
}
/**
* Group meals by date
*/
export function groupMealsByDate(meals: Meal[]): Map<string, Meal[]> {
const grouped = new Map<string, Meal[]>();
meals.forEach((meal) => {
const date = meal.timestamp.split('T')[0];
const existing = grouped.get(date) || [];
grouped.set(date, [...existing, meal]);
});
return grouped;
}
/**
* Filter meals by date range
*/
export function filterMealsByDateRange(meals: Meal[], dateFrom: string, dateTo: string): Meal[] {
const from = new Date(dateFrom);
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
return meals.filter((meal) => {
const mealDate = new Date(meal.timestamp);
return mealDate >= from && mealDate <= to;
});
}
/**
* Download blob as file
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Generate export filename
*/
export function generateFilename(
type: 'meals' | 'summary' | 'goals',
format: 'csv' | 'pdf',
dateFrom?: string,
dateTo?: string
): string {
const date = new Date().toISOString().split('T')[0];
const range = dateFrom && dateTo ? `_${dateFrom}_${dateTo}` : '';
return `nutriphi-${type}${range}_${date}.${format}`;
}

View file

@ -0,0 +1,91 @@
/**
* Goals Service for Nutriphi Web
* Handles nutrition goals API calls
*/
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import type { NutritionGoal, DailyProgress, GoalProgress } from '$lib/types/goal';
const API_BASE = env.backend.url;
class GoalsService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const token = await tokenManager.getValidToken();
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized');
}
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Get user's nutrition goals
*/
async getGoals(userId: string): Promise<NutritionGoal | null> {
try {
return await this.request<NutritionGoal>(`/goals/${userId}`);
} catch {
return null;
}
}
/**
* Create or update nutrition goals
*/
async saveGoals(goals: Partial<NutritionGoal> & { user_id: string }): Promise<NutritionGoal> {
return this.request<NutritionGoal>('/goals', {
method: 'POST',
body: JSON.stringify(goals),
});
}
/**
* Get daily progress
*/
async getDailyProgress(userId: string, date: string): Promise<DailyProgress> {
return this.request<DailyProgress>(`/progress/${userId}?date=${date}`);
}
/**
* Get goal progress with percentages
*/
async getGoalProgress(userId: string, date: string): Promise<GoalProgress | null> {
try {
const [goals, progress] = await Promise.all([
this.getGoals(userId),
this.getDailyProgress(userId, date),
]);
if (!goals) return null;
const percentages = {
calories: goals.calories_target ? (progress.calories / goals.calories_target) * 100 : 0,
protein: goals.protein_target ? (progress.protein / goals.protein_target) * 100 : 0,
carbs: goals.carbs_target ? (progress.carbs / goals.carbs_target) * 100 : 0,
fat: goals.fat_target ? (progress.fat / goals.fat_target) * 100 : 0,
fiber: goals.fiber_target ? (progress.fiber / goals.fiber_target) * 100 : 0,
};
return { goal: goals, progress, percentages };
} catch {
return null;
}
}
}
export const goalsService = new GoalsService();

View file

@ -0,0 +1,154 @@
/**
* Meal Service for Nutriphi Web
* Handles all meal-related API calls
*/
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import type { Meal, MealWithItems, FoodItem, DailySummary, MealFilters } from '$lib/types/meal';
const API_BASE = env.backend.url;
class MealService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const token = await tokenManager.getValidToken();
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized');
}
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Get all meals for a user
*/
async getMeals(userId: string, filters?: MealFilters): Promise<Meal[]> {
const params = new URLSearchParams();
if (filters?.date) params.set('date', filters.date);
if (filters?.mealType) params.set('mealType', filters.mealType);
if (filters?.minHealthScore) params.set('minHealthScore', String(filters.minHealthScore));
const query = params.toString() ? `?${params.toString()}` : '';
return this.request<Meal[]>(`/meals/user/${userId}${query}`);
}
/**
* Get a single meal with its food items
*/
async getMealById(id: string): Promise<MealWithItems> {
return this.request<MealWithItems>(`/meals/${id}`);
}
/**
* Get daily nutrition summary
*/
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
return this.request<DailySummary>(`/meals/user/${userId}/summary?date=${date}`);
}
/**
* Create a new meal
*/
async createMeal(meal: Partial<Meal> & { user_id: string }): Promise<Meal> {
return this.request<Meal>('/meals', {
method: 'POST',
body: JSON.stringify(meal),
});
}
/**
* Upload a meal photo and trigger analysis
*/
async uploadMealPhoto(data: {
photoUrl: string;
storagePath: string;
userId: string;
mealType?: string;
}): Promise<{ id: string; status: string }> {
return this.request('/meals/upload', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Update a meal
*/
async updateMeal(id: string, updates: Partial<Meal>): Promise<Meal> {
return this.request<Meal>(`/meals/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
/**
* Delete a meal
*/
async deleteMeal(id: string): Promise<void> {
await this.request(`/meals/${id}`, {
method: 'DELETE',
});
}
/**
* Analyze a food image
*/
async analyzeImage(imageBase64: string): Promise<{
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
confidence: number;
ingredients?: string[];
}> {
return this.request('/meals/analyze/image', {
method: 'POST',
body: JSON.stringify({ imageBase64 }),
});
}
/**
* Analyze a food description
*/
async analyzeText(description: string): Promise<{
foodName: string;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
confidence: number;
}> {
return this.request('/meals/analyze/text', {
method: 'POST',
body: JSON.stringify({ description }),
});
}
/**
* Health check
*/
async healthCheck(): Promise<{ status: string; timestamp: string; service: string }> {
return this.request('/health');
}
}
export const mealService = new MealService();

View file

@ -0,0 +1,229 @@
/**
* Stats Service for Nutriphi Web
* Calculates and aggregates nutrition statistics
*/
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import type {
DateRange,
StatsData,
CalorieDataPoint,
MacroDistribution,
WeeklyData,
HealthTrendPoint,
} from '$lib/types/stats';
import type { Meal } from '$lib/types/meal';
const API_BASE = env.backend.url;
class StatsService {
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const token = await tokenManager.getValidToken();
const response = await fetch(`${API_BASE}/api${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Get stats from backend (if available) or calculate locally
*/
async getStats(userId: string, range: DateRange): Promise<StatsData> {
try {
// Try to get from backend first
return await this.request<StatsData>(`/stats/${userId}?range=${range}`);
} catch {
// If backend doesn't have stats endpoint, return empty data
return this.getEmptyStats();
}
}
/**
* Calculate stats from meals data locally
*/
calculateStats(meals: Meal[], range: DateRange): StatsData {
const now = new Date();
const rangeStart = this.getRangeStartDate(now, range);
// Filter meals by date range
const filteredMeals = meals.filter((meal) => new Date(meal.timestamp) >= rangeStart);
if (filteredMeals.length === 0) {
return this.getEmptyStats();
}
return {
calorieData: this.calculateCalorieData(filteredMeals, range),
macroData: this.calculateMacroDistribution(filteredMeals),
weeklyData: this.calculateWeeklyData(filteredMeals),
healthData: this.calculateHealthTrend(filteredMeals),
totals: this.calculateTotals(filteredMeals),
};
}
private getRangeStartDate(now: Date, range: DateRange): Date {
const start = new Date(now);
switch (range) {
case 'week':
start.setDate(start.getDate() - 7);
break;
case 'month':
start.setMonth(start.getMonth() - 1);
break;
case 'year':
start.setFullYear(start.getFullYear() - 1);
break;
}
return start;
}
private calculateCalorieData(meals: Meal[], range: DateRange): CalorieDataPoint[] {
const dailyCalories = new Map<string, number>();
meals.forEach((meal) => {
const date = meal.timestamp.split('T')[0];
const current = dailyCalories.get(date) || 0;
dailyCalories.set(date, current + (meal.total_calories || 0));
});
return Array.from(dailyCalories.entries())
.map(([date, calories]) => ({ date, calories }))
.sort((a, b) => a.date.localeCompare(b.date));
}
private calculateMacroDistribution(meals: Meal[]): MacroDistribution {
const totals = meals.reduce(
(acc, meal) => ({
protein: acc.protein + (meal.total_protein || 0),
carbs: acc.carbs + (meal.total_carbs || 0),
fat: acc.fat + (meal.total_fat || 0),
}),
{ protein: 0, carbs: 0, fat: 0 }
);
const total = totals.protein + totals.carbs + totals.fat;
if (total === 0) return { protein: 33, carbs: 34, fat: 33 };
return {
protein: Math.round((totals.protein / total) * 100),
carbs: Math.round((totals.carbs / total) * 100),
fat: Math.round((totals.fat / total) * 100),
};
}
private calculateWeeklyData(meals: Meal[]): WeeklyData[] {
const weeklyMeals = new Map<string, Meal[]>();
meals.forEach((meal) => {
const date = new Date(meal.timestamp);
const weekStart = this.getWeekStart(date);
const weekKey = weekStart.toISOString().split('T')[0];
const existing = weeklyMeals.get(weekKey) || [];
weeklyMeals.set(weekKey, [...existing, meal]);
});
return Array.from(weeklyMeals.entries())
.map(([week, weekMeals]) => {
const days = new Set(weekMeals.map((m) => m.timestamp.split('T')[0])).size;
return {
week,
avgCalories: Math.round(
weekMeals.reduce((sum, m) => sum + (m.total_calories || 0), 0) / days
),
avgProtein: Math.round(
weekMeals.reduce((sum, m) => sum + (m.total_protein || 0), 0) / days
),
avgCarbs: Math.round(weekMeals.reduce((sum, m) => sum + (m.total_carbs || 0), 0) / days),
avgFat: Math.round(weekMeals.reduce((sum, m) => sum + (m.total_fat || 0), 0) / days),
mealCount: weekMeals.length,
};
})
.sort((a, b) => a.week.localeCompare(b.week));
}
private getWeekStart(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
return new Date(d.setDate(diff));
}
private calculateHealthTrend(meals: Meal[]): HealthTrendPoint[] {
const dailyHealth = new Map<string, { scores: number[]; count: number }>();
meals.forEach((meal) => {
if (meal.health_score === undefined) return;
const date = meal.timestamp.split('T')[0];
const existing = dailyHealth.get(date) || { scores: [], count: 0 };
dailyHealth.set(date, {
scores: [...existing.scores, meal.health_score],
count: existing.count + 1,
});
});
return Array.from(dailyHealth.entries())
.map(([date, data]) => ({
date,
avgHealthScore:
Math.round((data.scores.reduce((a, b) => a + b, 0) / data.scores.length) * 10) / 10,
mealCount: data.count,
}))
.sort((a, b) => a.date.localeCompare(b.date));
}
private calculateTotals(meals: Meal[]): StatsData['totals'] {
const days = new Set(meals.map((m) => m.timestamp.split('T')[0])).size || 1;
const totalCalories = meals.reduce((sum, m) => sum + (m.total_calories || 0), 0);
const totalProtein = meals.reduce((sum, m) => sum + (m.total_protein || 0), 0);
const totalCarbs = meals.reduce((sum, m) => sum + (m.total_carbs || 0), 0);
const totalFat = meals.reduce((sum, m) => sum + (m.total_fat || 0), 0);
const healthScores = meals
.filter((m) => m.health_score !== undefined)
.map((m) => m.health_score!);
return {
avgCalories: Math.round(totalCalories / days),
avgProtein: Math.round(totalProtein / days),
avgCarbs: Math.round(totalCarbs / days),
avgFat: Math.round(totalFat / days),
totalMeals: meals.length,
avgHealthScore:
healthScores.length > 0
? Math.round((healthScores.reduce((a, b) => a + b, 0) / healthScores.length) * 10) / 10
: 0,
};
}
private getEmptyStats(): StatsData {
return {
calorieData: [],
macroData: { protein: 33, carbs: 34, fat: 33 },
weeklyData: [],
healthData: [],
totals: {
avgCalories: 0,
avgProtein: 0,
avgCarbs: 0,
avgFat: 0,
totalMeals: 0,
avgHealthScore: 0,
},
};
}
}
export const statsService = new StatsService();

View file

@ -0,0 +1,342 @@
/**
* Token Manager for Nutriphi Web
* Handles JWT token lifecycle, refresh logic, and request queueing
*/
import { authService, type UserData } from './authService';
import { browser } from '$app/environment';
export enum TokenState {
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
VALID = 'valid',
}
interface QueuedRequest {
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
}
interface TokenRefreshResult {
success: boolean;
token?: string;
error?: string;
}
type TokenStateObserver = (state: TokenState, token?: string) => void;
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
class TokenManager {
private state: TokenState = TokenState.IDLE;
private refreshPromise: Promise<TokenRefreshResult> | null = null;
private requestQueue: QueuedRequest[] = [];
private observers: Set<TokenStateObserver> = new Set();
private readonly MAX_QUEUE_SIZE = 50;
private readonly QUEUE_TIMEOUT_MS = 30000;
private readonly MAX_REFRESH_ATTEMPTS = 3;
private refreshAttempts = 0;
private lastRefreshTime = 0;
private readonly REFRESH_COOLDOWN_MS = 5000;
private static instance: TokenManager;
private constructor() {
if (browser) {
this.checkInitialState();
}
}
static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
subscribe(observer: TokenStateObserver): () => void {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
private notifyObservers(state: TokenState, token?: string): void {
this.observers.forEach((observer) => {
try {
observer(state, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
private setState(newState: TokenState, token?: string): void {
if (this.state !== newState) {
this.state = newState;
this.notifyObservers(newState, token);
}
}
getState(): TokenState {
return this.state;
}
private async checkInitialState(): Promise<void> {
if (!browser) return;
try {
const token = this.getStoredToken();
if (!token) {
this.setState(TokenState.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
this.setState(TokenState.VALID, token);
} else {
this.setState(TokenState.EXPIRED);
}
} catch {
this.setState(TokenState.EXPIRED);
}
}
private getStoredToken(): string | null {
if (!browser) return null;
return localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
}
private getStoredRefreshToken(): string | null {
if (!browser) return null;
return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
}
private storeTokens(appToken: string, refreshToken: string, email?: string): void {
if (!browser) return;
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
if (email) {
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, email);
}
}
private clearStoredTokens(): void {
if (!browser) return;
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER_EMAIL);
}
async getValidToken(): Promise<string | null> {
const currentToken = this.getStoredToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
this.setState(TokenState.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
this.setState(TokenState.EXPIRED);
return null;
}
const refreshResult = await this.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
return null;
}
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (this.state === TokenState.REFRESHING && this.refreshPromise) {
return this.queueRequest(input, init);
}
const refreshResult = await this.refreshToken();
if (refreshResult.success && refreshResult.token) {
return this.retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
}
private async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
if (this.requestQueue.length >= this.MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
this.requestQueue.push(queueItem);
setTimeout(() => {
this.removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, this.QUEUE_TIMEOUT_MS);
});
}
private removeFromQueue(requestId: string): void {
const index = this.requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
this.requestQueue.splice(index, 1);
}
}
private async refreshToken(): Promise<TokenRefreshResult> {
const now = Date.now();
if (now - this.lastRefreshTime < this.REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (this.refreshAttempts >= this.MAX_REFRESH_ATTEMPTS) {
await this.handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (this.refreshPromise) {
return await this.refreshPromise;
}
this.setState(TokenState.REFRESHING);
this.lastRefreshTime = now;
this.refreshPromise = this.performTokenRefresh();
try {
const result = await this.refreshPromise;
if (result.success) {
this.refreshAttempts = 0;
this.setState(TokenState.VALID, result.token);
await this.processQueuedRequests(result.token!);
} else {
this.refreshAttempts++;
this.setState(TokenState.EXPIRED);
await this.rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
this.refreshPromise = null;
}
}
private async performTokenRefresh(): Promise<TokenRefreshResult> {
try {
const refreshToken = this.getStoredRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken, refreshToken: newRefreshToken, userData } = refreshResult;
if (!appToken || !newRefreshToken) {
throw new Error('Invalid tokens received from refresh');
}
this.storeTokens(appToken, newRefreshToken, userData?.email);
return { success: true, token: appToken };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
private async handleRefreshFailure(): Promise<void> {
try {
this.clearStoredTokens();
this.setState(TokenState.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
private async processQueuedRequests(token: string): Promise<void> {
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
try {
const response = await this.retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
private async rejectQueuedRequests(error: string): Promise<void> {
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
private async retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
reset(): void {
this.state = TokenState.IDLE;
this.refreshPromise = null;
this.refreshAttempts = 0;
this.lastRefreshTime = 0;
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
}
async clearTokens(): Promise<void> {
try {
this.clearStoredTokens();
this.reset();
} catch {
this.reset();
}
}
}
export const tokenManager = TokenManager.getInstance();
export default tokenManager;

View file

@ -0,0 +1,204 @@
/**
* Upload Service for Nutriphi Web
* Handles meal photo uploads via backend (Hetzner Object Storage)
*/
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import type { MealType } from '$lib/types/meal';
const API_BASE = env.backend.url;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/heic', 'image/webp'];
interface UploadResult {
success: boolean;
mealId?: string;
photoUrl?: string;
error?: string;
}
interface UploadProgress {
status: 'uploading' | 'analyzing' | 'complete' | 'error';
progress: number;
message?: string;
}
type ProgressCallback = (progress: UploadProgress) => void;
/**
* Validate file before upload
*/
function validateFile(file: File): { valid: boolean; error?: string } {
if (!ALLOWED_TYPES.includes(file.type)) {
return { valid: false, error: 'Ungültiges Dateiformat. Erlaubt: JPG, PNG, HEIC, WebP' };
}
if (file.size > MAX_FILE_SIZE) {
return { valid: false, error: 'Datei zu groß. Maximal 10MB erlaubt.' };
}
return { valid: true };
}
/**
* Convert file to base64 for upload
*/
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result); // Keep the data URL format
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Upload a meal photo and create a meal record
* The backend handles storage to Hetzner Object Storage
*/
export async function uploadMealPhoto(
file: File,
userId: string,
mealType: MealType = 'lunch',
onProgress?: ProgressCallback
): Promise<UploadResult> {
// Validate file
const validation = validateFile(file);
if (!validation.valid) {
return { success: false, error: validation.error };
}
try {
// Step 1: Convert to base64
onProgress?.({ status: 'uploading', progress: 10, message: 'Bild wird vorbereitet...' });
const base64Data = await fileToBase64(file);
onProgress?.({ status: 'uploading', progress: 30, message: 'Wird hochgeladen...' });
// Step 2: Get auth token
const token = await tokenManager.getValidToken();
// Step 3: Send to backend for upload and analysis
onProgress?.({ status: 'analyzing', progress: 50, message: 'Wird analysiert...' });
const response = await fetch(`${API_BASE}/api/meals/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
imageBase64: base64Data,
userId,
mealType,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `Upload failed: ${response.status}`);
}
onProgress?.({ status: 'analyzing', progress: 80, message: 'KI analysiert...' });
const result = await response.json();
// Step 4: Complete
onProgress?.({ status: 'complete', progress: 100, message: 'Fertig!' });
return {
success: true,
mealId: result.id,
photoUrl: result.imageUrl,
};
} catch (error) {
console.error('Upload error:', error);
onProgress?.({
status: 'error',
progress: 0,
message: error instanceof Error ? error.message : 'Upload fehlgeschlagen',
});
return {
success: false,
error: error instanceof Error ? error.message : 'Upload fehlgeschlagen',
};
}
}
/**
* Delete a meal photo from storage (via backend)
*/
export async function deleteMealPhoto(mealId: string): Promise<boolean> {
try {
const token = await tokenManager.getValidToken();
const response = await fetch(`${API_BASE}/api/meals/${mealId}`, {
method: 'DELETE',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
return response.ok;
} catch (error) {
console.error('Delete error:', error);
return false;
}
}
/**
* Resize image before upload (optional, for performance)
*/
export async function resizeImage(
file: File,
maxWidth: number = 1920,
maxHeight: number = 1920,
quality: number = 0.85
): Promise<File> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
img.onload = () => {
let { width, height } = img;
// Calculate new dimensions
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width *= ratio;
height *= ratio;
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
const resizedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
} else {
reject(new Error('Failed to resize image'));
}
},
'image/jpeg',
quality
);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}

View file

@ -0,0 +1,249 @@
/**
* Auth Store for Nutriphi Web
* Manages authentication state using Mana middleware pattern
*/
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { authService, type UserData } from '$lib/services/authService';
import { tokenManager } from '$lib/services/tokenManager';
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
interface AuthState {
user: UserData | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
});
async function initialize() {
if (!browser) return;
try {
const token = localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
return;
}
if (authService.isTokenValidLocally(token)) {
const userData = authService.getUserFromToken(token);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return;
}
}
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
if (refreshToken) {
try {
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return;
}
}
} catch (error) {
console.error('Failed to refresh token on init:', error);
}
}
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
} catch (error) {
console.error('Error initializing auth:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: 'Failed to initialize authentication',
});
}
}
async function signIn(
email: string,
password: string
): Promise<{ success: boolean; error?: string }> {
update((state) => ({ ...state, isLoading: true, error: null }));
try {
const result = await authService.signIn(email, password);
if (!result.success) {
update((state) => ({
...state,
isLoading: false,
error: result.error || 'Sign in failed',
}));
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
if (result.email) {
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
return { success: false, error: errorMessage };
}
}
async function signUp(
email: string,
password: string
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> {
update((state) => ({ ...state, isLoading: true, error: null }));
try {
const result = await authService.signUp(email, password);
if (!result.success) {
update((state) => ({
...state,
isLoading: false,
error: result.error || 'Sign up failed',
}));
return { success: false, error: result.error };
}
if (result.needsVerification) {
update((state) => ({ ...state, isLoading: false, error: null }));
return { success: true, needsVerification: true };
}
if (result.appToken && result.refreshToken) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
if (result.email) {
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
return { success: false, error: errorMessage };
}
}
async function signInWithGoogle(idToken: string): Promise<{ success: boolean; error?: string }> {
update((state) => ({ ...state, isLoading: true, error: null }));
try {
const result = await authService.signInWithGoogle(idToken);
if (!result.success) {
update((state) => ({
...state,
isLoading: false,
error: result.error || 'Google Sign-In failed',
}));
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, result.appToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, result.refreshToken);
if (result.email) {
localStorage.setItem(STORAGE_KEYS.USER_EMAIL, result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
update((state) => ({ ...state, isLoading: false, error: errorMessage }));
return { success: false, error: errorMessage };
}
}
async function signOut(): Promise<void> {
update((state) => ({ ...state, isLoading: true }));
try {
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
if (refreshToken) {
await authService.signOut(refreshToken);
}
} catch (error) {
console.error('Error during sign out:', error);
} finally {
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER_EMAIL);
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
}
}
async function forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
return authService.forgotPassword(email);
}
if (browser) {
initialize();
}
return {
subscribe,
signIn,
signUp,
signInWithGoogle,
signOut,
forgotPassword,
initialize,
};
}
export const auth = createAuthStore();
export const user = derived(auth, ($auth) => $auth.user);
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
export const isLoading = derived(auth, ($auth) => $auth.isLoading);

View file

@ -0,0 +1,143 @@
/**
* Goals Store for Nutriphi Web
* Manages nutrition goals state using Svelte 5 Runes
*/
import { goalsService } from '$lib/services/goalsService';
import type { NutritionGoal, DailyProgress, GoalProgress } from '$lib/types/goal';
// State
let goals = $state<NutritionGoal | null>(null);
let todayProgress = $state<DailyProgress | null>(null);
let goalProgress = $state<GoalProgress | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
// Derived
const hasGoals = $derived(goals !== null);
const caloriePercentage = $derived(
goals && todayProgress
? Math.min(Math.round((todayProgress.calories / goals.calories_target) * 100), 100)
: 0
);
const proteinPercentage = $derived(
goals && todayProgress
? Math.min(Math.round((todayProgress.protein / goals.protein_target) * 100), 100)
: 0
);
const carbsPercentage = $derived(
goals && todayProgress
? Math.min(Math.round((todayProgress.carbs / goals.carbs_target) * 100), 100)
: 0
);
const fatPercentage = $derived(
goals && todayProgress
? Math.min(Math.round((todayProgress.fat / goals.fat_target) * 100), 100)
: 0
);
export const goalsStore = {
// Getters
get goals() {
return goals;
},
get todayProgress() {
return todayProgress;
},
get goalProgress() {
return goalProgress;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get hasGoals() {
return hasGoals;
},
get caloriePercentage() {
return caloriePercentage;
},
get proteinPercentage() {
return proteinPercentage;
},
get carbsPercentage() {
return carbsPercentage;
},
get fatPercentage() {
return fatPercentage;
},
// Actions
async loadGoals(userId: string) {
isLoading = true;
error = null;
try {
goals = await goalsService.getGoals(userId);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load goals';
console.error('Failed to load goals:', err);
} finally {
isLoading = false;
}
},
async loadDailyProgress(userId: string, date?: string) {
const targetDate = date || new Date().toISOString().split('T')[0];
try {
todayProgress = await goalsService.getDailyProgress(userId, targetDate);
} catch (err) {
console.error('Failed to load daily progress:', err);
todayProgress = null;
}
},
async loadGoalProgress(userId: string, date?: string) {
const targetDate = date || new Date().toISOString().split('T')[0];
try {
goalProgress = await goalsService.getGoalProgress(userId, targetDate);
} catch (err) {
console.error('Failed to load goal progress:', err);
goalProgress = null;
}
},
async saveGoals(userId: string, goalData: Partial<NutritionGoal>) {
isLoading = true;
error = null;
try {
goals = await goalsService.saveGoals({
user_id: userId,
...goalData,
} as NutritionGoal);
return goals;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save goals';
console.error('Failed to save goals:', err);
throw err;
} finally {
isLoading = false;
}
},
clearError() {
error = null;
},
reset() {
goals = null;
todayProgress = null;
goalProgress = null;
isLoading = false;
error = null;
},
};

View file

@ -0,0 +1,169 @@
/**
* Meals Store for Nutriphi Web
* Manages meal state using Svelte 5 Runes
*/
import { mealService } from '$lib/services/mealService';
import type { Meal, MealWithItems, DailySummary, MealFilters } from '$lib/types/meal';
// State
let meals = $state<Meal[]>([]);
let selectedMeal = $state<MealWithItems | null>(null);
let dailySummary = $state<DailySummary | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
// Derived
const sortedMeals = $derived(
[...meals].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
);
const todaysMeals = $derived(
meals.filter((meal) => {
const today = new Date().toISOString().split('T')[0];
return meal.timestamp.startsWith(today);
})
);
const totalCaloriesToday = $derived(
todaysMeals.reduce((sum, meal) => sum + (meal.total_calories || 0), 0)
);
export const mealsStore = {
// Getters
get meals() {
return meals;
},
get sortedMeals() {
return sortedMeals;
},
get todaysMeals() {
return todaysMeals;
},
get selectedMeal() {
return selectedMeal;
},
get dailySummary() {
return dailySummary;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
get totalCaloriesToday() {
return totalCaloriesToday;
},
// Actions
async loadMeals(userId: string, filters?: MealFilters) {
isLoading = true;
error = null;
try {
meals = await mealService.getMeals(userId, filters);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load meals';
console.error('Failed to load meals:', err);
} finally {
isLoading = false;
}
},
async loadMealById(id: string) {
isLoading = true;
error = null;
try {
selectedMeal = await mealService.getMealById(id);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load meal';
console.error('Failed to load meal:', err);
selectedMeal = null;
} finally {
isLoading = false;
}
},
async loadDailySummary(userId: string, date: string) {
try {
dailySummary = await mealService.getDailySummary(userId, date);
} catch (err) {
console.error('Failed to load daily summary:', err);
dailySummary = null;
}
},
async createMeal(meal: Partial<Meal> & { user_id: string }) {
isLoading = true;
error = null;
try {
const newMeal = await mealService.createMeal(meal);
meals = [newMeal, ...meals];
return newMeal;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create meal';
console.error('Failed to create meal:', err);
throw err;
} finally {
isLoading = false;
}
},
async updateMeal(id: string, updates: Partial<Meal>) {
isLoading = true;
error = null;
try {
const updatedMeal = await mealService.updateMeal(id, updates);
meals = meals.map((m) => (m.id === id ? updatedMeal : m));
if (selectedMeal?.id === id) {
selectedMeal = { ...selectedMeal, ...updatedMeal };
}
return updatedMeal;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update meal';
console.error('Failed to update meal:', err);
throw err;
} finally {
isLoading = false;
}
},
async deleteMeal(id: string) {
isLoading = true;
error = null;
try {
await mealService.deleteMeal(id);
meals = meals.filter((m) => m.id !== id);
if (selectedMeal?.id === id) {
selectedMeal = null;
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete meal';
console.error('Failed to delete meal:', err);
throw err;
} finally {
isLoading = false;
}
},
clearSelectedMeal() {
selectedMeal = null;
},
clearError() {
error = null;
},
reset() {
meals = [];
selectedMeal = null;
dailySummary = null;
isLoading = false;
error = null;
},
};

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,24 @@
/**
* Nutriphi Theme Store
*
* Uses the shared theme system with Nutriphi's green primary color.
*/
import { createThemeStore } from '@manacore/shared-theme';
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
/**
* Nutriphi theme store instance
*
* - Default variant: nature (green)
* - Custom primary: Green (#22c55e)
* - All 4 theme variants available
*/
export const theme = createThemeStore({
appId: 'nutriphi',
defaultVariant: 'nature',
primaryColor: {
light: '142 71% 45%', // Green #22c55e
dark: '142 71% 45%',
},
});

View file

@ -0,0 +1,39 @@
/**
* Nutrition Goal Types for Nutriphi Web
*/
export interface NutritionGoal {
id: string;
user_id: string;
calories_target: number;
protein_target: number;
carbs_target: number;
fat_target: number;
fiber_target?: number;
sugar_limit?: number;
created_at: string;
updated_at: string;
}
export interface DailyProgress {
date: string;
calories: number;
protein: number;
carbs: number;
fat: number;
fiber: number;
sugar: number;
mealsCount: number;
}
export interface GoalProgress {
goal: NutritionGoal;
progress: DailyProgress;
percentages: {
calories: number;
protein: number;
carbs: number;
fat: number;
fiber: number;
};
}

View file

@ -0,0 +1,77 @@
/**
* Meal Types for Nutriphi Web
* Based on mobile app data model
*/
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export type AnalysisStatus = 'pending' | 'completed' | 'failed' | 'manual';
export type HealthCategory = 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
export type FoodCategory =
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
export interface Meal {
id: string;
user_id: string;
photo_path: string;
photo_url?: string;
timestamp: string;
meal_type: MealType;
location?: string;
analysis_status: AnalysisStatus;
total_calories?: number;
total_protein?: number;
total_carbs?: number;
total_fat?: number;
total_fiber?: number;
total_sugar?: number;
health_score?: number;
health_category?: HealthCategory;
user_notes?: string;
user_rating?: number;
created_at: string;
updated_at: string;
}
export interface FoodItem {
id: string;
meal_id: string;
name: string;
category: FoodCategory;
portion_size: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
}
export interface MealWithItems extends Meal {
food_items: FoodItem[];
}
export interface DailySummary {
date: string;
totalCalories: number;
totalProtein: number;
totalCarbs: number;
totalFat: number;
totalFiber: number;
totalSugar: number;
mealCount: number;
avgHealthScore: number;
}
export interface MealFilters {
date?: string;
mealType?: MealType;
minHealthScore?: number;
}

View file

@ -0,0 +1,56 @@
/**
* Statistics Types for Nutriphi Web
*/
export type DateRange = 'week' | 'month' | 'year';
export interface CalorieDataPoint {
date: string;
calories: number;
target?: number;
}
export interface MacroDistribution {
protein: number;
carbs: number;
fat: number;
}
export interface WeeklyData {
week: string;
avgCalories: number;
avgProtein: number;
avgCarbs: number;
avgFat: number;
mealCount: number;
}
export interface HealthTrendPoint {
date: string;
avgHealthScore: number;
mealCount: number;
}
export interface StatsData {
calorieData: CalorieDataPoint[];
macroData: MacroDistribution;
weeklyData: WeeklyData[];
healthData: HealthTrendPoint[];
totals: {
avgCalories: number;
avgProtein: number;
avgCarbs: number;
avgFat: number;
totalMeals: number;
avgHealthScore: number;
};
}
export interface ExportOptions {
format: 'csv' | 'pdf';
dateFrom: string;
dateTo: string;
includeMeals: boolean;
includeStats: boolean;
includeGoals: boolean;
}

View file

@ -0,0 +1,160 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth, isAuthenticated } from '$lib/stores/auth';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
// Navigation shortcuts (Ctrl+1-7)
const navRoutes = [
'/meals', // Ctrl+1
'/upload', // Ctrl+2
'/stats', // Ctrl+3
'/goals', // Ctrl+4
'/export', // Ctrl+5
'/subscription', // Ctrl+6
'/settings', // Ctrl+7
];
// Navigation items for Nutriphi
const navItems: PillNavItem[] = [
{ href: '/meals', label: 'Mahlzeiten', icon: 'archive' },
{ href: '/upload', label: 'Upload', icon: 'upload' },
{ href: '/stats', label: 'Statistik', icon: 'chart' },
{ href: '/goals', label: 'Ziele', icon: 'target' },
{ href: '/export', label: 'Export', icon: 'download' },
{ href: '/subscription', label: 'Mana', icon: 'mana' },
{ href: '/settings', label: 'Settings', icon: 'settings' },
];
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= 7) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let effectiveMode = $derived(theme.effectiveMode);
const isFullHeightPage = $derived(
$page.url.pathname === '/meals' || $page.url.pathname === '/upload'
);
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('nutriphi-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('nutriphi-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
onMount(() => {
if (!$isAuthenticated) {
goto(`/login?redirectTo=${$page.url.pathname}`);
} else {
const savedSidebar = localStorage.getItem('nutriphi-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
const savedCollapsed = localStorage.getItem('nutriphi-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
}
});
async function handleLogout() {
await auth.signOut();
goto('/login');
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if loading}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div
class="spinner-border mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-r-transparent border-green-500"
></div>
<p class="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
{:else}
<div class="flex min-h-screen flex-col">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Nutriphi"
homeRoute="/meals"
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
isDark={effectiveMode === 'dark'}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
primaryColor="#22c55e"
>
{#snippet logo()}
<span class="text-xl">🥗</span>
<span class="pill-label font-bold">Nutriphi</span>
{/snippet}
</PillNavigation>
<main
class="main-content flex-1 transition-all duration-300 {isCollapsed
? ''
: isSidebarMode
? 'pl-[180px]'
: 'pt-20'} {isFullHeightPage ? 'overflow-hidden' : 'overflow-auto'}"
>
{#if isFullHeightPage}
{@render children?.()}
{:else}
<div class="container mx-auto px-4 py-8">
{@render children?.()}
</div>
{/if}
</main>
</div>
{/if}

View file

@ -0,0 +1,177 @@
<script lang="ts">
let dateFrom = $state('');
let dateTo = $state('');
let format = $state<'csv' | 'pdf'>('csv');
let includeMeals = $state(true);
let includeStats = $state(true);
let includeGoals = $state(false);
let isExporting = $state(false);
// Set default date range (last 30 days)
$effect(() => {
if (!dateFrom && !dateTo) {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
dateTo = today.toISOString().split('T')[0];
dateFrom = thirtyDaysAgo.toISOString().split('T')[0];
}
});
async function handleExport() {
if (!dateFrom || !dateTo) return;
isExporting = true;
try {
// TODO: Implement actual export
await new Promise((resolve) => setTimeout(resolve, 1500));
// Create download link
const blob = new Blob(['Export data'], {
type: format === 'csv' ? 'text/csv' : 'application/pdf',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nutriphi-export-${dateFrom}-${dateTo}.${format}`;
a.click();
URL.revokeObjectURL(url);
} catch {
// Handle error
} finally {
isExporting = false;
}
}
</script>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Daten exportieren</h1>
<p class="text-gray-600 dark:text-gray-400">
Exportiere deine Ernährungsdaten als CSV oder PDF
</p>
</div>
<!-- Export Form -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<!-- Date Range -->
<div class="mb-6">
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Zeitraum</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Von
</label>
<input
type="date"
bind:value={dateFrom}
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bis
</label>
<input
type="date"
bind:value={dateTo}
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
</div>
<!-- Format Selection -->
<div class="mb-6">
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Format</h2>
<div class="grid grid-cols-2 gap-4">
<button
onclick={() => (format = 'csv')}
class="rounded-xl border-2 p-4 text-center transition-colors {format === 'csv'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
>
<div class="mb-2 text-3xl">📊</div>
<p class="font-semibold text-gray-900 dark:text-white">CSV</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Für Excel & Co.</p>
</button>
<button
onclick={() => (format = 'pdf')}
class="rounded-xl border-2 p-4 text-center transition-colors {format === 'pdf'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
>
<div class="mb-2 text-3xl">📄</div>
<p class="font-semibold text-gray-900 dark:text-white">PDF</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Mit Grafiken</p>
</button>
</div>
</div>
<!-- Content Selection -->
<div class="mb-6">
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Inhalt</h2>
<div class="space-y-3">
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={includeMeals}
class="h-5 w-5 rounded border-gray-300 text-green-500 focus:ring-green-500"
/>
<div>
<p class="font-medium text-gray-900 dark:text-white">Mahlzeiten</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Alle erfassten Mahlzeiten mit Nährwerten
</p>
</div>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={includeStats}
class="h-5 w-5 rounded border-gray-300 text-green-500 focus:ring-green-500"
/>
<div>
<p class="font-medium text-gray-900 dark:text-white">Statistiken</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Tägliche Zusammenfassungen und Trends
</p>
</div>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={includeGoals}
class="h-5 w-5 rounded border-gray-300 text-green-500 focus:ring-green-500"
/>
<div>
<p class="font-medium text-gray-900 dark:text-white">Ziele & Fortschritt</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Deine Ernährungsziele und deren Erreichung
</p>
</div>
</label>
</div>
</div>
<!-- Export Button -->
<button
onclick={handleExport}
disabled={isExporting || !dateFrom || !dateTo}
class="w-full rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isExporting ? 'Wird exportiert...' : `Als ${format.toUpperCase()} exportieren`}
</button>
</div>
<!-- Info -->
<div class="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400">
<strong>Hinweis:</strong> Der Export enthält alle Daten im gewählten Zeitraum. CSV-Dateien können
in Excel, Google Sheets oder ähnlichen Programmen geöffnet werden.
</p>
</div>
</div>

View file

@ -0,0 +1,225 @@
<script lang="ts">
import type { NutritionGoal, DailyProgress } from '$lib/types/goal';
let goals = $state<NutritionGoal | null>(null);
let todayProgress = $state<DailyProgress | null>(null);
let isEditing = $state(false);
let isLoading = $state(false);
// Form state
let caloriesTarget = $state(2000);
let proteinTarget = $state(50);
let carbsTarget = $state(250);
let fatTarget = $state(65);
function getProgressPercent(current: number, target: number): number {
if (!target) return 0;
return Math.min(Math.round((current / target) * 100), 100);
}
function getProgressColor(percent: number): string {
if (percent >= 100) return 'bg-green-500';
if (percent >= 75) return 'bg-yellow-500';
return 'bg-blue-500';
}
async function saveGoals() {
isLoading = true;
// TODO: Save goals to API
await new Promise((resolve) => setTimeout(resolve, 500));
isEditing = false;
isLoading = false;
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Ernährungsziele</h1>
<p class="text-gray-600 dark:text-gray-400">Setze und verfolge deine täglichen Ziele</p>
</div>
{#if !isEditing}
<button
onclick={() => (isEditing = true)}
class="rounded-xl bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-600"
>
Ziele bearbeiten
</button>
{/if}
</div>
<!-- Today's Progress -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Heutiger Fortschritt</h2>
<div class="space-y-4">
<!-- Calories -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Kalorien</span>
<span class="font-medium text-gray-900 dark:text-white">
{todayProgress?.calories ?? 0} / {goals?.calories_target ?? caloriesTarget} kcal
</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {getProgressColor(
getProgressPercent(
todayProgress?.calories ?? 0,
goals?.calories_target ?? caloriesTarget
)
)} transition-all"
style="width: {getProgressPercent(
todayProgress?.calories ?? 0,
goals?.calories_target ?? caloriesTarget
)}%"
></div>
</div>
</div>
<!-- Protein -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Protein</span>
<span class="font-medium text-gray-900 dark:text-white">
{todayProgress?.protein ?? 0} / {goals?.protein_target ?? proteinTarget} g
</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-blue-500 transition-all"
style="width: {getProgressPercent(
todayProgress?.protein ?? 0,
goals?.protein_target ?? proteinTarget
)}%"
></div>
</div>
</div>
<!-- Carbs -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Kohlenhydrate</span>
<span class="font-medium text-gray-900 dark:text-white">
{todayProgress?.carbs ?? 0} / {goals?.carbs_target ?? carbsTarget} g
</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-green-500 transition-all"
style="width: {getProgressPercent(
todayProgress?.carbs ?? 0,
goals?.carbs_target ?? carbsTarget
)}%"
></div>
</div>
</div>
<!-- Fat -->
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Fett</span>
<span class="font-medium text-gray-900 dark:text-white">
{todayProgress?.fat ?? 0} / {goals?.fat_target ?? fatTarget} g
</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-orange-500 transition-all"
style="width: {getProgressPercent(
todayProgress?.fat ?? 0,
goals?.fat_target ?? fatTarget
)}%"
></div>
</div>
</div>
</div>
</div>
<!-- Edit Goals Form -->
{#if isEditing}
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Ziele festlegen</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Kalorien (kcal/Tag)
</label>
<input
type="number"
bind:value={caloriesTarget}
min="500"
max="10000"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Protein (g/Tag)
</label>
<input
type="number"
bind:value={proteinTarget}
min="10"
max="500"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Kohlenhydrate (g/Tag)
</label>
<input
type="number"
bind:value={carbsTarget}
min="10"
max="1000"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Fett (g/Tag)
</label>
<input
type="number"
bind:value={fatTarget}
min="10"
max="500"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div class="mt-6 flex gap-4">
<button
onclick={() => (isEditing = false)}
class="flex-1 rounded-xl border-2 border-gray-300 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={saveGoals}
disabled={isLoading}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? 'Wird gespeichert...' : 'Speichern'}
</button>
</div>
</div>
{/if}
<!-- Goal Tips -->
<div class="rounded-2xl bg-gradient-to-r from-green-500 to-emerald-600 p-6 text-white">
<h2 class="mb-2 text-lg font-semibold">Tipp</h2>
<p>
Die empfohlene tägliche Kalorienzufuhr liegt bei etwa 2000 kcal für Frauen und 2500 kcal für
Männer. Passe deine Ziele an deine persönlichen Bedürfnisse und Aktivitätslevel an.
</p>
</div>
</div>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import { mealsStore } from '$lib/stores/meals.svelte';
import MealGrid from '$lib/components/meals/MealGrid.svelte';
import type { Meal } from '$lib/types/meal';
import { onMount } from 'svelte';
let meals = $derived(mealsStore.sortedMeals);
let isLoading = $derived(mealsStore.isLoading);
onMount(() => {
if ($user?.id) {
mealsStore.loadMeals($user.id);
}
});
function handleMealClick(meal: Meal) {
goto(`/meals/${meal.id}`);
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div class="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Mahlzeiten</h1>
<p class="text-gray-600 dark:text-gray-400">
{meals.length}
{meals.length === 1 ? 'Mahlzeit' : 'Mahlzeiten'} erfasst
</p>
</div>
<button
onclick={() => goto('/upload')}
class="flex items-center gap-2 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-4 py-2 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700"
>
<span class="text-lg">+</span>
Neue Mahlzeit
</button>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto p-6">
{#if isLoading}
<div class="flex h-64 items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-green-500 border-t-transparent"
></div>
</div>
{:else if meals.length === 0}
<div class="flex h-64 flex-col items-center justify-center text-center">
<div class="mb-4 text-6xl">🥗</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
<p class="mb-4 text-gray-600 dark:text-gray-400">
Erfasse deine erste Mahlzeit mit einem Foto
</p>
<button
onclick={() => goto('/upload')}
class="rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700"
>
Foto hochladen
</button>
</div>
{:else}
<MealGrid {meals} onMealClick={handleMealClick} />
{/if}
</div>
</div>

View file

@ -0,0 +1,289 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { mealsStore } from '$lib/stores/meals.svelte';
import NutritionBar from '$lib/components/meals/NutritionBar.svelte';
import FoodItemList from '$lib/components/meals/FoodItemList.svelte';
import MealEditModal from '$lib/components/meals/MealEditModal.svelte';
import { onMount } from 'svelte';
const mealId = $derived($page.params.id);
let meal = $derived(mealsStore.selectedMeal);
let isLoading = $derived(mealsStore.isLoading);
let showEditModal = $state(false);
let pollingInterval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
if (mealId) {
mealsStore.loadMealById(mealId);
}
// Start polling if meal is pending analysis
startPollingIfNeeded();
return () => {
mealsStore.clearSelectedMeal();
if (pollingInterval) clearInterval(pollingInterval);
};
});
// Watch for meal changes to start/stop polling
$effect(() => {
if (meal?.analysis_status === 'pending') {
startPollingIfNeeded();
} else if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
});
function startPollingIfNeeded() {
if (meal?.analysis_status === 'pending' && !pollingInterval) {
pollingInterval = setInterval(() => {
if (mealId) {
mealsStore.loadMealById(mealId);
}
}, 3000); // Poll every 3 seconds
}
}
function getMealTypeLabel(type: string): string {
const labels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};
return labels[type] || type;
}
function getHealthColor(score?: number): string {
if (!score) return 'bg-gray-400';
if (score >= 8) return 'bg-green-500';
if (score >= 6) return 'bg-yellow-500';
if (score >= 4) return 'bg-orange-500';
return 'bg-red-500';
}
async function handleDelete() {
if (!meal || !confirm('Mahlzeit wirklich löschen?')) return;
await mealsStore.deleteMeal(meal.id);
goto('/meals');
}
function getNutritionText(): string {
if (!meal) return '';
const parts = [];
if (meal.total_calories) parts.push(`${Math.round(meal.total_calories)} kcal`);
if (meal.total_protein) parts.push(`${Math.round(meal.total_protein)}g Protein`);
if (meal.total_carbs) parts.push(`${Math.round(meal.total_carbs)}g Kohlenhydrate`);
if (meal.total_fat) parts.push(`${Math.round(meal.total_fat)}g Fett`);
return parts.join(' | ');
}
async function handleShare() {
if (!meal) return;
const text = `${getMealTypeLabel(meal.meal_type)}\n${getNutritionText()}`;
if (navigator.share) {
try {
await navigator.share({ title: 'Nutriphi Mahlzeit', text });
} catch {
// User cancelled or share failed
}
} else {
// Fallback to copy
handleCopy();
}
}
async function handleCopy() {
if (!meal) return;
const text = `${getMealTypeLabel(meal.meal_type)}\n${getNutritionText()}`;
try {
await navigator.clipboard.writeText(text);
// Could add a toast notification here
} catch {
// Copy failed
}
}
</script>
<div class="mx-auto max-w-4xl">
<button
onclick={() => goto('/meals')}
class="mb-4 flex items-center gap-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<span></span>
Zurück zu Mahlzeiten
</button>
{#if isLoading}
<div class="flex h-64 items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-green-500 border-t-transparent"
></div>
</div>
{:else if !meal}
<div class="rounded-2xl bg-white p-8 text-center shadow-lg dark:bg-gray-800">
<div class="mb-4 text-6xl">🍽️</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Mahlzeit nicht gefunden
</h2>
<p class="mb-4 text-gray-600 dark:text-gray-400">
Diese Mahlzeit existiert nicht oder wurde gelöscht.
</p>
<button
onclick={() => goto('/meals')}
class="rounded-xl bg-green-500 px-6 py-2 font-semibold text-white hover:bg-green-600"
>
Zu Mahlzeiten
</button>
</div>
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<!-- Photo -->
<div class="overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-700">
{#if meal.photo_url}
<img src={meal.photo_url} alt="Meal" class="h-full w-full object-cover" />
{:else}
<div class="flex h-64 items-center justify-center text-6xl">🍽️</div>
{/if}
</div>
<!-- Details -->
<div class="space-y-6">
<!-- Header -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{getMealTypeLabel(meal.meal_type)}
</h1>
{#if meal.analysis_status === 'pending'}
<span
class="flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
>
<span class="h-2 w-2 animate-pulse rounded-full bg-yellow-500"></span>
Analysiert...
</span>
{:else if meal.analysis_status === 'failed'}
<span
class="rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
Fehler
</span>
{/if}
</div>
{#if meal.health_score}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full {getHealthColor(meal.health_score)}"></div>
<span class="font-semibold text-gray-900 dark:text-white"
>{meal.health_score}/10</span
>
</div>
{/if}
</div>
<p class="text-gray-600 dark:text-gray-400">
{new Date(meal.timestamp).toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<!-- User Rating -->
{#if meal.user_rating && meal.user_rating > 0}
<div class="mt-3 flex items-center gap-1">
{#each [1, 2, 3, 4, 5] as star}
<span
class="text-lg {star <= meal.user_rating
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'}"
>
</span>
{/each}
<span class="ml-1 text-sm text-gray-500 dark:text-gray-400">Deine Bewertung</span>
</div>
{/if}
</div>
<!-- User Notes -->
{#if meal.user_notes}
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">Notizen</h2>
<p class="text-gray-600 dark:text-gray-400">{meal.user_notes}</p>
</div>
{/if}
<!-- Nutrition -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Nährwerte</h2>
<NutritionBar {meal} showDetailed={true} />
</div>
<!-- Food Items -->
{#if meal.food_items && meal.food_items.length > 0}
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Zutaten</h2>
<FoodItemList items={meal.food_items} />
</div>
{/if}
<!-- Actions -->
<div class="space-y-3">
<div class="flex gap-3">
<button
onclick={() => (showEditModal = true)}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700"
>
Bearbeiten
</button>
<button
onclick={handleShare}
class="rounded-xl border-2 border-gray-300 px-4 py-3 text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
aria-label="Teilen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
</button>
<button
onclick={handleCopy}
class="rounded-xl border-2 border-gray-300 px-4 py-3 text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
aria-label="Kopieren"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<button
onclick={handleDelete}
class="w-full rounded-xl border-2 border-red-500 py-3 font-semibold text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
>
Löschen
</button>
</div>
</div>
</div>
<!-- Edit Modal -->
<MealEditModal {meal} isOpen={showEditModal} onClose={() => (showEditModal = false)} />
{/if}
</div>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth, user } from '$lib/stores/auth';
import { theme } from '$lib/stores/theme';
let isDeleting = $state(false);
let showDeleteConfirm = $state(false);
let effectiveMode = $derived(theme.effectiveMode);
async function handleDeleteAllData() {
if (!showDeleteConfirm) {
showDeleteConfirm = true;
return;
}
isDeleting = true;
// TODO: Implement data deletion
await new Promise((resolve) => setTimeout(resolve, 1000));
isDeleting = false;
showDeleteConfirm = false;
}
async function handleLogout() {
await auth.signOut();
goto('/login');
}
</script>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Einstellungen</h1>
<p class="text-gray-600 dark:text-gray-400">Verwalte dein Konto und App-Einstellungen</p>
</div>
<!-- Account -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Konto</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">E-Mail</p>
<p class="text-gray-600 dark:text-gray-400">{$user?.email || 'Nicht angemeldet'}</p>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">Benutzer-ID</p>
<p class="font-mono text-sm text-gray-600 dark:text-gray-400">{$user?.id || '—'}</p>
</div>
</div>
</div>
</div>
<!-- Appearance -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Erscheinungsbild</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">Dunkles Design</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Aktiviere den Dark Mode für eine augenfreundliche Ansicht
</p>
</div>
<button
onclick={() => theme.toggleMode()}
class="relative h-7 w-12 rounded-full transition-colors {effectiveMode === 'dark'
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'}"
>
<span
class="absolute top-1 h-5 w-5 rounded-full bg-white shadow transition-transform {effectiveMode ===
'dark'
? 'translate-x-6'
: 'translate-x-1'}"
></span>
</button>
</div>
</div>
</div>
<!-- Data Management -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Datenverwaltung</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">Daten exportieren</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Exportiere alle deine Mahlzeiten und Statistiken
</p>
</div>
<button
onclick={() => goto('/export')}
class="rounded-xl bg-gray-100 px-4 py-2 font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Export
</button>
</div>
<hr class="border-gray-200 dark:border-gray-700" />
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-red-600 dark:text-red-400">Alle Daten löschen</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Löscht alle deine Mahlzeiten und Statistiken unwiderruflich
</p>
</div>
{#if showDeleteConfirm}
<div class="flex gap-2">
<button
onclick={() => (showDeleteConfirm = false)}
class="rounded-xl bg-gray-100 px-4 py-2 font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Abbrechen
</button>
<button
onclick={handleDeleteAllData}
disabled={isDeleting}
class="rounded-xl bg-red-500 px-4 py-2 font-medium text-white hover:bg-red-600 disabled:opacity-50"
>
{isDeleting ? 'Wird gelöscht...' : 'Bestätigen'}
</button>
</div>
{:else}
<button
onclick={handleDeleteAllData}
class="rounded-xl bg-red-100 px-4 py-2 font-medium text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Löschen
</button>
{/if}
</div>
</div>
</div>
<!-- Logout -->
<button
onclick={handleLogout}
class="w-full rounded-2xl border-2 border-red-500 bg-white py-4 font-semibold text-red-600 transition-colors hover:bg-red-50 dark:bg-gray-800 dark:hover:bg-red-900/20"
>
Abmelden
</button>
<!-- App Info -->
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
<p>Nutriphi Web v0.1.0</p>
<p>Teil des Mana Core Ökosystems</p>
</div>
</div>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import type { DateRange, StatsData } from '$lib/types/stats';
let dateRange = $state<DateRange>('week');
let isLoading = $state(false);
// Placeholder stats data
let stats = $state<StatsData | null>(null);
const dateRanges: { value: DateRange; label: string }[] = [
{ value: 'week', label: 'Woche' },
{ value: 'month', label: 'Monat' },
{ value: 'year', label: 'Jahr' },
];
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Statistiken</h1>
<p class="text-gray-600 dark:text-gray-400">Überblick über deine Ernährung</p>
</div>
<!-- Date Range Selector -->
<div class="flex rounded-xl bg-gray-100 p-1 dark:bg-gray-700">
{#each dateRanges as range}
<button
onclick={() => (dateRange = range.value)}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {dateRange ===
range.value
? 'bg-white text-green-600 shadow dark:bg-gray-600 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'}"
>
{range.label}
</button>
{/each}
</div>
</div>
{#if isLoading}
<div class="flex h-64 items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-green-500 border-t-transparent"
></div>
</div>
{:else}
<!-- Summary Cards -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">Durchschn. Kalorien</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">— kcal</p>
<p class="text-sm text-green-600">Pro Tag</p>
</div>
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">Durchschn. Protein</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">— g</p>
<p class="text-sm text-blue-600">Pro Tag</p>
</div>
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">Mahlzeiten</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">0</p>
<p class="text-sm text-gray-500">Insgesamt</p>
</div>
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">Health Score</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">—/10</p>
<p class="text-sm text-yellow-600">Durchschnitt</p>
</div>
</div>
<!-- Charts -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- Calorie Chart -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Kalorien-Verlauf</h2>
<div class="flex h-64 items-center justify-center text-gray-400">
<div class="text-center">
<div class="mb-2 text-4xl">📊</div>
<p>Noch keine Daten</p>
<p class="text-sm">Erfasse deine erste Mahlzeit</p>
</div>
</div>
</div>
<!-- Macro Distribution -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Makro-Verteilung</h2>
<div class="flex h-64 items-center justify-center text-gray-400">
<div class="text-center">
<div class="mb-2 text-4xl">🥧</div>
<p>Noch keine Daten</p>
<p class="text-sm">Erfasse deine erste Mahlzeit</p>
</div>
</div>
</div>
</div>
<!-- Weekly Overview -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Wochen-Übersicht</h2>
<div class="flex h-48 items-center justify-center text-gray-400">
<div class="text-center">
<div class="mb-2 text-4xl">📅</div>
<p>Noch keine Daten verfügbar</p>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
// Placeholder subscription data
let currentPlan = $state('free');
let manaCredits = $state(150);
let dailyMana = $state(5);
</script>
<div class="mx-auto max-w-4xl space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Mana Credits</h1>
<p class="text-gray-600 dark:text-gray-400">Verwalte deine Mana Credits und Abonnement</p>
</div>
<!-- Current Balance -->
<div class="rounded-2xl bg-gradient-to-r from-green-500 to-emerald-600 p-6 text-white">
<div class="flex items-center justify-between">
<div>
<p class="text-green-100">Aktuelles Guthaben</p>
<p class="text-4xl font-bold">{manaCredits} Mana</p>
<p class="mt-1 text-green-200">+{dailyMana} Mana täglich</p>
</div>
<div class="text-6xl"></div>
</div>
</div>
<!-- Usage Info -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Mana-Kosten</h2>
<div class="space-y-3">
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700">
<div class="flex items-center gap-3">
<span class="text-2xl">📸</span>
<div>
<p class="font-medium text-gray-900 dark:text-white">Foto-Analyse</p>
<p class="text-sm text-gray-600 dark:text-gray-400">KI analysiert deine Mahlzeit</p>
</div>
</div>
<span class="font-semibold text-green-600">5 Mana</span>
</div>
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700">
<div class="flex items-center gap-3">
<span class="text-2xl">📝</span>
<div>
<p class="font-medium text-gray-900 dark:text-white">Text-Analyse</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Beschreibung analysieren</p>
</div>
</div>
<span class="font-semibold text-green-600">3 Mana</span>
</div>
</div>
</div>
<!-- Subscription Plans -->
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Abonnements</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Free -->
<div
class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800 {currentPlan === 'free'
? 'ring-2 ring-green-500'
: ''}"
>
<p class="text-sm text-gray-600 dark:text-gray-400">Kostenlos</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">Free</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">€0</p>
<p class="text-sm text-gray-500">/Monat</p>
<ul class="mt-4 space-y-2 text-sm">
<li class="text-gray-600 dark:text-gray-400">✓ 150 Mana Start</li>
<li class="text-gray-600 dark:text-gray-400">✓ +5 Mana/Tag</li>
<li class="text-gray-600 dark:text-gray-400">✓ Basis-Analysen</li>
</ul>
{#if currentPlan === 'free'}
<div
class="mt-4 rounded-lg bg-green-100 py-2 text-center text-sm font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
Aktueller Plan
</div>
{/if}
</div>
<!-- Stream -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">Stream</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">Basic</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">€5.99</p>
<p class="text-sm text-gray-500">/Monat</p>
<ul class="mt-4 space-y-2 text-sm">
<li class="text-gray-600 dark:text-gray-400">✓ 500 Mana/Monat</li>
<li class="text-gray-600 dark:text-gray-400">✓ +10 Mana/Tag</li>
<li class="text-gray-600 dark:text-gray-400">✓ Erweiterte Analysen</li>
</ul>
<button
class="mt-4 w-full rounded-lg bg-green-500 py-2 text-sm font-medium text-white hover:bg-green-600"
>
Upgrade
</button>
</div>
<!-- River -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">River</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">Standard</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">€14.99</p>
<p class="text-sm text-gray-500">/Monat</p>
<ul class="mt-4 space-y-2 text-sm">
<li class="text-gray-600 dark:text-gray-400">✓ 1500 Mana/Monat</li>
<li class="text-gray-600 dark:text-gray-400">✓ +20 Mana/Tag</li>
<li class="text-gray-600 dark:text-gray-400">✓ Prioritäts-Support</li>
</ul>
<button
class="mt-4 w-full rounded-lg bg-green-500 py-2 text-sm font-medium text-white hover:bg-green-600"
>
Upgrade
</button>
</div>
<!-- Lake -->
<div class="rounded-2xl border-2 border-green-500 bg-white p-6 shadow-lg dark:bg-gray-800">
<div
class="mb-2 inline-block rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
Beliebt
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Lake</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">Premium</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">€29.99</p>
<p class="text-sm text-gray-500">/Monat</p>
<ul class="mt-4 space-y-2 text-sm">
<li class="text-gray-600 dark:text-gray-400">✓ 4000 Mana/Monat</li>
<li class="text-gray-600 dark:text-gray-400">✓ +50 Mana/Tag</li>
<li class="text-gray-600 dark:text-gray-400">✓ Alle Features</li>
</ul>
<button
class="mt-4 w-full rounded-lg bg-gradient-to-r from-green-500 to-emerald-600 py-2 text-sm font-medium text-white hover:from-green-600 hover:to-emerald-700"
>
Upgrade
</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,229 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores/auth';
import { uploadMealPhoto, resizeImage } from '$lib/services/uploadService';
import type { MealType } from '$lib/types/meal';
let isDragging = $state(false);
let selectedFile = $state<File | null>(null);
let previewUrl = $state<string | null>(null);
let mealType = $state<MealType>('lunch');
let isUploading = $state(false);
let uploadProgress = $state(0);
let uploadMessage = $state('');
let error = $state('');
const mealTypes: { value: MealType; label: string }[] = [
{ value: 'breakfast', label: 'Frühstück' },
{ value: 'lunch', label: 'Mittagessen' },
{ value: 'dinner', label: 'Abendessen' },
{ value: 'snack', label: 'Snack' },
];
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
}
function handleFileInput(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFile(input.files[0]);
}
}
function handleFile(file: File) {
if (!file.type.startsWith('image/')) {
error = 'Bitte wähle ein Bild aus';
return;
}
if (file.size > 10 * 1024 * 1024) {
error = 'Das Bild darf maximal 10MB groß sein';
return;
}
error = '';
selectedFile = file;
previewUrl = URL.createObjectURL(file);
}
function clearSelection() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
selectedFile = null;
previewUrl = null;
error = '';
uploadProgress = 0;
uploadMessage = '';
}
async function handleUpload() {
if (!selectedFile || !$user?.id) return;
isUploading = true;
uploadProgress = 0;
uploadMessage = '';
error = '';
try {
// Resize large images
let fileToUpload = selectedFile;
if (selectedFile.size > 2 * 1024 * 1024) {
uploadMessage = 'Bild wird optimiert...';
fileToUpload = await resizeImage(selectedFile);
}
// Upload with progress tracking
const result = await uploadMealPhoto(fileToUpload, $user.id, mealType, (progress) => {
uploadProgress = progress.progress;
uploadMessage = progress.message || '';
});
if (result.success) {
// Navigate to meals after successful upload
setTimeout(() => {
goto('/meals');
}, 500);
} else {
error = result.error || 'Upload fehlgeschlagen';
isUploading = false;
}
} catch (err) {
error = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
isUploading = false;
}
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div class="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Mahlzeit hochladen</h1>
<p class="text-gray-600 dark:text-gray-400">
Lade ein Foto deiner Mahlzeit hoch und erhalte automatisch eine Nährwertanalyse
</p>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto p-6">
<div class="mx-auto max-w-2xl">
{#if error}
<div
class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{error}
</div>
{/if}
{#if !selectedFile}
<!-- Drop Zone -->
<div
class="rounded-2xl border-2 border-dashed p-12 text-center transition-colors {isDragging
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="button"
tabindex="0"
>
<div class="mb-4 text-6xl">📸</div>
<p class="mb-2 text-lg font-medium text-gray-900 dark:text-white">Foto hierher ziehen</p>
<p class="mb-4 text-gray-600 dark:text-gray-400">oder klicken zum Auswählen</p>
<label
class="inline-block cursor-pointer rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700"
>
Foto auswählen
<input type="file" accept="image/*" class="hidden" onchange={handleFileInput} />
</label>
<p class="mt-4 text-sm text-gray-500">Unterstützte Formate: JPG, PNG, HEIC (max. 10MB)</p>
</div>
{:else}
<!-- Preview -->
<div class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
<div class="relative mb-6 overflow-hidden rounded-xl">
<img src={previewUrl} alt="Preview" class="h-64 w-full object-cover" />
<button
onclick={clearSelection}
class="absolute right-2 top-2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
>
</button>
</div>
<!-- Meal Type Selection -->
<div class="mb-6">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Art der Mahlzeit
</label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each mealTypes as type}
<button
onclick={() => (mealType = type.value)}
class="rounded-xl px-4 py-3 text-sm font-medium transition-colors {mealType ===
type.value
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{type.label}
</button>
{/each}
</div>
</div>
<!-- Upload Progress -->
{#if isUploading}
<div class="mb-6">
<div class="mb-2 flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">
{uploadMessage || 'Wird hochgeladen...'}
</span>
<span class="font-medium text-green-600">{uploadProgress}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-gradient-to-r from-green-500 to-emerald-600 transition-all duration-300"
style="width: {uploadProgress}%"
></div>
</div>
</div>
{/if}
<!-- Actions -->
<div class="flex gap-4">
<button
onclick={clearSelection}
disabled={isUploading}
class="flex-1 rounded-xl border-2 border-gray-300 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleUpload}
disabled={isUploading}
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploading ? 'Wird hochgeladen...' : 'Analysieren'}
</button>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<div
class="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 dark:from-gray-900 dark:to-gray-800"
>
{@render children()}
</div>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { NutriPhiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
titleForm: 'Passwort zurücksetzen',
titleSuccess: 'E-Mail gesendet',
description:
'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.',
emailPlaceholder: 'E-Mail',
sendResetLinkButton: 'Link senden',
sending: 'Wird gesendet...',
backToLogin: 'Zurück zur Anmeldung',
resendEmail: 'E-Mail erneut senden',
successMessage:
'Wir haben einen Link zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang.',
emailRequired: 'E-Mail ist erforderlich',
sendFailed: 'Fehler beim Senden der E-Mail',
};
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<svelte:head>
<title>Passwort zurücksetzen | Nutriphi</title>
</svelte:head>
<ForgotPasswordPage
appName="Nutriphi"
logo={NutriPhiLogo}
primaryColor="#10b981"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#d1fae5"
darkBackground="#022c22"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { NutriPhiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth';
import AppSlider from '$lib/components/AppSlider.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/meals');
// German translations
const translations = {
title: 'Anmelden',
subtitle: 'Melde dich mit deinem Konto an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolgreich!',
orDivider: 'oder',
noAccount: 'Noch kein Konto?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...',
};
async function handleSignIn(email: string, password: string) {
return auth.signIn(email, password);
}
</script>
<svelte:head>
<title>Anmelden | Nutriphi</title>
</svelte:head>
<LoginPage
appName="Nutriphi"
logo={NutriPhiLogo}
primaryColor="#10b981"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#d1fae5"
darkBackground="#022c22"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { NutriPhiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
title: 'Konto erstellen',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
confirmPasswordPlaceholder: 'Passwort bestätigen',
passwordRequirements:
'Passwort muss mindestens 8 Zeichen mit Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten.',
createAccountButton: 'Konto erstellen',
creatingAccount: 'Wird erstellt...',
backToLogin: 'Zurück zur Anmeldung',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
passwordRequired: 'Passwort ist erforderlich',
confirmPasswordRequired: 'Bitte bestätige dein Passwort',
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',
passwordStrengthError:
'Passwort muss Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten',
registrationFailed: 'Registrierung fehlgeschlagen',
accountCreated: 'Konto erstellt! Bitte überprüfe deine E-Mails zur Bestätigung.',
};
async function handleSignUp(email: string, password: string) {
return auth.signUp(email, password);
}
</script>
<svelte:head>
<title>Registrieren | Nutriphi</title>
</svelte:head>
<RegisterPage
appName="Nutriphi"
logo={NutriPhiLogo}
primaryColor="#10b981"
onSignUp={handleSignUp}
{goto}
successRedirect="/meals"
loginPath="/login"
lightBackground="#d1fae5"
darkBackground="#022c22"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import '../app.css';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
let { children } = $props();
// Initialize theme on mount
onMount(() => {
theme.initialize();
});
// Get effective theme mode
let effectiveMode = $derived(theme.effectiveMode);
</script>
<svelte:head>
<title>Nutriphi - KI-gestützter Ernährungs-Tracker</title>
<meta
name="description"
content="Tracke deine Ernährung mit KI-gestützter Mahlzeit-Analyse. Einfach Foto hochladen und Nährwerte erhalten."
/>
</svelte:head>
<div
class="min-h-screen bg-theme-background text-theme-primary"
class:dark={effectiveMode === 'dark'}
>
{@render children()}
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<main
class="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center p-4"
>
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div class="mb-6">
<div
class="w-20 h-20 bg-gradient-to-br from-green-400 to-emerald-500 rounded-full mx-auto flex items-center justify-center"
>
<span class="text-4xl">🥗</span>
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nutriphi</h1>
<p class="text-gray-600 mb-8">KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse</p>
<div class="space-y-4">
<button
class="w-full py-3 px-6 bg-gradient-to-r from-green-500 to-emerald-600 text-white font-semibold rounded-xl hover:from-green-600 hover:to-emerald-700 transition-all shadow-lg hover:shadow-xl"
onclick={() => goto('/login')}
>
Anmelden
</button>
<button
class="w-full py-3 px-6 border-2 border-green-500 text-green-600 font-semibold rounded-xl hover:bg-green-50 transition-all"
onclick={() => goto('/register')}
>
Registrieren
</button>
</div>
<div class="mt-8 pt-6 border-t border-gray-200">
<p class="text-sm text-gray-500">
Teil des <span class="font-semibold text-green-600">Mana Core</span> Ökosystems
</p>
</div>
</div>
</main>