mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 22:06:42 +02:00
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:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
1
apps-archived/nutriphi/apps/web/src/app.css
Normal file
1
apps-archived/nutriphi/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import 'tailwindcss';
|
||||
18
apps-archived/nutriphi/apps/web/src/app.d.ts
vendored
Normal file
18
apps-archived/nutriphi/apps/web/src/app.d.ts
vendored
Normal 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 {};
|
||||
12
apps-archived/nutriphi/apps/web/src/app.html
Normal file
12
apps-archived/nutriphi/apps/web/src/app.html
Normal 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>
|
||||
9
apps-archived/nutriphi/apps/web/src/hooks.server.ts
Normal file
9
apps-archived/nutriphi/apps/web/src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
42
apps-archived/nutriphi/apps/web/src/lib/config/env.ts
Normal file
42
apps-archived/nutriphi/apps/web/src/lib/config/env.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
116
apps-archived/nutriphi/apps/web/src/lib/services/api.ts
Normal file
116
apps-archived/nutriphi/apps/web/src/lib/services/api.ts
Normal 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();
|
||||
411
apps-archived/nutriphi/apps/web/src/lib/services/authService.ts
Normal file
411
apps-archived/nutriphi/apps/web/src/lib/services/authService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
154
apps-archived/nutriphi/apps/web/src/lib/services/mealService.ts
Normal file
154
apps-archived/nutriphi/apps/web/src/lib/services/mealService.ts
Normal 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();
|
||||
229
apps-archived/nutriphi/apps/web/src/lib/services/statsService.ts
Normal file
229
apps-archived/nutriphi/apps/web/src/lib/services/statsService.ts
Normal 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();
|
||||
342
apps-archived/nutriphi/apps/web/src/lib/services/tokenManager.ts
Normal file
342
apps-archived/nutriphi/apps/web/src/lib/services/tokenManager.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
249
apps-archived/nutriphi/apps/web/src/lib/stores/auth.ts
Normal file
249
apps-archived/nutriphi/apps/web/src/lib/stores/auth.ts
Normal 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);
|
||||
143
apps-archived/nutriphi/apps/web/src/lib/stores/goals.svelte.ts
Normal file
143
apps-archived/nutriphi/apps/web/src/lib/stores/goals.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
169
apps-archived/nutriphi/apps/web/src/lib/stores/meals.svelte.ts
Normal file
169
apps-archived/nutriphi/apps/web/src/lib/stores/meals.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
24
apps-archived/nutriphi/apps/web/src/lib/stores/theme.ts
Normal file
24
apps-archived/nutriphi/apps/web/src/lib/stores/theme.ts
Normal 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%',
|
||||
},
|
||||
});
|
||||
39
apps-archived/nutriphi/apps/web/src/lib/types/goal.ts
Normal file
39
apps-archived/nutriphi/apps/web/src/lib/types/goal.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
77
apps-archived/nutriphi/apps/web/src/lib/types/meal.ts
Normal file
77
apps-archived/nutriphi/apps/web/src/lib/types/meal.ts
Normal 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;
|
||||
}
|
||||
56
apps-archived/nutriphi/apps/web/src/lib/types/stats.ts
Normal file
56
apps-archived/nutriphi/apps/web/src/lib/types/stats.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
30
apps-archived/nutriphi/apps/web/src/routes/+layout.svelte
Normal file
30
apps-archived/nutriphi/apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
42
apps-archived/nutriphi/apps/web/src/routes/+page.svelte
Normal file
42
apps-archived/nutriphi/apps/web/src/routes/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue