Initial commit: Hellth Hub monorepo
Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95
.claude/rules.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Claude Project Rules - Hellth Hub
|
||||
|
||||
## 1. Type Safety
|
||||
|
||||
- TypeScript strict mode
|
||||
- Kein `any`
|
||||
- Keine unsicheren Type Assertions (`as X`)
|
||||
- Drizzle-Query-Builder-Typen nutzen statt eigene Interfaces dupliziert beschreiben
|
||||
|
||||
## 2. Trennung der Verantwortlichkeiten
|
||||
|
||||
```
|
||||
Business-Logik → packages/api (tRPC Router, Coach, Domain-Helper)
|
||||
DB-Logik → packages/db (Drizzle-Schema, Migrationen)
|
||||
Apps → apps/admin (nur UI + dünner Glue zur API)
|
||||
```
|
||||
|
||||
Die Admin-App enthält keine Business-Logik. Wenn Berechnung in beiden Seiten gebraucht wird (z. B. Nährwerte), gehört der Kanon in `packages/api/src/lib/`; das Frontend bekommt ihn über tRPC oder dupliziert nur die UI-nahe Anzeige-Variante in `apps/admin/lib/hellth-data.ts`.
|
||||
|
||||
## 3. Domäne
|
||||
|
||||
Hellth Hub ist eine Single-User-Health-App pro Benutzer-Account. Datenmodell siehe `packages/db/src/schema/health.ts` (Foods, Recipes, MealLogs, ShoppingLists, Stores, ActivityLogs, Wearables, ClubActivities, Coach …).
|
||||
|
||||
Keine Naturfreunde-Begriffe wiederbeleben (News, Events, Fachgruppen, Mandanten, Häuser, Buchungen, `tenantId`, `verband_admin`, `haus_admin`, Mitgliedsausweise, Google-Wallet).
|
||||
|
||||
## 4. Rollen
|
||||
|
||||
- `user` (default) - normaler Benutzer
|
||||
- `admin` - Plattform-Admin (z. B. `admin@onl1.eu`)
|
||||
- Admins haben Pflicht-2FA (siehe `enforceTwoFactorForPrivilegedUsers` in `packages/api/src/trpc/init.ts`)
|
||||
|
||||
Rechte immer **serverseitig** prüfen (tRPC-Procedure-Auswahl: `protectedProcedure` für eingeloggte User, `platformProcedure` für Admins).
|
||||
|
||||
## 5. Performance
|
||||
|
||||
- Keine N+1-Queries (Drizzle `.with()` / Joins / `inArray` für Batch-Loads nutzen)
|
||||
- Listen-Endpoints mit Suche: Filterung auf DB-Seite (`ilike`), nicht in-Memory
|
||||
- Index-Pfade respektieren (siehe Index-Definitionen in `schema/health.ts`)
|
||||
|
||||
## 6. Sicherheit
|
||||
|
||||
- Auth-Pflicht für alle Routen außer Login / Health-Check
|
||||
- Keine Secrets im Repo - nur `.env` (in `.gitignore`)
|
||||
- 2FA-Recovery nur für Admins (`platformProcedure`)
|
||||
- Bei `twoFactorRecoveryRequired = true` muss 2FA-Neueinrichtung erzwungen werden
|
||||
- Login auf `/login/otp` muss sowohl TOTP-Code als auch Backup-Code unterstützen
|
||||
- Better-Auth-Konfiguration (`packages/api/src/lib/auth.ts`) ist Single Source of Truth für Auth-Optionen
|
||||
- CSP / Security-Headers in `apps/admin/next.config.ts` und `packages/api/src/app.ts` synchron halten
|
||||
|
||||
### User-Security-Felder (aktiv)
|
||||
|
||||
```
|
||||
twoFactorEnabled
|
||||
twoFactorRecoveryRequired
|
||||
twoFactorRecoveryStartedAt
|
||||
twoFactorRecoveryStartedBy
|
||||
```
|
||||
|
||||
## 7. UI-Regeln
|
||||
|
||||
- Mobile-first - Hellth Hub wird primär am Handy benutzt
|
||||
- Klar, modern, funktional - keine visuellen Spielereien
|
||||
- Bottom-Navigation auf Mobile, Sidebar auf Desktop (`apps/admin/app/_components/`)
|
||||
- Neue Formulare über `FormField` und `inputClassName` aus `apps/admin/app/_components/form-field.tsx`
|
||||
- Deutsche UI: echte Umlaute (`ä`, `ö`, `ü`, `ß`)
|
||||
- UI-Inspiration siehe `docs/inspiration/bitepal/`
|
||||
|
||||
## 8. DB-Änderungen
|
||||
|
||||
- Schema-Änderung, Migration, API-Endpoint und UI immer zusammen denken
|
||||
- `pnpm --filter @hellth/db db:generate` für Migrationen
|
||||
- Migrationen nicht nachträglich umbenennen oder löschen (Drizzle tracked sie per Datei + Hash)
|
||||
- Neue Tabellen brauchen passende Indizes für die Haupt-Queries
|
||||
|
||||
## 9. Tests
|
||||
|
||||
Mindestens absichern:
|
||||
- Auth- und 2FA-Pfade
|
||||
- Nährwert- / Kalorien- / Wochenbudget-Berechnung
|
||||
- Hellfireclub-Punkte-Logik
|
||||
|
||||
Vor PR / größerer Änderung lokal grün:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @hellth/api test
|
||||
pnpm --filter admin test
|
||||
pnpm exec tsc --noEmit --project packages/api/tsconfig.json
|
||||
$env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
|
||||
```
|
||||
|
||||
## 10. Arbeitsweise mit Claude
|
||||
|
||||
- Bestehende User-Änderungen nicht zurücksetzen
|
||||
- Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung
|
||||
- Cleanup an Altlasten nur nach expliziter Freigabe (siehe `claude-info.md` Abschnitt "Bekannte Altlasten")
|
||||
14
.claude/settings.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(wc:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(pnpm --filter @hellth/db db:push:*)",
|
||||
"Bash(pnpm --filter @hellth/db db:generate:*)",
|
||||
"Bash(pnpm --filter @hellth/db db:migrate:*)",
|
||||
"Bash(pnpm --filter @hellth/api test:*)",
|
||||
"Bash(pnpm --filter admin test:*)",
|
||||
"Bash(pnpm exec tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Datenbank
|
||||
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
|
||||
|
||||
# Better Auth, in Produktion durch starkes Secret ersetzen
|
||||
BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
|
||||
BETTER_AUTH_BASE_URL=http://localhost:3002
|
||||
PASSKEY_RP_NAME=Hellth Hub
|
||||
|
||||
# API
|
||||
API_PORT=3002
|
||||
TRUSTED_ORIGINS=http://localhost:3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
TRUST_PROXY_FORWARD_HEADERS=false
|
||||
DATABASE_REQUIRE_SSL=false
|
||||
ENABLE_DB_MIGRATIONS=false
|
||||
|
||||
# Plattform-Admin, kommasepariert
|
||||
PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
||||
NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
||||
|
||||
# Initialer Admin-User
|
||||
ADMIN_EMAIL=admin@domain.tld
|
||||
ADMIN_INITIAL_PASSWORD=please-set-a-strong-password-with-16-plus-chars
|
||||
|
||||
# Wearables: Fitbit OAuth, optional
|
||||
FITBIT_CLIENT_ID=
|
||||
FITBIT_CLIENT_SECRET=
|
||||
FITBIT_REDIRECT_URI=http://localhost:3002/api/wearables/fitbit/callback
|
||||
WEARABLE_TOKEN_ENCRYPTION_KEY=change-me-32-byte-base64-key
|
||||
47
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build/Cache
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
.turbo/
|
||||
.cache/
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.logs/
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Env/Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS/Editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Runtime/Uploads
|
||||
uploads/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Python (for scripts/)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# Local machine-specific agent settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# Project-specific
|
||||
credentials.txt
|
||||
4
.npmrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
onlyBuiltDependencies[]=esbuild
|
||||
onlyBuiltDependencies[]=sharp
|
||||
onlyBuiltDependencies[]=unrs-resolver
|
||||
shamefully-hoist=true
|
||||
24
AGENTS.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Agent-Regeln - Hellth Hub
|
||||
|
||||
Regeln für AI-Agents (Codex, Claude, weitere Assistenten) in diesem Repository.
|
||||
|
||||
## Ziel
|
||||
|
||||
- Einheitliche Arbeitsweise für AI-gestützte Änderungen.
|
||||
- Klare Priorität bei möglichen Konflikten zwischen Markdown-Quellen.
|
||||
- Hellth Hub bleibt fachlich eine Health- und Ernährungstracking-App.
|
||||
|
||||
## Lesereihenfolge für Agents
|
||||
|
||||
1. `AGENTS.md`
|
||||
2. `docs/ai/quick-reference.md`
|
||||
3. `docs/INDEX.md`
|
||||
4. Fachliche Detaildokumente nach Bedarf
|
||||
|
||||
## Grundregeln
|
||||
|
||||
- Umlaute immer korrekt schreiben.
|
||||
- Keine alten Altprojekt-, Tenant- oder Community-Portal-Begriffe in UI, API oder Doku einführen.
|
||||
- Benutzerbezogene Einstellungen bleiben pro User gespeichert.
|
||||
- Auth, 2FA, Admin-UI und Docker-Setup bleiben lauffähig.
|
||||
- Keine Secrets in Markdown-Dateien speichern.
|
||||
11
CLAUDE.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Claude Working Guidelines - Hellth Hub
|
||||
|
||||
Hellth Hub ist eine mobile-first Health- und Ernährungstracking-App für den Home-Lab-Betrieb.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Umlaute immer korrekt schreiben.
|
||||
- Keine alten Altprojekt-/Tenant-/Community-Begriffe in neue UI oder Doku übernehmen.
|
||||
- Einstellungen und Health-Daten sind benutzerbezogen, nicht global.
|
||||
- Bestehende Architektur nutzen: `apps/admin`, `packages/api`, `packages/db`.
|
||||
- Änderungen mit Tests absichern, besonders bei Auth, Health-Daten, Wochenbudget und Einkaufslisten.
|
||||
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Hellth Hub
|
||||
|
||||
Mobile-first Web-App für persönliches Health- und Ernährungstracking im Home-Lab.
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d db
|
||||
pnpm --filter @hellth/db db:migrate
|
||||
pnpm --filter @hellth/api seed:admin
|
||||
pnpm --filter @hellth/api seed:health
|
||||
pnpm --filter @hellth/api dev
|
||||
pnpm --filter admin dev
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
- Admin-UI: http://localhost:3001
|
||||
- API: http://localhost:3002
|
||||
- Health Check: http://localhost:3002/api/health
|
||||
|
||||
## Aktuelle Module
|
||||
|
||||
- Auth, Login, 2FA, Passkeys und Benutzerverwaltung
|
||||
- Tagesansicht mit Essen, Trinken, Gewicht, Aktivitäten und Coach-Hinweisen
|
||||
- Lebensmittelkatalog mit Nährwerten, Portionen, Favoriten und OCR-Vorbereitung
|
||||
- Rezepte mit flexiblen Mengenangaben, Bewertungen und Meal-Prep-Bezug
|
||||
- Essensplanung mit Tages- und Wochenlogik
|
||||
- Wochenbudget für Kalorien inklusive Sportbonus
|
||||
- Einkaufsliste mit Märkten, Abteilungen und Laufweg-Sortierung
|
||||
- Ziele, Onboarding und persönliche Einstellungen pro Benutzer
|
||||
- Erinnerungen, Aktivitätspunkte, Hellfireclub und Wearable-Vorbereitung
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
pnpm --filter @hellth/api test -- --test-reporter=spec
|
||||
pnpm --filter admin test
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002 pnpm --filter admin build
|
||||
```
|
||||
371
apps/admin/app/(authed)/aktivitaeten/page.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BoltIcon,
|
||||
CalendarDaysIcon,
|
||||
FireIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type TemplateCategory = "mobility" | "cardio" | "strength" | "habit" | string;
|
||||
type ActivitySource = "manual" | "fitbit";
|
||||
|
||||
function todayKey() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfWeek(dateKey: string) {
|
||||
const date = new Date(`${dateKey}T12:00:00`);
|
||||
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||||
date.setDate(date.getDate() - day + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function pointsLabel(points: number) {
|
||||
if (points >= 500) return "Starke Woche";
|
||||
if (points >= 250) return "Guter Lauf";
|
||||
if (points >= 100) return "In Bewegung";
|
||||
return "Startklar";
|
||||
}
|
||||
|
||||
function categoryColor(category: TemplateCategory) {
|
||||
if (category.includes("cardio")) return "bg-orange-50 text-orange-700";
|
||||
if (category.includes("strength")) return "bg-blue-50 text-blue-700";
|
||||
if (category.includes("mobility")) return "bg-emerald-50 text-emerald-700";
|
||||
if (category.includes("sport")) return "bg-amber-50 text-amber-700";
|
||||
return "bg-purple-50 text-purple-700";
|
||||
}
|
||||
|
||||
function categoryLabel(category: TemplateCategory) {
|
||||
if (category.includes("cardio")) return "Ausdauer";
|
||||
if (category.includes("strength")) return "Kraft";
|
||||
if (category.includes("mobility")) return "Beweglichkeit";
|
||||
if (category.includes("sport")) return "Sport";
|
||||
return "Routine";
|
||||
}
|
||||
|
||||
function sourceLabel(source: string) {
|
||||
if (source === "fitbit") return "Fitbit";
|
||||
if (source === "health_connect") return "Health Connect";
|
||||
return "manuell";
|
||||
}
|
||||
|
||||
function estimatePoints(minutes: number, calories: number) {
|
||||
return Math.max(5, Math.round(minutes * 1.2 + calories / 6));
|
||||
}
|
||||
|
||||
export default function AktivitätenPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedDate, setSelectedDate] = useState(todayKey());
|
||||
const [templateQuery, setTemplateQuery] = useState("");
|
||||
const [templateCategory, setTemplateCategory] = useState<"all" | "fitbit" | "manual">("all");
|
||||
const [customName, setCustomName] = useState("");
|
||||
const [customMinutes, setCustomMinutes] = useState("15");
|
||||
const [customPoints, setCustomPoints] = useState("30");
|
||||
const [customCalories, setCustomCalories] = useState("60");
|
||||
const [customNote, setCustomNote] = useState("");
|
||||
const [customSource, setCustomSource] = useState<ActivitySource>("manual");
|
||||
|
||||
const templatesQuery = trpc.health.listActivityTemplates.useQuery();
|
||||
const activityWeekQuery = trpc.health.activityWeek.useQuery({ date: selectedDate });
|
||||
const addLogMutation = trpc.health.addActivityLog.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.activityWeek.invalidate();
|
||||
await utils.health.overview.invalidate();
|
||||
},
|
||||
});
|
||||
const saveTemplateMutation = trpc.health.saveActivityTemplate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.listActivityTemplates.invalidate();
|
||||
setCustomName("");
|
||||
},
|
||||
});
|
||||
const deleteLogMutation = trpc.health.deleteActivityLog.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.activityWeek.invalidate();
|
||||
await utils.health.overview.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const logs = activityWeekQuery.data?.logs ?? [];
|
||||
const templates = templatesQuery.data ?? [];
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const needle = templateQuery.trim().toLowerCase();
|
||||
return templates.filter((template) => {
|
||||
const isFitbit = template.id.startsWith("fitbit-") || template.category.startsWith("fitbit_");
|
||||
if (templateCategory === "fitbit" && !isFitbit) return false;
|
||||
if (templateCategory === "manual" && isFitbit) return false;
|
||||
if (!needle) return true;
|
||||
return [template.name, template.category].some((value) => value.toLowerCase().includes(needle));
|
||||
});
|
||||
}, [templateCategory, templateQuery, templates]);
|
||||
const todayLogs = logs.filter((log) => log.date === selectedDate);
|
||||
const dayPoints = todayLogs.reduce((sum, log) => sum + log.points, 0);
|
||||
const weekPoints = logs.reduce((sum, log) => sum + log.points, 0);
|
||||
const weekMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
|
||||
const weekCalories = logs.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
||||
|
||||
const weeklyDays = useMemo(() => {
|
||||
const monday = new Date(`${startOfWeek(selectedDate)}T12:00:00`);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + index);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
const points = logs.filter((log) => log.date === key).reduce((sum, log) => sum + log.points, 0);
|
||||
return { key, label: date.toLocaleDateString("de-DE", { weekday: "narrow" }), day: date.getDate(), points };
|
||||
});
|
||||
}, [logs, selectedDate]);
|
||||
|
||||
function logTemplate(template: (typeof templates)[number]) {
|
||||
const isFitbit = template.id.startsWith("fitbit-") || template.category.startsWith("fitbit_");
|
||||
addLogMutation.mutate({
|
||||
date: selectedDate,
|
||||
name: template.name,
|
||||
templateId: template.id,
|
||||
minutes: template.defaultMinutes,
|
||||
points: template.defaultPoints,
|
||||
calories: template.defaultCalories,
|
||||
source: isFitbit ? "fitbit" : "manual",
|
||||
});
|
||||
}
|
||||
|
||||
function saveCustomTemplate() {
|
||||
if (!customName.trim()) return;
|
||||
saveTemplateMutation.mutate({
|
||||
name: customName.trim(),
|
||||
defaultMinutes: Number(customMinutes) || 5,
|
||||
defaultPoints: Number(customPoints) || 10,
|
||||
defaultCalories: Number(customCalories) || 0,
|
||||
category: "habit",
|
||||
icon: "custom",
|
||||
});
|
||||
}
|
||||
|
||||
function addCustomLog() {
|
||||
if (!customName.trim()) return;
|
||||
const minutes = Number(customMinutes) || 5;
|
||||
const calories = Number(customCalories) || 0;
|
||||
const points = Number(customPoints) || estimatePoints(minutes, calories);
|
||||
addLogMutation.mutate({
|
||||
date: selectedDate,
|
||||
name: customName.trim(),
|
||||
minutes,
|
||||
points,
|
||||
calories,
|
||||
source: customSource,
|
||||
note: customNote.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-emerald-300">Belohnungssystem</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal">Aktivitätspunkte</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm font-semibold text-white/60">
|
||||
Fitbit-kompatibler Katalog, verbrannte kcal und manuelle Nacherfassung für Training ohne Uhr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-28 w-28 place-items-center rounded-full bg-white text-gray-950 ring-[12px] ring-white/10">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-black">{dayPoints}</p>
|
||||
<p className="text-xs font-black text-gray-400">heute</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<BoltIcon className="h-8 w-8 text-emerald-500" />
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-950">{weekPoints}</p>
|
||||
<p className="text-sm font-bold text-gray-400">Punkte diese Woche</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDaysIcon className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-950">{weekMinutes}</p>
|
||||
<p className="text-sm font-bold text-gray-400">aktive Minuten</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<FireIcon className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-950">{weekCalories}</p>
|
||||
<p className="text-sm font-bold text-gray-400">kcal Sportbonus</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<SparklesIcon className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-950">{pointsLabel(weekPoints)}</p>
|
||||
<p className="text-sm font-bold text-gray-400">aktueller Status</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-black text-gray-950">Woche</h2>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(event) => setSelectedDate(event.target.value || todayKey())}
|
||||
className="rounded-full border border-gray-200 px-4 py-3 text-sm font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-7 gap-2">
|
||||
{weeklyDays.map((day) => {
|
||||
const active = day.key === selectedDate;
|
||||
return (
|
||||
<button
|
||||
key={day.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(day.key)}
|
||||
className={`min-h-20 rounded-3xl p-2 text-center ${active ? "bg-gray-950 text-white" : "bg-gray-50 text-gray-400"}`}
|
||||
>
|
||||
<p className="text-sm font-black uppercase">{day.label}</p>
|
||||
<p className="text-xl font-black">{day.day}</p>
|
||||
<p className={`mt-1 text-xs font-black ${active ? "text-emerald-300" : "text-gray-300"}`}>{day.points} P</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-black text-gray-950">Fitness-Katalog</h2>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "fitbit", "manual"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setTemplateCategory(key)}
|
||||
className={`rounded-full px-4 py-2 text-xs font-black ${templateCategory === key ? "bg-gray-950 text-white" : "bg-gray-100 text-gray-500"}`}
|
||||
>
|
||||
{key === "all" ? "Alle" : key === "fitbit" ? "Fitbit" : "Eigene"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={templateQuery}
|
||||
onChange={(event) => setTemplateQuery(event.target.value)}
|
||||
placeholder="Aktivität suchen"
|
||||
className="mt-4 w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
{filteredTemplates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => logTemplate(template)}
|
||||
disabled={addLogMutation.isPending}
|
||||
className="rounded-3xl bg-gray-50 p-4 text-left hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-lg font-black text-gray-950">{template.name}</p>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">
|
||||
{template.defaultMinutes} Minuten · {categoryLabel(template.category)}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-gray-300">ca. {template.defaultCalories} kcal Sportbonus</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-black ${categoryColor(template.category)}`}>
|
||||
{template.defaultPoints} P
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!filteredTemplates.length && !templatesQuery.isLoading ? (
|
||||
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Keine passende Vorlage gefunden.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Aktivität nacherfassen</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input value={customName} onChange={(event) => setCustomName(event.target.value)} placeholder="z. B. 15 Minuten Yoga" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<select value={customSource} onChange={(event) => setCustomSource(event.target.value as ActivitySource)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
<option value="manual">Manuell erfasst</option>
|
||||
<option value="fitbit">Fitbit nachgetragen</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={customMinutes} onChange={(event) => setCustomMinutes(event.target.value)} placeholder="Minuten" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={customPoints} onChange={(event) => setCustomPoints(event.target.value)} placeholder="Punkte" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={customCalories} onChange={(event) => setCustomCalories(event.target.value)} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
</div>
|
||||
<textarea value={customNote} onChange={(event) => setCustomNote(event.target.value)} placeholder="Notiz optional" className="min-h-20 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomLog}
|
||||
disabled={addLogMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-emerald-600 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Für {new Date(`${selectedDate}T12:00:00`).toLocaleDateString("de-DE")} eintragen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCustomTemplate}
|
||||
disabled={saveTemplateMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Zusätzlich als Vorlage speichern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Heute eingetragen</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{todayLogs.length ? todayLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
||||
<FireIcon className="h-6 w-6 text-orange-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-black text-gray-950">{log.name}</p>
|
||||
<p className="text-xs font-bold text-gray-400">
|
||||
{log.minutes} min · {log.points} Punkte · {log.calories ?? 0} kcal · {sourceLabel(log.source)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteLogMutation.mutate({ id: log.id })}
|
||||
className="rounded-full p-2 text-red-500 hover:bg-red-50"
|
||||
aria-label="Eintrag löschen"
|
||||
title="Eintrag löschen"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="rounded-2xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Aktivität für diesen Tag.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/admin/app/(authed)/aktivitaetsfeed/page.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === "accepted") return "übernommen";
|
||||
if (status === "rejected") return "abgelehnt";
|
||||
if (status === "pending") return "in Prüfung";
|
||||
return status;
|
||||
}
|
||||
|
||||
export default function AktivitaetsfeedPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const feedQuery = trpc.health.adminActivityFeed.useQuery();
|
||||
const reviewStoreChange = trpc.health.reviewStoreChangeRequest.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.adminActivityFeed.invalidate();
|
||||
await utils.health.listStores.invalidate();
|
||||
},
|
||||
});
|
||||
const items = feedQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Activity Feed</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Eingereichte Rezeptvorschläge, Marktänderungen und weitere prüfpflichtige Aktionen an einem Ort.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={`${item.type}-${item.id}`} className="rounded-3xl bg-gray-50 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase text-[#0F766E]">
|
||||
{item.type === "recipe_submission" ? "Rezeptvorschlag" : "Marktänderung"}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-black text-gray-950">{item.title}</h2>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">{item.actorName}{item.actorEmail ? ` · ${item.actorEmail}` : ""}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-500">{item.detail}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-black ${
|
||||
item.status === "accepted"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: item.status === "rejected"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{statusLabel(item.status)}
|
||||
</span>
|
||||
<p className="mt-2 text-xs font-bold text-gray-400">{new Date(item.createdAt).toLocaleString("de-DE")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={item.href} className="rounded-full bg-white px-4 py-2 text-xs font-black text-gray-700">
|
||||
Öffnen
|
||||
</Link>
|
||||
{item.type === "store_change" && item.status === "pending" ? (
|
||||
<>
|
||||
<button type="button" onClick={() => reviewStoreChange.mutate({ id: item.id, action: "accept" })} className="rounded-full bg-[#0F766E] px-4 py-2 text-xs font-black text-white">
|
||||
Übernehmen
|
||||
</button>
|
||||
<button type="button" onClick={() => reviewStoreChange.mutate({ id: item.id, action: "reject" })} className="rounded-full bg-red-50 px-4 py-2 text-xs font-black text-red-700">
|
||||
Ablehnen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!items.length && !feedQuery.isLoading ? (
|
||||
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Aktuell gibt es keine Einträge.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
apps/admin/app/(authed)/benutzer/page.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import { PlusIcon, ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type Role = "user" | "admin";
|
||||
|
||||
export default function BenutzerPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const usersQuery = trpc.users.list.useQuery();
|
||||
const createUser = trpc.users.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setCreateForm({ name: "", email: "", password: "", globalRole: "user" });
|
||||
return utils.users.list.invalidate();
|
||||
},
|
||||
});
|
||||
const updateUser = trpc.users.update.useMutation({
|
||||
onSuccess: () => utils.users.list.invalidate(),
|
||||
});
|
||||
const startRecovery = trpc.users.startTwoFactorRecovery.useMutation({
|
||||
onSuccess: () => utils.users.list.invalidate(),
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
globalRole: "user" as Role,
|
||||
});
|
||||
|
||||
const users = usersQuery.data ?? [];
|
||||
const selectedUser = users.find((user) => user.id === selectedUserId) ?? users[0] ?? null;
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return users;
|
||||
return users.filter((user) =>
|
||||
[user.name, user.email, user.globalRole].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)),
|
||||
);
|
||||
}, [search, users]);
|
||||
|
||||
function submitCreate() {
|
||||
if (!createForm.name.trim() || !createForm.email.trim() || createForm.password.length < 16) return;
|
||||
createUser.mutate({
|
||||
name: createForm.name.trim(),
|
||||
email: createForm.email.trim(),
|
||||
password: createForm.password,
|
||||
globalRole: createForm.globalRole,
|
||||
});
|
||||
}
|
||||
|
||||
function updateRole(userId: string, role: Role) {
|
||||
const user = users.find((item) => item.id === userId);
|
||||
if (!user) return;
|
||||
updateUser.mutate({
|
||||
userId,
|
||||
name: user.name ?? user.email,
|
||||
email: user.email,
|
||||
globalRole: role,
|
||||
image: user.image ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Benutzerverwaltung</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Benutzer, Rollen und 2FA-Wiederherstellung für Hellth Hub.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1fr_0.75fr]">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Name, E-Mail oder Rolle suchen"
|
||||
className="w-full rounded-full border border-gray-200 px-4 py-3 text-sm font-semibold outline-none focus:border-[#0F766E]"
|
||||
/>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-3xl border border-gray-100">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 text-xs uppercase text-gray-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Benutzer</th>
|
||||
<th className="px-4 py-3">Rolle</th>
|
||||
<th className="px-4 py-3">2FA</th>
|
||||
<th className="px-4 py-3">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-3">
|
||||
<button type="button" onClick={() => setSelectedUserId(user.id)} className="text-left">
|
||||
<p className="font-black text-gray-950">{user.name ?? "Unbenannt"}</p>
|
||||
<p className="text-xs font-semibold text-gray-400">{user.email}</p>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={user.globalRole === "admin" ? "admin" : "user"}
|
||||
onChange={(event) => updateRole(user.id, event.target.value as Role)}
|
||||
className="rounded-full border border-gray-200 px-3 py-2 text-xs font-bold"
|
||||
>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold ${user.twoFactorEnabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
{user.twoFactorEnabled ? "aktiv" : "aus"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRecovery.mutate({ userId: user.id })}
|
||||
className="rounded-full bg-gray-100 px-3 py-2 text-xs font-black text-gray-700 disabled:opacity-40"
|
||||
disabled={startRecovery.isPending}
|
||||
>
|
||||
2FA zurücksetzen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!filteredUsers.length ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-sm font-bold text-gray-400">
|
||||
Keine Benutzer gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Neuen Benutzer anlegen</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input value={createForm.name} onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={createForm.email} onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))} placeholder="E-Mail" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input type="password" value={createForm.password} onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))} placeholder="Initiales Passwort, min. 16 Zeichen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<select value={createForm.globalRole} onChange={(event) => setCreateForm((current) => ({ ...current, globalRole: event.target.value as Role }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button type="button" onClick={submitCreate} disabled={createUser.isPending} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Benutzer anlegen
|
||||
</button>
|
||||
{createUser.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{createUser.error.message}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedUser ? (
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-teal-50 text-lg font-black text-[#0F766E]">
|
||||
{(selectedUser.name ?? selectedUser.email).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-black text-gray-950">{selectedUser.name ?? selectedUser.email}</h2>
|
||||
<p className="text-xs font-semibold text-gray-400">{selectedUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 text-sm font-bold text-gray-500">
|
||||
<p>Erstellt: {new Date(selectedUser.createdAt).toLocaleDateString("de-DE")}</p>
|
||||
<p>Letzte Aktivität: {selectedUser.lastActivityAt ? new Date(selectedUser.lastActivityAt).toLocaleString("de-DE") : "unbekannt"}</p>
|
||||
<p className="inline-flex items-center gap-2">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-[#0F766E]" />
|
||||
{selectedUser.twoFactorRecoveryRequired ? "2FA-Wiederherstellung offen" : "2FA-Status sauber"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
apps/admin/app/(authed)/einkaufsliste/page.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, ExclamationTriangleIcon, ShoppingCartIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { todayKey, weekDates } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
export default function EinkaufslistePage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedStoreId, setSelectedStoreId] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const days = weekDates(todayKey());
|
||||
const storesQuery = trpc.health.listStores.useQuery();
|
||||
const listsQuery = trpc.health.shoppingLists.useQuery({ limit: 5 });
|
||||
const generateMutation = trpc.health.generateShoppingList.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.shoppingLists.invalidate();
|
||||
setStatus("Einkaufsliste aus der aktuellen Wochenplanung erstellt.");
|
||||
},
|
||||
});
|
||||
const updateItemMutation = trpc.health.updateShoppingListItem.useMutation({
|
||||
onSuccess: () => utils.health.shoppingLists.invalidate(),
|
||||
});
|
||||
|
||||
const stores = storesQuery.data ?? [];
|
||||
const selectedStore = stores.find((store) => store.id === selectedStoreId) ?? stores.find((store) => store.favorite) ?? stores[0];
|
||||
const latestList = listsQuery.data?.[0];
|
||||
const items = useMemo(() => latestList?.items ?? [], [latestList]);
|
||||
const grouped = useMemo(() => {
|
||||
return items.reduce<Record<string, typeof items>>((groups, item) => {
|
||||
const key = item.category || "Sonstiges";
|
||||
groups[key] = [...(groups[key] ?? []), item];
|
||||
return groups;
|
||||
}, {});
|
||||
}, [items]);
|
||||
|
||||
function generateList() {
|
||||
generateMutation.mutate({
|
||||
startsOn: days[0]!,
|
||||
endsOn: days[6]!,
|
||||
storeId: selectedStore?.id ?? null,
|
||||
name: `Einkauf ${days[0]} bis ${days[6]}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShoppingCartIcon className="h-8 w-8 text-[#0F766E]" />
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
|
||||
<h1 className="text-4xl font-black tracking-normal text-gray-950">Einkaufsliste</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Persistiert in der Datenbank und direkt aus deiner Essensplanung generiert.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select
|
||||
value={selectedStoreId || selectedStore?.id || ""}
|
||||
onChange={(event) => setSelectedStoreId(event.target.value)}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-black"
|
||||
>
|
||||
{stores.map((store) => (
|
||||
<option key={store.id} value={store.id}>
|
||||
{store.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={generateList} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white">
|
||||
Aus Planung erstellen
|
||||
</button>
|
||||
</div>
|
||||
{latestList ? (
|
||||
<p className="mt-3 text-sm font-bold text-gray-400">
|
||||
Aktive Liste: {latestList.name} · {items.length} Artikel
|
||||
</p>
|
||||
) : null}
|
||||
{status ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
|
||||
</section>
|
||||
|
||||
{!latestList && !listsQuery.isLoading ? (
|
||||
<section className="rounded-[2rem] bg-amber-50 p-5 ring-1 ring-amber-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-amber-700" />
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-amber-950">Noch keine Einkaufsliste</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-amber-800">
|
||||
Plane zuerst Gerichte oder erstelle direkt eine Liste aus der aktuellen Woche.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([category, list]) => (
|
||||
<section key={category} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-400">{category}</h2>
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className="grid gap-3 py-4 sm:grid-cols-[56px_1fr_170px] sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItemMutation.mutate({ id: item.id, checked: !item.checked })}
|
||||
className={`grid h-12 w-12 place-items-center rounded-2xl ${item.checked ? "bg-green-500 text-white" : "bg-gray-100 text-gray-300"}`}
|
||||
aria-label="Gekauft umschalten"
|
||||
title="Gekauft umschalten"
|
||||
>
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div>
|
||||
<p className={`text-lg font-black ${item.checked ? "text-gray-300 line-through" : "text-gray-950"}`}>{item.name}</p>
|
||||
<p className="text-sm font-bold text-gray-400">Rezeptmenge: {Math.round(Number(item.amountG))} g</p>
|
||||
</div>
|
||||
<input
|
||||
value={item.purchaseAmount ?? ""}
|
||||
onChange={(event) => updateItemMutation.mutate({ id: item.id, purchaseAmount: event.target.value })}
|
||||
placeholder={`${Math.ceil(Number(item.amountG) / 100) * 100} g`}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
779
apps/admin/app/(authed)/einstellungen/page.tsx
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BeakerIcon,
|
||||
BellIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
FireIcon,
|
||||
MoonIcon,
|
||||
ScaleIcon,
|
||||
SunIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DataPageLayout } from "@/app/_components/data-page-layout";
|
||||
import { useTheme } from "@/app/_components/theme-provider";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { HellthGoals, loadHellthData } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
const PASSWORD_CHARS =
|
||||
"ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*";
|
||||
|
||||
const dietPreferenceOptions = [
|
||||
{ id: "vegan", label: "Vegan" },
|
||||
{ id: "vegetarian", label: "Vegetarisch" },
|
||||
{ id: "meat", label: "Fleisch" },
|
||||
{ id: "fish", label: "Fisch" },
|
||||
] as const;
|
||||
|
||||
type DietPreference = (typeof dietPreferenceOptions)[number]["id"];
|
||||
|
||||
function todayKey() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function generatePassword(length = 16) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
async function toOptimizedAvatarDataUrl(file: File) {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const maxEdge = 320;
|
||||
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas nicht verfügbar");
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/webp", 0.82);
|
||||
if (!dataUrl.startsWith("data:image/")) {
|
||||
throw new Error("Profilbild konnte nicht verarbeitet werden.");
|
||||
}
|
||||
if (dataUrl.length > 120_000) {
|
||||
throw new Error("Profilbild ist nach Komprimierung noch zu groß.");
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const { isDark, toggleDark } = useTheme();
|
||||
const utils = trpc.useUtils();
|
||||
const profileQuery = trpc.users.myProfile.useQuery();
|
||||
const securityQuery = trpc.security.status.useQuery();
|
||||
const healthOverviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
|
||||
const passkeysQuery = authClient.useListPasskeys();
|
||||
const userKey = profileQuery.data?.id ?? profileQuery.data?.email ?? null;
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [healthGoals, setHealthGoals] = useState<HellthGoals | null>(null);
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [street, setStreet] = useState("");
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>(["vegan", "vegetarian", "meat", "fish"]);
|
||||
const [mealsPerDay, setMealsPerDay] = useState("3");
|
||||
const [cooksPerDay, setCooksPerDay] = useState("1");
|
||||
const [preferredMealPrepDays, setPreferredMealPrepDays] = useState("0");
|
||||
const [meatFishFrequency, setMeatFishFrequency] = useState<"none" | "once_per_day" | "flexible">("flexible");
|
||||
|
||||
const isPlatformAdmin = profileQuery.data?.role === "admin";
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [newPasskeyName, setNewPasskeyName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (profileQuery.data) {
|
||||
setFirstName(profileQuery.data.firstName ?? profileQuery.data.name ?? "");
|
||||
setLastName(profileQuery.data.lastName ?? profileQuery.data.name ?? "");
|
||||
setPhone(profileQuery.data.phone ?? "");
|
||||
setMobile(profileQuery.data.mobile ?? "");
|
||||
setStreet(profileQuery.data.street ?? "");
|
||||
setPostalCode(profileQuery.data.postalCode ?? "");
|
||||
setCity(profileQuery.data.city ?? "");
|
||||
setImage(profileQuery.data.image ?? null);
|
||||
}
|
||||
}, [profileQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userKey) return;
|
||||
setHealthGoals(loadHellthData(userKey).goals);
|
||||
}, [userKey]);
|
||||
|
||||
const updateMyProfile = trpc.users.updateMyProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([utils.users.myProfile.invalidate(), utils.security.status.invalidate()]);
|
||||
setStatusMessage("Profil gespeichert.");
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message);
|
||||
setStatusMessage(null);
|
||||
},
|
||||
});
|
||||
|
||||
const changeMyPassword = trpc.users.changeMyPassword.useMutation({
|
||||
onSuccess: () => {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmNewPassword("");
|
||||
setStatusMessage("Passwort erfolgreich aktualisiert.");
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message);
|
||||
setStatusMessage(null);
|
||||
},
|
||||
});
|
||||
|
||||
const updateHealthProfile = trpc.health.upsertProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await healthOverviewQuery.refetch();
|
||||
await utils.health.recipeRecommendations.invalidate();
|
||||
setStatusMessage("Essenspräferenzen gespeichert.");
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error.message);
|
||||
setStatusMessage(null);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const profile = healthOverviewQuery.data?.profile;
|
||||
if (!profile) return;
|
||||
setDietPreferences((profile.dietPreferences?.length ? profile.dietPreferences : ["vegan", "vegetarian", "meat", "fish"]) as DietPreference[]);
|
||||
setMealsPerDay(String(profile.mealsPerDay ?? 3));
|
||||
setCooksPerDay(String(profile.cooksPerDay ?? 1));
|
||||
setPreferredMealPrepDays(String(profile.preferredMealPrepDays ?? 0));
|
||||
setMeatFishFrequency((profile.meatFishFrequency as "none" | "once_per_day" | "flexible") ?? "flexible");
|
||||
}, [healthOverviewQuery.data?.profile]);
|
||||
|
||||
function toggleDietPreference(preference: DietPreference) {
|
||||
setDietPreferences((current) => {
|
||||
const next = current.includes(preference) ? current.filter((item) => item !== preference) : [...current, preference];
|
||||
return next.length ? next : current;
|
||||
});
|
||||
}
|
||||
|
||||
function saveEatingPreferences() {
|
||||
const profile = healthOverviewQuery.data?.profile;
|
||||
updateHealthProfile.mutate({
|
||||
goal: profile?.goal ?? "healthier",
|
||||
age: profile?.age ?? null,
|
||||
heightCm: profile?.heightCm == null ? null : Number(profile.heightCm),
|
||||
currentWeightKg: profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg),
|
||||
targetWeightKg: profile?.targetWeightKg == null ? null : Number(profile.targetWeightKg),
|
||||
activityLevel: profile?.activityLevel ?? "moderate",
|
||||
dietStyle: profile?.dietStyle ?? "flexible",
|
||||
dietPreferences,
|
||||
mealsPerDay: Number(mealsPerDay) || 3,
|
||||
cooksPerDay: Number(cooksPerDay) || 1,
|
||||
preferredMealPrepDays: Number(preferredMealPrepDays) || 0,
|
||||
meatFishFrequency,
|
||||
cookingTime: (profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium",
|
||||
budgetFocus: profile?.budgetFocus ?? false,
|
||||
excludedFoods: profile?.excludedFoods ?? null,
|
||||
biggestChallenge: profile?.biggestChallenge ?? null,
|
||||
medicalNote: profile?.medicalNote ?? false,
|
||||
completed: Boolean(profile?.completedAt),
|
||||
});
|
||||
}
|
||||
|
||||
const isBusy = updateMyProfile.isPending || changeMyPassword.isPending;
|
||||
const passwordMismatch =
|
||||
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
|
||||
const canSaveProfile = useMemo(
|
||||
() =>
|
||||
!!firstName.trim() &&
|
||||
firstName.trim().length >= 2 &&
|
||||
(firstName.trim() !== (profileQuery.data?.firstName ?? profileQuery.data?.name ?? "") ||
|
||||
(lastName.trim() || null) !== (profileQuery.data?.lastName ?? null) ||
|
||||
(phone.trim() || null) !== (profileQuery.data?.phone ?? null) ||
|
||||
(mobile.trim() || null) !== (profileQuery.data?.mobile ?? null) ||
|
||||
(street.trim() || null) !== (profileQuery.data?.street ?? null) ||
|
||||
(postalCode.trim() || null) !== (profileQuery.data?.postalCode ?? null) ||
|
||||
(city.trim() || null) !== (profileQuery.data?.city ?? null) ||
|
||||
(image ?? null) !== (profileQuery.data?.image ?? null)),
|
||||
[
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
mobile,
|
||||
street,
|
||||
postalCode,
|
||||
city,
|
||||
image,
|
||||
profileQuery.data?.firstName,
|
||||
profileQuery.data?.name,
|
||||
profileQuery.data?.lastName,
|
||||
profileQuery.data?.phone,
|
||||
profileQuery.data?.mobile,
|
||||
profileQuery.data?.street,
|
||||
profileQuery.data?.postalCode,
|
||||
profileQuery.data?.city,
|
||||
profileQuery.data?.image,
|
||||
],
|
||||
);
|
||||
|
||||
async function handleAddPasskey() {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: newPasskeyName.trim() || undefined,
|
||||
});
|
||||
if (result.error) {
|
||||
setErrorMessage(result.error.message ?? "Passkey konnte nicht hinzugefügt werden.");
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Passkey erfolgreich hinzugefügt.");
|
||||
setNewPasskeyName("");
|
||||
await passkeysQuery.refetch();
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setErrorMessage("Bitte eine Bilddatei auswählen.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setErrorMessage("Profilbild ist zu groß. Maximal 5 MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await toOptimizedAvatarDataUrl(file);
|
||||
setImage(dataUrl);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Profilbild konnte nicht verarbeitet werden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DataPageLayout title="Einstellungen" description="Persönliches Profil und Sicherheit">
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-400">Ziele</h2>
|
||||
<div className="mt-5 divide-y divide-gray-100">
|
||||
{[
|
||||
{ icon: FireIcon, label: "Kalorien", value: `${healthGoals?.calories ?? 2000} kcal`, color: "text-orange-500" },
|
||||
{ icon: ScaleIcon, label: "Makros", value: `${healthGoals?.carbs ?? 200}/${healthGoals?.fat ?? 67}/${healthGoals?.protein ?? 150} g`, color: "text-blue-500" },
|
||||
{ icon: ClockIcon, label: "Fasten", value: `${healthGoals?.fastingHours ?? 12} h`, color: "text-rose-500" },
|
||||
{ icon: BeakerIcon, label: "Wasser", value: `${healthGoals?.drinkMl ?? 2500} ml`, color: "text-blue-500" },
|
||||
{ icon: ScaleIcon, label: "Gewicht", value: `${healthGoals?.weightKg ?? 120} kg`, color: "text-orange-500" },
|
||||
].map((item) => (
|
||||
<Link key={item.label} href="/ziele" className="flex w-full items-center gap-4 py-4 text-left">
|
||||
<item.icon className={`h-7 w-7 ${item.color}`} />
|
||||
<span className="flex-1 text-lg font-black text-gray-950">{item.label}</span>
|
||||
<span className="text-lg font-black text-gray-400">{item.value}</span>
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-300" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-400">Rezept-Präferenzen</h2>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-gray-400">Ernährung</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{dietPreferenceOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => toggleDietPreference(option.id)}
|
||||
className={`rounded-full px-3 py-2 text-sm font-black ${
|
||||
dietPreferences.includes(option.id) ? "bg-[#0F766E] text-white" : "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Mahlzeiten</span>
|
||||
<input value={mealsPerDay} onChange={(event) => setMealsPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
|
||||
<input value={cooksPerDay} onChange={(event) => setCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Mealprep</span>
|
||||
<input value={preferredMealPrepDays} onChange={(event) => setPreferredMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Fleisch/Fisch</span>
|
||||
<select
|
||||
value={meatFishFrequency}
|
||||
onChange={(event) => setMeatFishFrequency(event.target.value as "none" | "once_per_day" | "flexible")}
|
||||
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold"
|
||||
>
|
||||
<option value="none">nie</option>
|
||||
<option value="once_per_day">maximal 1x am Tag</option>
|
||||
<option value="flexible">flexibel</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEatingPreferences}
|
||||
disabled={updateHealthProfile.isPending}
|
||||
className="w-full rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
Präferenzen speichern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-400">Anwendung</h2>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<BellIcon className="h-7 w-7 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-lg font-black text-gray-950">Tägliche Erinnerung</p>
|
||||
<p className="text-sm font-bold text-gray-400">Tracking und Planung</p>
|
||||
</div>
|
||||
<span className="relative inline-flex h-8 w-14 rounded-full bg-green-500">
|
||||
<span className="absolute right-1 top-1 h-6 w-6 rounded-full bg-white" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isDark ? <MoonIcon className="h-7 w-7 text-indigo-400" /> : <SunIcon className="h-7 w-7 text-amber-500" />}
|
||||
<div className="flex-1">
|
||||
<p className="text-lg font-black text-gray-950">Dunkles Design</p>
|
||||
<p className="text-sm font-bold text-gray-400">{isDark ? "aktiv" : "inaktiv"}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDark}
|
||||
className={`relative inline-flex h-8 w-14 rounded-full ${isDark ? "bg-[#0F766E]" : "bg-gray-200"}`}
|
||||
aria-label="Dunkles Design umschalten"
|
||||
>
|
||||
<span className={`absolute top-1 h-6 w-6 rounded-full bg-white transition ${isDark ? "right-1" : "left-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<section className="lg:col-span-2 bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#333333]">Mein Profil</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Du kannst dein eigenes Profil bearbeiten. Höhere Admins können dich zusätzlich in
|
||||
der Benutzerverwaltung bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Name</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={firstName}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
placeholder="Vorname"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Nachname</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={lastName}
|
||||
onChange={(event) => setLastName(event.target.value)}
|
||||
placeholder="Nachname"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Profilbild</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 border border-gray-200 overflow-hidden shrink-0">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="Profilbild" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm text-gray-400">
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="inline-flex px-3 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm cursor-pointer">
|
||||
Bild hochladen
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void handleImageUpload(file);
|
||||
}
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Bild wird automatisch skaliert und komprimiert.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImage(null)}
|
||||
className="block text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">E-Mail</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm bg-gray-50 text-gray-500"
|
||||
value={profileQuery.data?.email ?? ""}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Telefon</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="Telefon"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Mobil</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(event.target.value)}
|
||||
placeholder="Mobil"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Straße</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={street}
|
||||
onChange={(event) => setStreet(event.target.value)}
|
||||
placeholder="Straße und Hausnummer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">PLZ</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={postalCode}
|
||||
onChange={(event) => setPostalCode(event.target.value)}
|
||||
placeholder="PLZ"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Ort</label>
|
||||
<input
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
value={city}
|
||||
onChange={(event) => setCity(event.target.value)}
|
||||
placeholder="Ort"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusMessage(null);
|
||||
setErrorMessage(null);
|
||||
updateMyProfile.mutate({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
mobile: mobile.trim() || null,
|
||||
street: street.trim() || null,
|
||||
postalCode: postalCode.trim() || null,
|
||||
city: city.trim() || null,
|
||||
image,
|
||||
});
|
||||
}}
|
||||
disabled={isBusy || !canSaveProfile}
|
||||
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Profil speichern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-[#333333]">Sicherheit</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
2FA Status:{" "}
|
||||
<strong>{securityQuery.data?.twoFactorEnabled ? "aktiv" : "nicht aktiv"}</strong>
|
||||
</p>
|
||||
{securityQuery.data?.twoFactorRecoveryRequired && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
2FA Recovery aktiv: Bitte OTP neu einrichten.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/sicherheit/2fa-setup"
|
||||
className="inline-flex px-4 py-2 rounded-lg bg-gray-900 text-white text-sm"
|
||||
>
|
||||
2FA verwalten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Passkeys</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Passkeys erlauben die Anmeldung per Gerät (z. B. Windows Hello, Touch ID oder
|
||||
Passwortmanager).
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPasskeyName}
|
||||
onChange={(event) => setNewPasskeyName(event.target.value)}
|
||||
placeholder="Name (optional), z. B. Laptop"
|
||||
className="flex-1 border border-gray-200 rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAddPasskey()}
|
||||
disabled={isBusy}
|
||||
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
Passkey hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{passkeysQuery.isPending && (
|
||||
<p className="text-xs text-gray-500">Passkeys werden geladen...</p>
|
||||
)}
|
||||
{passkeysQuery.error && (
|
||||
<p className="text-xs text-red-600">
|
||||
{passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."}
|
||||
</p>
|
||||
)}
|
||||
{!passkeysQuery.isPending &&
|
||||
!passkeysQuery.error &&
|
||||
(passkeysQuery.data?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-gray-500">Noch keine Passkeys hinterlegt.</p>
|
||||
)}
|
||||
{(passkeysQuery.data ?? []).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700"
|
||||
>
|
||||
<p className="font-medium text-gray-800">{item.name || "Unbenannter Passkey"}</p>
|
||||
<p className="text-gray-500">Erstellt: {new Date(item.createdAt).toLocaleString("de-DE")}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Passwort zurücksetzen</h3>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 pr-11 text-sm"
|
||||
placeholder="Aktuelles Passwort"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword((value) => !value)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={showCurrentPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeSlashIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 pr-11 text-sm"
|
||||
placeholder="Neues Passwort (mind. 16 Zeichen)"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((value) => !value)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={showNewPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeSlashIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
className={`w-full border rounded-lg px-3 py-2.5 pr-11 text-sm ${
|
||||
passwordMismatch ? "border-red-300" : "border-gray-200"
|
||||
}`}
|
||||
placeholder="Neues Passwort bestätigen"
|
||||
value={confirmNewPassword}
|
||||
onChange={(event) => setConfirmNewPassword(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((value) => !value)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={showConfirmPassword ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlashIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordMismatch && (
|
||||
<p className="text-xs text-red-600">Die neuen Passwörter stimmen nicht überein.</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const generated = generatePassword(16);
|
||||
setNewPassword(generated);
|
||||
setConfirmNewPassword(generated);
|
||||
setShowNewPassword(true);
|
||||
setShowConfirmPassword(true);
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm w-fit"
|
||||
>
|
||||
Passwort generieren (16)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusMessage(null);
|
||||
setErrorMessage(null);
|
||||
changeMyPassword.mutate({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!currentPassword ||
|
||||
newPassword.length < 16 ||
|
||||
confirmNewPassword.length < 16 ||
|
||||
passwordMismatch
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Passwort aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lg:col-span-3 bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#333333]">Darstellung</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Erscheinungsbild der Anwendung anpassen.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{isDark ? (
|
||||
<MoonIcon className="h-5 w-5 text-indigo-400" />
|
||||
) : (
|
||||
<SunIcon className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Dunkles Design</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDark}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
|
||||
isDark ? "bg-[#0F766E]" : "bg-gray-200"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Dunkles Design umschalten"
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
|
||||
isDark ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{profileQuery.isLoading && (
|
||||
<div className="mt-6 text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-500">
|
||||
Profil wird geladen...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(profileQuery.error || securityQuery.error) && (
|
||||
<div className="mt-6 text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
||||
{profileQuery.error?.message ?? securityQuery.error?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<div className="mt-6 text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-700">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mt-6 text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</DataPageLayout>
|
||||
);
|
||||
}
|
||||
169
apps/admin/app/(authed)/einstellungen/punkte/page.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type TemplateDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
defaultMinutes: number;
|
||||
defaultCalories: number;
|
||||
defaultPoints: string;
|
||||
};
|
||||
|
||||
function numberInput(value: unknown) {
|
||||
return String(Number(value) || 0);
|
||||
}
|
||||
|
||||
export default function PunkteEinstellungenPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const settingsQuery = trpc.health.pointSettings.useQuery();
|
||||
const [recipeSubmissionPoints, setRecipeSubmissionPoints] = useState("25");
|
||||
const [clubActivityDefaultPoints, setClubActivityDefaultPoints] = useState("100");
|
||||
const [templates, setTemplates] = useState<TemplateDraft[]>([]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const updateMutation = trpc.health.updatePointSettings.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.health.pointSettings.invalidate(),
|
||||
utils.health.pointDefaults.invalidate(),
|
||||
utils.health.listActivityTemplates.invalidate(),
|
||||
]);
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1600);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsQuery.data) return;
|
||||
setRecipeSubmissionPoints(numberInput(settingsQuery.data.recipeSubmissionPoints));
|
||||
setClubActivityDefaultPoints(numberInput(settingsQuery.data.clubActivityDefaultPoints));
|
||||
setTemplates(settingsQuery.data.activityTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
defaultMinutes: template.defaultMinutes,
|
||||
defaultCalories: template.defaultCalories,
|
||||
defaultPoints: numberInput(template.defaultPoints),
|
||||
})));
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups = new Map<string, TemplateDraft[]>();
|
||||
for (const template of templates) {
|
||||
const current = groups.get(template.category) ?? [];
|
||||
current.push(template);
|
||||
groups.set(template.category, current);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "de-DE"));
|
||||
}, [templates]);
|
||||
|
||||
function updateTemplate(id: string, value: string) {
|
||||
setTemplates((current) => current.map((template) => (
|
||||
template.id === id ? { ...template, defaultPoints: value } : template
|
||||
)));
|
||||
}
|
||||
|
||||
function save() {
|
||||
updateMutation.mutate({
|
||||
recipeSubmissionPoints: Number(recipeSubmissionPoints) || 0,
|
||||
clubActivityDefaultPoints: Number(clubActivityDefaultPoints) || 0,
|
||||
activityTemplates: templates.map((template) => ({
|
||||
id: template.id,
|
||||
defaultPoints: Number(template.defaultPoints) || 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Punktevergabe</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Globale Punktwerte für Hellfireclub, Rezeptvorschläge und Aktivitätskatalog.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Systempunkte</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="grid gap-2">
|
||||
<span className="text-sm font-black text-gray-500">Rezeptvorschlag einreichen</span>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeSubmissionPoints}
|
||||
onChange={(event) => setRecipeSubmissionPoints(event.target.value)}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2">
|
||||
<span className="text-sm font-black text-gray-500">Hellfireclub-Aktivität erstellen, Default</span>
|
||||
<input
|
||||
type="number"
|
||||
value={clubActivityDefaultPoints}
|
||||
onChange={(event) => setClubActivityDefaultPoints(event.target.value)}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Hinweis</h2>
|
||||
<p className="mt-4 text-sm font-semibold text-gray-500">
|
||||
Bestehende Punkte-Logs bleiben unverändert. Änderungen gelten für neue Aktivitäten,
|
||||
neue Rezeptvorschläge und neue Einträge aus dem Aktivitätskatalog.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={updateMutation.isPending}
|
||||
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
Punkte speichern
|
||||
</button>
|
||||
{saved ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">Punkte gespeichert.</p> : null}
|
||||
{updateMutation.error ? <p className="mt-3 rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{updateMutation.error.message}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Aktivitätskatalog</h2>
|
||||
<div className="mt-5 grid gap-5">
|
||||
{groupedTemplates.map(([category, entries]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-black uppercase text-gray-400">{category}</h3>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{entries.map((template) => (
|
||||
<div key={template.id} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[1fr_120px_120px_130px] md:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-950">{template.name}</p>
|
||||
<p className="text-xs font-bold text-gray-400">{template.defaultMinutes} min · {template.defaultCalories} kcal</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-400">{template.id}</span>
|
||||
<span className="text-xs font-bold text-gray-400">{template.category}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={template.defaultPoints}
|
||||
onChange={(event) => updateTemplate(template.id, event.target.value)}
|
||||
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!templates.length && !settingsQuery.isLoading ? (
|
||||
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Keine globalen Aktivitätsvorlagen gefunden.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
apps/admin/app/(authed)/erinnerungen/page.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
FireIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import {
|
||||
ReminderKind,
|
||||
ReminderRule,
|
||||
ReminderSettings,
|
||||
defaultReminderSettings,
|
||||
loadHellthData,
|
||||
saveHellthData,
|
||||
} from "@/lib/hellth-data";
|
||||
|
||||
const reminderMeta: Record<
|
||||
ReminderKind,
|
||||
{ title: string; description: string; icon: typeof BeakerIcon; color: string }
|
||||
> = {
|
||||
water: {
|
||||
title: "Wasser trinken",
|
||||
description: "Regelmäßig kleine Trinkimpulse statt abends alles nachholen.",
|
||||
icon: BeakerIcon,
|
||||
color: "text-blue-500",
|
||||
},
|
||||
snack: {
|
||||
title: "Snack prüfen",
|
||||
description: "Hilft, geplante Snacks bewusst zu essen und ungeplantes Snacken zu vermeiden.",
|
||||
icon: FireIcon,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
movement: {
|
||||
title: "Kurz bewegen",
|
||||
description: "Steh vom Schreibtisch auf, streck dich oder geh ein paar Schritte.",
|
||||
icon: ClockIcon,
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
};
|
||||
|
||||
function permissionLabel(permission: NotificationPermission | "unsupported") {
|
||||
if (permission === "granted") return "Benachrichtigungen sind erlaubt.";
|
||||
if (permission === "denied") return "Benachrichtigungen sind im Browser blockiert.";
|
||||
if (permission === "unsupported") return "Dieser Browser unterstützt keine Web-Benachrichtigungen.";
|
||||
return "Benachrichtigungen sind noch nicht aktiviert.";
|
||||
}
|
||||
|
||||
export default function ErinnerungenPage() {
|
||||
const { data: session } = useSession();
|
||||
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||||
const [settings, setSettings] = useState<ReminderSettings>(defaultReminderSettings);
|
||||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("default");
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userKey) return;
|
||||
setSettings(loadHellthData(userKey).reminders);
|
||||
setPermission(typeof Notification === "undefined" ? "unsupported" : Notification.permission);
|
||||
}, [userKey]);
|
||||
|
||||
function persist(nextSettings: ReminderSettings) {
|
||||
if (!userKey) return;
|
||||
setSettings(nextSettings);
|
||||
const data = loadHellthData(userKey);
|
||||
saveHellthData({ ...data, reminders: nextSettings }, userKey);
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1500);
|
||||
}
|
||||
|
||||
function updateRule(kind: ReminderKind, patch: Partial<ReminderRule>) {
|
||||
persist({
|
||||
...settings,
|
||||
rules: {
|
||||
...settings.rules,
|
||||
[kind]: { ...settings.rules[kind], ...patch },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function requestPermission() {
|
||||
if (typeof Notification === "undefined") {
|
||||
setPermission("unsupported");
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
persist({
|
||||
...settings,
|
||||
enabled: result === "granted" ? settings.enabled : false,
|
||||
permissionRequestedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTest(kind: ReminderKind) {
|
||||
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||||
const rule = settings.rules[kind];
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(`Hellth Hub: ${reminderMeta[kind].title}`, {
|
||||
body: rule.message,
|
||||
tag: `hellth-test-${kind}`,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to regular notification.
|
||||
}
|
||||
}
|
||||
new Notification(`Hellth Hub: ${reminderMeta[kind].title}`, { body: rule.message });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Coach Impulse</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Erinnerungen</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Kleine Push-Impulse für Wasser, geplante Snacks und Bewegung im Arbeitsalltag.
|
||||
</p>
|
||||
</div>
|
||||
<BellAlertIcon className="h-10 w-10 text-[#0F766E]" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">Benachrichtigungen</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">{permissionLabel(permission)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void requestPermission()}
|
||||
className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white"
|
||||
>
|
||||
Berechtigung anfragen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={permission !== "granted"}
|
||||
onClick={() => persist({ ...settings, enabled: !settings.enabled })}
|
||||
className={`rounded-full px-5 py-3 text-sm font-black disabled:opacity-40 ${
|
||||
settings.enabled ? "bg-emerald-600 text-white" : "bg-gray-100 text-gray-950"
|
||||
}`}
|
||||
>
|
||||
{settings.enabled ? "Aktiv" : "Pausiert"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{(Object.keys(settings.rules) as ReminderKind[]).map((kind) => {
|
||||
const meta = reminderMeta[kind];
|
||||
const rule = settings.rules[kind];
|
||||
return (
|
||||
<section key={kind} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<meta.icon className={`h-8 w-8 ${meta.color}`} />
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">{meta.title}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-5 flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
|
||||
Aktivieren
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
onChange={(event) => updateRule(kind, { enabled: event.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<label className="text-xs font-black uppercase text-gray-400">
|
||||
Von
|
||||
<input
|
||||
type="time"
|
||||
value={rule.startTime}
|
||||
onChange={(event) => updateRule(kind, { startTime: event.target.value })}
|
||||
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-black uppercase text-gray-400">
|
||||
Bis
|
||||
<input
|
||||
type="time"
|
||||
value={rule.endTime}
|
||||
onChange={(event) => updateRule(kind, { endTime: event.target.value })}
|
||||
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
|
||||
Intervall Minuten
|
||||
<input
|
||||
type="number"
|
||||
min={15}
|
||||
step={15}
|
||||
value={rule.intervalMinutes}
|
||||
onChange={(event) => updateRule(kind, { intervalMinutes: Number(event.target.value) || 60 })}
|
||||
className="mt-1 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-950"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block text-xs font-black uppercase text-gray-400">
|
||||
Nachricht
|
||||
<textarea
|
||||
value={rule.message}
|
||||
onChange={(event) => updateRule(kind, { message: event.target.value })}
|
||||
className="mt-1 min-h-24 w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-semibold normal-case text-gray-950"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={permission !== "granted"}
|
||||
onClick={() => void sendTest(kind)}
|
||||
className="mt-4 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{saved ? (
|
||||
<div className="fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
Gespeichert
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
apps/admin/app/(authed)/gewicht/page.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowTrendingDownIcon,
|
||||
CalendarDaysIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { todayKey } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
function formatDate(dateKey: string) {
|
||||
return new Date(`${dateKey}T12:00:00`).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function WeightChart({ entries }: { entries: Array<{ id?: string; date: string; weightKg: number }> }) {
|
||||
if (entries.length < 2) {
|
||||
return (
|
||||
<div className="grid min-h-56 place-items-center rounded-[2rem] bg-gray-50 p-6 text-center text-sm font-bold text-gray-400">
|
||||
Noch zu wenig Messpunkte für einen Verlauf.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const min = Math.min(...entries.map((entry) => entry.weightKg));
|
||||
const max = Math.max(...entries.map((entry) => entry.weightKg));
|
||||
const range = Math.max(0.5, max - min);
|
||||
|
||||
return (
|
||||
<div className="mt-5 flex min-h-64 items-end gap-2 rounded-[2rem] bg-gray-50 p-4">
|
||||
{entries.map((entry) => {
|
||||
const height = 18 + ((entry.weightKg - min) / range) * 76;
|
||||
return (
|
||||
<div key={entry.id ?? entry.date} className="flex min-w-8 flex-1 flex-col items-center justify-end gap-2">
|
||||
<div className="w-full rounded-full bg-[#0F766E]" style={{ height: `${height}%` }} title={`${entry.weightKg} kg`} />
|
||||
<p className="text-[10px] font-bold text-gray-400">{entry.date.slice(5)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GewichtPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [date, setDate] = useState(todayKey());
|
||||
const [weightKg, setWeightKg] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const historyQuery = trpc.health.weightHistory.useQuery({ limit: 120 });
|
||||
const saveMutation = trpc.health.upsertWeightLog.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.health.weightHistory.invalidate(),
|
||||
utils.health.statsSummary.invalidate(),
|
||||
utils.health.overview.invalidate({ date }),
|
||||
]);
|
||||
setWeightKg("");
|
||||
setNote("");
|
||||
setStatus("Gewicht gespeichert.");
|
||||
},
|
||||
});
|
||||
const deleteMutation = trpc.health.deleteWeightLog.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.health.weightHistory.invalidate(),
|
||||
utils.health.statsSummary.invalidate(),
|
||||
]);
|
||||
setStatus("Eintrag entfernt.");
|
||||
},
|
||||
});
|
||||
|
||||
const history = historyQuery.data;
|
||||
const entries = history?.entries ?? [];
|
||||
const latest = history?.latest;
|
||||
const sortedRecent = useMemo(() => [...entries].reverse(), [entries]);
|
||||
|
||||
function saveWeight() {
|
||||
const value = Number(weightKg.replace(",", "."));
|
||||
if (!value || value <= 0) return;
|
||||
saveMutation.mutate({
|
||||
date,
|
||||
weightKg: value,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-emerald-300">Gewicht</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal">Verlauf statt Tageslaune</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
|
||||
Einzelne Werte schwanken. Hellth Hub schaut auf den Trend und macht Prognosen erst, wenn genug echte Daten da sind.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-28 w-28 place-items-center rounded-full bg-emerald-500 text-white ring-[12px] ring-emerald-500/20">
|
||||
<ScaleIcon className="h-14 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1fr_0.75fr]">
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">Gewichtshistorie</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">{entries.length} Messpunkt(e) in der Datenbank</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-600">
|
||||
Ziel: {history?.targetWeightKg ? `${history.targetWeightKg} kg` : "nicht gesetzt"}
|
||||
</div>
|
||||
</div>
|
||||
<WeightChart entries={entries} />
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<PlusIcon className="h-7 w-7 text-[#0F766E]" />
|
||||
<h2 className="text-xl font-black text-gray-950">Messwert eintragen</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={weightKg} onChange={(event) => setWeightKg(event.target.value)} placeholder={latest ? `${latest.weightKg} kg` : "Gewicht kg"} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<textarea value={note} onChange={(event) => setNote(event.target.value)} placeholder="Notiz optional" className="min-h-20 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<button type="button" onClick={saveWeight} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
|
||||
Speichern
|
||||
</button>
|
||||
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowTrendingDownIcon className="h-7 w-7 text-emerald-600" />
|
||||
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-semibold text-gray-500">{history?.projectionMessage ?? "Noch keine Prognose verfügbar."}</p>
|
||||
{history?.targetDate ? (
|
||||
<p className="mt-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-black text-emerald-800">
|
||||
Hochrechnung Zieltermin: {formatDate(history.targetDate)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-xs font-bold text-gray-400">
|
||||
Vertrauen: {history?.confidence === "medium" ? "mittel" : history?.confidence === "low" ? "niedrig" : "noch nicht genug Daten"}
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-gray-400">Aktuell</p>
|
||||
<p className="mt-4 text-4xl font-black text-gray-950">{latest ? `${latest.weightKg} kg` : "-"}</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-gray-400">Seit Start</p>
|
||||
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromFirst} kg` : "-"}</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-gray-400">Letzte Änderung</p>
|
||||
<p className="mt-4 text-4xl font-black text-gray-950">{history ? `${history.deltaFromPrevious} kg` : "-"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDaysIcon className="h-7 w-7 text-gray-400" />
|
||||
<h2 className="text-xl font-black text-gray-950">Messwerte</h2>
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{sortedRecent.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-4 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-lg font-black text-gray-950">{entry.weightKg} kg</p>
|
||||
<p className="text-sm font-bold text-gray-400">
|
||||
{formatDate(entry.date)}
|
||||
{entry.note ? ` · ${entry.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => deleteMutation.mutate({ id: entry.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Löschen" title="Löschen">
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!entries.length ? <p className="py-5 text-sm font-bold text-gray-400">Noch keine Messwerte gespeichert.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
apps/admin/app/(authed)/hellfireclub/page.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BoltIcon,
|
||||
CheckCircleIcon,
|
||||
FireIcon,
|
||||
GlobeAltIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
PlusIcon,
|
||||
TrophyIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ClubActivityCategory,
|
||||
ClubScope,
|
||||
GermanState,
|
||||
clubCategories,
|
||||
defaultClubProfile,
|
||||
germanStates,
|
||||
todayKey,
|
||||
} from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type ClubForm = {
|
||||
title: string;
|
||||
category: ClubActivityCategory;
|
||||
scope: ClubScope;
|
||||
date: string;
|
||||
time: string;
|
||||
durationMinutes: string;
|
||||
points: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type ClubProfileView = {
|
||||
userId: string;
|
||||
state: string;
|
||||
city?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
privacyMode: "city" | "state";
|
||||
};
|
||||
|
||||
const initialForm: ClubForm = {
|
||||
title: "",
|
||||
category: "walk",
|
||||
scope: "state",
|
||||
date: todayKey(),
|
||||
time: "18:00",
|
||||
durationMinutes: "60",
|
||||
points: "100",
|
||||
note: "",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function categoryLabel(category: string) {
|
||||
return clubCategories.find((item) => item.key === category)?.label ?? "Sonstiges";
|
||||
}
|
||||
|
||||
function categoryStyle(category: string) {
|
||||
if (category === "walk") return "bg-emerald-50 text-emerald-700";
|
||||
if (category === "run") return "bg-orange-50 text-orange-700";
|
||||
if (category === "mobility") return "bg-blue-50 text-blue-700";
|
||||
if (category === "strength") return "bg-purple-50 text-purple-700";
|
||||
if (category === "bike") return "bg-cyan-50 text-cyan-700";
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
|
||||
function scopeLabel(scope: ClubScope) {
|
||||
if (scope === "local") return "Lokal";
|
||||
if (scope === "state") return "Bundesland";
|
||||
return "Deutschlandweit";
|
||||
}
|
||||
|
||||
function distanceKm(
|
||||
profile: { latitude?: number | null; longitude?: number | null },
|
||||
activity: { latitude?: number | null; longitude?: number | null },
|
||||
) {
|
||||
if (
|
||||
profile.latitude === undefined ||
|
||||
profile.latitude === null ||
|
||||
profile.longitude === undefined ||
|
||||
profile.longitude === null ||
|
||||
activity.latitude === undefined ||
|
||||
activity.latitude === null ||
|
||||
activity.longitude === undefined ||
|
||||
activity.longitude === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
const earthKm = 6371;
|
||||
const deltaLat = toRad(activity.latitude - profile.latitude);
|
||||
const deltaLon = toRad(activity.longitude - profile.longitude);
|
||||
const lat1 = toRad(profile.latitude);
|
||||
const lat2 = toRad(activity.latitude);
|
||||
const haversine =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
||||
return Math.round(earthKm * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)));
|
||||
}
|
||||
|
||||
function locationLabel(profile: { state: string; city?: string | null; privacyMode: string }, scope: ClubScope) {
|
||||
if (scope === "nationwide") return "Deutschlandweit";
|
||||
if (scope === "state" || profile.privacyMode === "state") return profile.state;
|
||||
return profile.city?.trim() || profile.state;
|
||||
}
|
||||
|
||||
export default function HellfireclubPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState<ClubActivityCategory | "all">("all");
|
||||
const [scope, setScope] = useState<ClubScope>("state");
|
||||
const [form, setForm] = useState<ClubForm>(initialForm);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [locationStatus, setLocationStatus] = useState<string | null>(null);
|
||||
|
||||
const profileQuery = trpc.health.clubProfile.useQuery();
|
||||
const activitiesQuery = trpc.health.clubActivities.useQuery({});
|
||||
const pointDefaultsQuery = trpc.health.pointDefaults.useQuery();
|
||||
const leaderboardQuery = trpc.health.clubLeaderboard.useQuery({ days: 90 });
|
||||
const upsertProfileMutation = trpc.health.upsertClubProfile.useMutation({
|
||||
onSuccess: () => utils.health.clubProfile.invalidate(),
|
||||
});
|
||||
const createActivityMutation = trpc.health.createClubActivity.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.clubActivities.invalidate();
|
||||
setForm(initialForm);
|
||||
setStatus("Aktivität erstellt. Andere User können jetzt passend zu deinem Gebiet mitmachen.");
|
||||
},
|
||||
});
|
||||
const joinActivityMutation = trpc.health.joinClubActivity.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.clubActivities.invalidate();
|
||||
setStatus("Du bist dabei. Die Aktivität ist in deinem Hellfireclub vorgemerkt.");
|
||||
},
|
||||
});
|
||||
const completeActivityMutation = trpc.health.completeClubActivity.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.health.clubActivities.invalidate(),
|
||||
utils.health.clubLeaderboard.invalidate(),
|
||||
utils.health.activityWeek.invalidate(),
|
||||
utils.health.overview.invalidate(),
|
||||
]);
|
||||
setStatus("Punkte gutgeschrieben und als Aktivität gespeichert.");
|
||||
},
|
||||
});
|
||||
|
||||
const rawProfile = profileQuery.data;
|
||||
const clubActivityDefaultPoints = pointDefaultsQuery.data?.clubActivityDefaultPoints ?? 100;
|
||||
const profile: ClubProfileView = {
|
||||
userId: rawProfile?.userId ?? "",
|
||||
state: rawProfile?.state ?? defaultClubProfile.state,
|
||||
city: rawProfile?.city ?? defaultClubProfile.city,
|
||||
latitude: rawProfile?.latitude == null ? null : Number(rawProfile.latitude),
|
||||
longitude: rawProfile?.longitude == null ? null : Number(rawProfile.longitude),
|
||||
privacyMode: (rawProfile?.privacyMode ?? defaultClubProfile.privacyMode) as "city" | "state",
|
||||
};
|
||||
const activities = activitiesQuery.data ?? [];
|
||||
|
||||
function updateProfile(patch: Partial<ClubProfileView>) {
|
||||
const next = { ...profile, ...patch };
|
||||
upsertProfileMutation.mutate({
|
||||
state: next.state,
|
||||
city: next.city ?? null,
|
||||
latitude: next.latitude == null ? null : Number(next.latitude),
|
||||
longitude: next.longitude == null ? null : Number(next.longitude),
|
||||
privacyMode: next.privacyMode as "city" | "state",
|
||||
});
|
||||
}
|
||||
|
||||
function requestLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
setLocationStatus("Dein Browser unterstützt keine Standortfreigabe.");
|
||||
return;
|
||||
}
|
||||
setLocationStatus("Standort wird abgefragt...");
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
updateProfile({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
setLocationStatus("Standort gespeichert. Sichtbar bleibt nur Stadt oder Bundesland.");
|
||||
},
|
||||
() => setLocationStatus("Standort konnte nicht gelesen werden. Bundesland-Filter funktioniert weiterhin."),
|
||||
{ enableHighAccuracy: false, maximumAge: 1000 * 60 * 30, timeout: 8000 },
|
||||
);
|
||||
}
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
const activityScope = (activity.scope ?? "nationwide") as ClubScope;
|
||||
if (scope === "nationwide" || activityScope === "nationwide") return true;
|
||||
if (scope === "state") return activity.state === profile.state;
|
||||
if (activity.state !== profile.state) return false;
|
||||
const distance = distanceKm(profile, activity);
|
||||
if (distance !== undefined) return distance <= 30;
|
||||
return Boolean(activity.city && profile.city && activity.city.toLowerCase() === profile.city.toLowerCase());
|
||||
})
|
||||
.filter((activity) => category === "all" || activity.category === category)
|
||||
.filter((activity) => {
|
||||
if (!needle) return true;
|
||||
return [activity.title, activity.hostName, activity.note, activity.locationLabel, activity.city, activity.state, categoryLabel(activity.category)]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(needle));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const distanceA = distanceKm(profile, a) ?? Number.MAX_SAFE_INTEGER;
|
||||
const distanceB = distanceKm(profile, b) ?? Number.MAX_SAFE_INTEGER;
|
||||
if (scope === "local" && distanceA !== distanceB) return distanceA - distanceB;
|
||||
return new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime();
|
||||
});
|
||||
}, [activities, category, profile, query, scope]);
|
||||
|
||||
const leaderboard = leaderboardQuery.data ?? [];
|
||||
|
||||
function createActivity() {
|
||||
if (!form.title.trim()) return;
|
||||
createActivityMutation.mutate({
|
||||
title: form.title.trim(),
|
||||
category: form.category,
|
||||
scope: form.scope,
|
||||
state: profile.state,
|
||||
city: profile.city ?? null,
|
||||
latitude: profile.latitude == null ? null : Number(profile.latitude),
|
||||
longitude: profile.longitude == null ? null : Number(profile.longitude),
|
||||
locationLabel: locationLabel(profile, form.scope),
|
||||
startsAt: `${form.date}T${form.time}:00.000Z`,
|
||||
durationMinutes: Number(form.durationMinutes) || 30,
|
||||
points: Number(form.points) || clubActivityDefaultPoints,
|
||||
note: form.note.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-red-300">Hellfireclub</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal">Gemeinsam getrennt aktiv</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
|
||||
Lokale Challenges, Bundesland-Events und deutschlandweite Aktivitäten. Jetzt persistent über API und Datenbank.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-28 w-28 place-items-center rounded-full bg-red-500 text-white ring-[12px] ring-red-500/20">
|
||||
<FireIcon className="h-14 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Aktivität, Ort oder Kategorie suchen"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as ClubActivityCategory | "all")}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-3 text-sm font-black text-gray-950"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
|
||||
{(["local", "state", "nationwide"] as ClubScope[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setScope(item)}
|
||||
className={`rounded-full px-3 py-2 text-xs font-black transition ${scope === item ? "bg-gray-950 text-white" : "text-gray-500"}`}
|
||||
>
|
||||
{scopeLabel(item)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{filteredActivities.map((activity) => {
|
||||
const participant = activity.participants.find((item) => item.userId === profile.userId);
|
||||
const completed = Boolean(participant?.completedAt);
|
||||
const distance = distanceKm(profile, activity);
|
||||
return (
|
||||
<article key={activity.id} className="rounded-[2rem] bg-gray-50 p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-black ${categoryStyle(activity.category)}`}>
|
||||
{categoryLabel(activity.category)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-400">
|
||||
{activity.points} Punkte
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-gray-500">
|
||||
{activity.scope === "nationwide" ? <GlobeAltIcon className="h-4 w-4" /> : <MapPinIcon className="h-4 w-4" />}
|
||||
{activity.locationLabel ?? scopeLabel((activity.scope ?? "nationwide") as ClubScope)}
|
||||
{distance !== undefined ? ` · ${distance} km` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-black text-gray-950">{activity.title}</h2>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">
|
||||
{activity.hostName} möchte am {formatDateTime(activity.startsAt)} für {activity.durationMinutes} Minuten los.
|
||||
</p>
|
||||
{activity.note ? <p className="mt-3 text-sm font-semibold text-gray-500">{activity.note}</p> : null}
|
||||
</div>
|
||||
<div className="flex min-w-44 flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(participant)}
|
||||
onClick={() => joinActivityMutation.mutate({ id: activity.id })}
|
||||
className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:bg-gray-200 disabled:text-gray-500"
|
||||
>
|
||||
{participant ? "Du bist dabei" : "Mitgehen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={completed}
|
||||
onClick={() => completeActivityMutation.mutate({ id: activity.id })}
|
||||
className="rounded-full bg-emerald-600 px-4 py-3 text-sm font-black text-white disabled:bg-emerald-100 disabled:text-emerald-700"
|
||||
>
|
||||
{completed ? "Abgeschlossen" : "Abschließen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm font-bold text-gray-400">
|
||||
<UserGroupIcon className="h-5 w-5" />
|
||||
{activity.participants.length || 0} dabei
|
||||
{activity.participants.length > 0 ? <span className="text-gray-300">{activity.participants.map((item) => item.displayName).join(", ")}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{!filteredActivities.length ? <p className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Für diesen Filter gibt es noch keine Aktivitäten.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPinIcon className="h-7 w-7 text-red-500" />
|
||||
<h2 className="text-xl font-black text-gray-950">Club-Profil</h2>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input
|
||||
value={profile.city ?? ""}
|
||||
onChange={(event) => updateProfile({ city: event.target.value })}
|
||||
placeholder="Stadt, z. B. Augsburg"
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
<select
|
||||
value={profile.state}
|
||||
onChange={(event) => updateProfile({ state: event.target.value as GermanState })}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
>
|
||||
{germanStates.map((state) => <option key={state} value={state}>{state}</option>)}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-gray-100 p-1">
|
||||
{(["city", "state"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => updateProfile({ privacyMode: mode })}
|
||||
className={`rounded-xl px-3 py-2 text-xs font-black ${profile.privacyMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-500"}`}
|
||||
>
|
||||
{mode === "city" ? "Stadt zeigen" : "Bundesland"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={requestLocation} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
Standort verwenden
|
||||
</button>
|
||||
{locationStatus ? <p className="text-xs font-bold text-gray-500">{locationStatus}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Aktivität erstellen</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="z. B. Eine Stunde spazieren gehen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<select value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value as ClubActivityCategory }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
|
||||
</select>
|
||||
<select value={form.scope} onChange={(event) => setForm((current) => ({ ...current, scope: event.target.value as ClubScope }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
<option value="local">Lokal in deiner Nähe</option>
|
||||
<option value="state">Im Bundesland</option>
|
||||
<option value="nationwide">Deutschlandweit</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" value={form.date} onChange={(event) => setForm((current) => ({ ...current, date: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input type="time" value={form.time} onChange={(event) => setForm((current) => ({ ...current, time: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={form.durationMinutes} onChange={(event) => setForm((current) => ({ ...current, durationMinutes: event.target.value }))} placeholder="Minuten" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={form.points} onChange={(event) => setForm((current) => ({ ...current, points: event.target.value }))} placeholder="Punkte" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
</div>
|
||||
<textarea value={form.note} onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz, Treffpunkt oder Motivation" className="min-h-24 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<button type="button" onClick={createActivity} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Einladen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrophyIcon className="h-8 w-8 text-amber-500" />
|
||||
<h2 className="text-xl font-black text-gray-950">Highscore</h2>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{leaderboard.length ? leaderboard.map((entry, index) => (
|
||||
<div key={entry.userKey} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-gray-950 text-sm font-black text-white">{index + 1}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
|
||||
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black text-gray-950">{entry.points}</p>
|
||||
<p className="text-xs font-bold text-gray-400">Punkte</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="rounded-2xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Punkte gesammelt.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status ? (
|
||||
<div className="flex gap-2 rounded-3xl bg-emerald-50 p-4 text-sm font-bold text-emerald-800">
|
||||
<CheckCircleIcon className="h-5 w-5 shrink-0" />
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<BoltIcon className="h-7 w-7 text-red-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-950">Persistenter Club</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">
|
||||
Aktivitäten, Teilnahmen, Abschlüsse und Punkte werden jetzt in der Datenbank gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
23
apps/admin/app/(authed)/layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { AuthGuard } from "@/app/_components/auth-guard";
|
||||
import { MobileNav } from "@/app/_components/mobile-nav";
|
||||
import { ReminderRunner } from "@/app/_components/reminder-runner";
|
||||
import { Sidebar } from "@/app/_components/sidebar";
|
||||
|
||||
export default function AuthedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ReminderRunner />
|
||||
<div className="flex min-h-screen bg-[#F9F9F9] dark:bg-neutral-950">
|
||||
<Sidebar />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MobileNav />
|
||||
<main className="p-4 pb-24 sm:p-6 lg:p-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
451
apps/admin/app/(authed)/lebensmittel/page.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CameraIcon,
|
||||
CheckCircleIcon,
|
||||
HandThumbDownIcon,
|
||||
HandThumbUpIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import { parseNutritionLabel, PortionUnit, unitLabel } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
const portionUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
|
||||
|
||||
type FoodForm = {
|
||||
name: string;
|
||||
brand: string;
|
||||
category: string;
|
||||
kcalPer100g: string;
|
||||
proteinPer100g: string;
|
||||
carbsPer100g: string;
|
||||
fatPer100g: string;
|
||||
servingG: string;
|
||||
preferredUnit: PortionUnit;
|
||||
densityGPerMl: string;
|
||||
portionLabel: string;
|
||||
portionUnit: PortionUnit;
|
||||
portionAmount: string;
|
||||
portionGramsEquivalent: string;
|
||||
barcode: string;
|
||||
availableEverywhere: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FoodForm = {
|
||||
name: "",
|
||||
brand: "",
|
||||
category: "Sonstiges",
|
||||
kcalPer100g: "",
|
||||
proteinPer100g: "",
|
||||
carbsPer100g: "",
|
||||
fatPer100g: "",
|
||||
servingG: "100",
|
||||
preferredUnit: "g",
|
||||
densityGPerMl: "",
|
||||
portionLabel: "",
|
||||
portionUnit: "piece",
|
||||
portionAmount: "1",
|
||||
portionGramsEquivalent: "",
|
||||
barcode: "",
|
||||
availableEverywhere: true,
|
||||
};
|
||||
|
||||
function toNumber(value: string) {
|
||||
return Number(value.replace(",", ".")) || 0;
|
||||
}
|
||||
|
||||
function formFromFood(food: Record<string, unknown>): FoodForm {
|
||||
return {
|
||||
...emptyForm,
|
||||
name: String(food.name ?? ""),
|
||||
brand: String(food.brand ?? ""),
|
||||
category: String(food.category ?? "Sonstiges"),
|
||||
kcalPer100g: food.kcalPer100g != null ? String(food.kcalPer100g) : "",
|
||||
proteinPer100g: food.proteinPer100g != null ? String(food.proteinPer100g) : "",
|
||||
carbsPer100g: food.carbsPer100g != null ? String(food.carbsPer100g) : "",
|
||||
fatPer100g: food.fatPer100g != null ? String(food.fatPer100g) : "",
|
||||
servingG: food.servingG != null ? String(food.servingG) : "100",
|
||||
barcode: String(food.barcode ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export default function LebensmittelPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [query, setQuery] = useState("");
|
||||
const [form, setForm] = useState<FoodForm>(emptyForm);
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [ocrText, setOcrText] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [ocrBusy, setOcrBusy] = useState(false);
|
||||
const [lookupBusy, setLookupBusy] = useState(false);
|
||||
const [sourceFoodId, setSourceFoodId] = useState<string | null>(null);
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
|
||||
const foodsQuery = trpc.health.listFoods.useQuery({ query });
|
||||
const sourceSearchQuery = trpc.health.searchOpenFoodFacts.useQuery(
|
||||
{ query: sourceQuery, pageSize: 8 },
|
||||
{ enabled: sourceQuery.trim().length >= 2 },
|
||||
);
|
||||
const saveFoodMutation = trpc.health.saveFood.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await utils.health.listFoods.invalidate();
|
||||
setForm(emptyForm);
|
||||
setStatus(`Lebensmittel gespeichert (${result.id}).`);
|
||||
},
|
||||
});
|
||||
const addNutritionSourceMutation = trpc.health.addNutritionSource.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await utils.health.listFoods.invalidate();
|
||||
setStatus(`Quelle gespeichert. Neuer Durchschnitt aus ${result.sourceCount} Quelle(n): ${result.average.kcalPer100g} kcal/100 g.`);
|
||||
},
|
||||
});
|
||||
const setFoodPreferenceMutation = trpc.health.setFoodPreference.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.listFoods.invalidate();
|
||||
await utils.health.recipeRecommendations.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const foods = foodsQuery.data ?? [];
|
||||
|
||||
function saveFood(source: "manual" | "openfoodfacts" | "ocr" = "manual") {
|
||||
if (!form.name.trim()) return;
|
||||
saveFoodMutation.mutate({
|
||||
name: form.name.trim(),
|
||||
brand: form.brand.trim() || null,
|
||||
category: form.category.trim() || "Sonstiges",
|
||||
kcalPer100g: toNumber(form.kcalPer100g),
|
||||
proteinPer100g: toNumber(form.proteinPer100g),
|
||||
carbsPer100g: toNumber(form.carbsPer100g),
|
||||
fatPer100g: toNumber(form.fatPer100g),
|
||||
servingG: toNumber(form.servingG) || 100,
|
||||
preferredUnit: form.preferredUnit,
|
||||
densityGPerMl: form.densityGPerMl.trim() ? toNumber(form.densityGPerMl) : null,
|
||||
barcode: form.barcode.trim() || null,
|
||||
source,
|
||||
availableEverywhere: form.availableEverywhere,
|
||||
portions: form.portionLabel.trim()
|
||||
? [{
|
||||
label: form.portionLabel.trim(),
|
||||
unit: form.portionUnit,
|
||||
amount: toNumber(form.portionAmount) || 1,
|
||||
gramsEquivalent: toNumber(form.portionGramsEquivalent) || toNumber(form.servingG) || 100,
|
||||
}]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function importFromOpenFoodFacts() {
|
||||
const cleanBarcode = barcode.replace(/\D/g, "");
|
||||
if (!cleanBarcode) return;
|
||||
setLookupBusy(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json?fields=product_name,brands,categories,nutriments,serving_quantity`);
|
||||
const result = await response.json() as {
|
||||
status?: number;
|
||||
product?: {
|
||||
product_name?: string;
|
||||
brands?: string;
|
||||
categories?: string;
|
||||
serving_quantity?: number;
|
||||
nutriments?: Record<string, number>;
|
||||
};
|
||||
};
|
||||
if (result.status !== 1 || !result.product) {
|
||||
setStatus("Kein Produkt für diesen Barcode gefunden.");
|
||||
return;
|
||||
}
|
||||
const nutriments = result.product.nutriments ?? {};
|
||||
setForm(formFromFood({
|
||||
name: result.product.product_name || "Unbenanntes Produkt",
|
||||
brand: result.product.brands?.split(",")[0]?.trim(),
|
||||
category: result.product.categories?.split(",")[0]?.trim() || "Supermarkt",
|
||||
kcalPer100g: nutriments["energy-kcal_100g"] ?? 0,
|
||||
proteinPer100g: nutriments.proteins_100g ?? 0,
|
||||
carbsPer100g: nutriments.carbohydrates_100g ?? 0,
|
||||
fatPer100g: nutriments.fat_100g ?? 0,
|
||||
servingG: result.product.serving_quantity ?? 100,
|
||||
barcode: cleanBarcode,
|
||||
}));
|
||||
setStatus("Open-Food-Facts-Daten geladen. Bitte prüfen und speichern.");
|
||||
} catch {
|
||||
setStatus("Open Food Facts konnte gerade nicht abgefragt werden.");
|
||||
} finally {
|
||||
setLookupBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLabelImage(file: File) {
|
||||
setOcrBusy(true);
|
||||
setStatus("OCR läuft im Browser. Das kann einen Moment dauern.");
|
||||
try {
|
||||
const { recognize } = await import("tesseract.js");
|
||||
const result = await recognize(file, "deu+eng");
|
||||
const text = result.data.text;
|
||||
const parsed = parseNutritionLabel(text);
|
||||
setOcrText(text);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
|
||||
}));
|
||||
setStatus("OCR abgeschlossen. Werte bitte kontrollieren und speichern.");
|
||||
} catch {
|
||||
setStatus("OCR konnte nicht abgeschlossen werden. Du kannst den Labeltext unten einfügen.");
|
||||
} finally {
|
||||
setOcrBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePastedOcr() {
|
||||
const parsed = parseNutritionLabel(ocrText);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
|
||||
}));
|
||||
setStatus("Text ausgewertet. Werte bitte kontrollieren und speichern.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Lebensmittelkatalog</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Eigene Basisdaten, Supermarktprodukte, Barcode-Import und Label-OCR. Lebensmittel werden jetzt serverseitig pro Benutzer gespeichert.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1fr_0.9fr]">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Lebensmittel, Marke, Kategorie oder Barcode suchen"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
{foods.map((food) => (
|
||||
<article key={food.id} className="rounded-3xl bg-gray-50 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-lg font-black text-gray-950">{food.name}</p>
|
||||
<p className="text-sm font-bold text-gray-400">{food.brand || food.category} - {Number(food.servingG)} g Portion</p>
|
||||
</div>
|
||||
{food.isVerified ? <CheckCircleIcon className="h-6 w-6 text-emerald-600" /> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-4 gap-2 text-center">
|
||||
<div><p className="text-xl font-black">{Number(food.kcalPer100g)}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
|
||||
<div><p className="text-xl font-black">{Number(food.proteinPer100g)}</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
||||
<div><p className="text-xl font-black">{Number(food.carbsPer100g)}</p><p className="text-xs font-bold text-gray-400">KH</p></div>
|
||||
<div><p className="text-xl font-black">{Number(food.fatPer100g)}</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold uppercase text-gray-300">{food.source}</span>
|
||||
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||
{food.availableEverywhere ? "überall erhältlich" : "Marktzuordnung"}
|
||||
</span>
|
||||
{food.portions?.map((portion) => (
|
||||
<span key={portion.id} className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">
|
||||
{portion.label}: {Number(portion.amount)} {unitLabel(portion.unit)} = {Number(portion.gramsEquivalent)} g
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === 1 ? 0 : 1 })}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
|
||||
food.userVote === 1 ? "bg-emerald-600 text-white" : "bg-white text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
<HandThumbUpIcon className="h-4 w-4" />
|
||||
Mag ich
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === -1 ? 0 : -1 })}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
|
||||
food.userVote === -1 ? "bg-red-600 text-white" : "bg-white text-red-700"
|
||||
}`}
|
||||
>
|
||||
<HandThumbDownIcon className="h-4 w-4" />
|
||||
Nicht meins
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl bg-white p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-black uppercase text-gray-400">Quellen</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSourceFoodId(sourceFoodId === food.id ? null : food.id);
|
||||
setSourceQuery(food.name);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-950 px-3 py-1 text-xs font-black text-white"
|
||||
>
|
||||
<ScaleIcon className="h-4 w-4" />
|
||||
Mittelwert
|
||||
</button>
|
||||
</div>
|
||||
{food.nutritionSources?.length ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{food.nutritionSources.map((source) => (
|
||||
<a
|
||||
key={source.id}
|
||||
href={source.url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-xl bg-gray-50 px-3 py-2 text-xs font-bold text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
{source.provider}: {Number(source.kcalPer100g)} kcal/100 g
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs font-bold text-gray-300">Noch keine einzelnen Quellen hinterlegt.</p>
|
||||
)}
|
||||
{sourceFoodId === food.id ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
value={sourceQuery}
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
placeholder="Open Food Facts suchen"
|
||||
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-semibold"
|
||||
/>
|
||||
{(sourceSearchQuery.data ?? []).map((source) => (
|
||||
<button
|
||||
key={`${source.provider}-${source.providerFoodId ?? source.name}`}
|
||||
type="button"
|
||||
onClick={() => addNutritionSourceMutation.mutate({ foodId: food.id, ...source })}
|
||||
className="block w-full rounded-2xl bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-900 hover:bg-emerald-100"
|
||||
>
|
||||
{source.name} · {source.brand || "ohne Marke"} · {source.kcalPer100g} kcal/100 g
|
||||
</button>
|
||||
))}
|
||||
{sourceSearchQuery.isFetching ? <p className="text-xs font-bold text-gray-300">Suche läuft...</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!foods.length && !foodsQuery.isLoading ? (
|
||||
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Lebensmittel gefunden.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Produkt importieren</h2>
|
||||
<div className="mt-4 grid grid-cols-[1fr_52px] gap-2">
|
||||
<input
|
||||
value={barcode}
|
||||
onChange={(event) => setBarcode(event.target.value)}
|
||||
placeholder="Barcode / EAN"
|
||||
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void importFromOpenFoodFacts()}
|
||||
disabled={lookupBusy}
|
||||
className="grid place-items-center rounded-full bg-gray-950 text-white disabled:opacity-50"
|
||||
aria-label="Barcode suchen"
|
||||
title="Barcode suchen"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Label per OCR</h2>
|
||||
<label className="mt-4 flex cursor-pointer items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
|
||||
<CameraIcon className="h-5 w-5" />
|
||||
Foto auswählen
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void readLabelImage(file);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={ocrText}
|
||||
onChange={(event) => setOcrText(event.target.value)}
|
||||
placeholder="Oder OCR-/Labeltext hier einfügen..."
|
||||
className="mt-3 min-h-28 w-full rounded-3xl border border-gray-200 px-4 py-3 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={parsePastedOcr} disabled={!ocrText.trim() || ocrBusy} className="mt-3 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40">
|
||||
Text auswerten
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Lebensmittel speichern</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.brand} onChange={(event) => setForm((current) => ({ ...current, brand: event.target.value }))} placeholder="Marke optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))} placeholder="Kategorie" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<label className="flex items-center gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.availableEverywhere}
|
||||
onChange={(event) => setForm((current) => ({ ...current, availableEverywhere: event.target.checked }))}
|
||||
/>
|
||||
Überall erhältlich
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={form.kcalPer100g} onChange={(event) => setForm((current) => ({ ...current, kcalPer100g: event.target.value }))} placeholder="kcal/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.servingG} onChange={(event) => setForm((current) => ({ ...current, servingG: event.target.value }))} placeholder="Portion g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.proteinPer100g} onChange={(event) => setForm((current) => ({ ...current, proteinPer100g: event.target.value }))} placeholder="Protein/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.carbsPer100g} onChange={(event) => setForm((current) => ({ ...current, carbsPer100g: event.target.value }))} placeholder="KH/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.fatPer100g} onChange={(event) => setForm((current) => ({ ...current, fatPer100g: event.target.value }))} placeholder="Fett/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.barcode} onChange={(event) => setForm((current) => ({ ...current, barcode: event.target.value }))} placeholder="Barcode" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
</div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
<p className="text-sm font-black text-gray-950">Alltagseinheiten</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<select value={form.preferredUnit} onChange={(event) => setForm((current) => ({ ...current, preferredUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
|
||||
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>Standard: {unitLabel(portionUnit)}</option>)}
|
||||
</select>
|
||||
<input value={form.densityGPerMl} onChange={(event) => setForm((current) => ({ ...current, densityGPerMl: event.target.value }))} placeholder="Dichte g/ml optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.portionLabel} onChange={(event) => setForm((current) => ({ ...current, portionLabel: event.target.value }))} placeholder="Portion, z. B. 1 Becher" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<select value={form.portionUnit} onChange={(event) => setForm((current) => ({ ...current, portionUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
|
||||
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>{unitLabel(portionUnit)}</option>)}
|
||||
</select>
|
||||
<input value={form.portionAmount} onChange={(event) => setForm((current) => ({ ...current, portionAmount: event.target.value }))} placeholder="Menge, z. B. 1" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<input value={form.portionGramsEquivalent} onChange={(event) => setForm((current) => ({ ...current, portionGramsEquivalent: event.target.value }))} placeholder="entspricht g/ml-Basis" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveFood(form.barcode ? "openfoodfacts" : "manual")}
|
||||
disabled={saveFoodMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
In Katalog aufnehmen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
|
||||
{saveFoodMutation.error ? <div className="rounded-3xl bg-red-50 px-5 py-4 text-sm font-bold text-red-800">{saveFoodMutation.error.message}</div> : null}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
apps/admin/app/(authed)/maerkte/page.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowTopRightOnSquareIcon, MapPinIcon, PlusIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormField, inputClassName } from "@/app/_components/form-field";
|
||||
import { uid } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type DepartmentDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type StoreDraft = {
|
||||
id?: string;
|
||||
name: string;
|
||||
address?: string | null;
|
||||
placeId?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
favorite: boolean;
|
||||
departments: DepartmentDraft[];
|
||||
};
|
||||
|
||||
function defaultDepartments(storeId: string): DepartmentDraft[] {
|
||||
return [
|
||||
{ id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 },
|
||||
{ id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 },
|
||||
{ id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 3 },
|
||||
{ id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 4 },
|
||||
{ id: `${storeId}-drinks`, name: "Getränke", sortOrder: 5 },
|
||||
{ id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 },
|
||||
];
|
||||
}
|
||||
|
||||
function mapsSearchUrl(name: string, address?: string | null) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([name, address].filter(Boolean).join(" "))}`;
|
||||
}
|
||||
|
||||
export default function MaerktePage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [storeName, setStoreName] = useState("");
|
||||
const [storeAddress, setStoreAddress] = useState("");
|
||||
const [storeLat, setStoreLat] = useState("");
|
||||
const [storeLng, setStoreLng] = useState("");
|
||||
const [departmentNames, setDepartmentNames] = useState<Record<string, string>>({});
|
||||
const [changeNotes, setChangeNotes] = useState<Record<string, string>>({});
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const storesQuery = trpc.health.listStores.useQuery();
|
||||
const requestsQuery = trpc.health.myStoreChangeRequests.useQuery();
|
||||
const saveStoreMutation = trpc.health.saveStore.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.listStores.invalidate();
|
||||
setStatus("Markt gespeichert.");
|
||||
},
|
||||
});
|
||||
const submitStoreChangeRequest = trpc.health.submitStoreChangeRequest.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.myStoreChangeRequests.invalidate();
|
||||
setStatus("Änderungsantrag eingereicht.");
|
||||
},
|
||||
});
|
||||
const setStoreFavoriteMutation = trpc.health.setStoreFavorite.useMutation({
|
||||
onSuccess: () => utils.health.listStores.invalidate(),
|
||||
});
|
||||
const deleteStoreMutation = trpc.health.deleteStore.useMutation({
|
||||
onSuccess: () => utils.health.listStores.invalidate(),
|
||||
});
|
||||
|
||||
const stores = useMemo(() => storesQuery.data ?? [], [storesQuery.data]);
|
||||
|
||||
function saveDraft(draft: StoreDraft) {
|
||||
saveStoreMutation.mutate({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
address: draft.address || null,
|
||||
placeId: draft.placeId || null,
|
||||
latitude: draft.latitude ?? null,
|
||||
longitude: draft.longitude ?? null,
|
||||
favorite: draft.favorite,
|
||||
departments: draft.departments
|
||||
.map((department, index) => ({
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
sortOrder: department.sortOrder || index + 1,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
});
|
||||
}
|
||||
|
||||
function addStore() {
|
||||
if (!storeName.trim()) return;
|
||||
const storeId = uid("store");
|
||||
saveDraft({
|
||||
id: storeId,
|
||||
name: storeName.trim(),
|
||||
address: storeAddress.trim() || null,
|
||||
latitude: storeLat ? Number(storeLat.replace(",", ".")) : null,
|
||||
longitude: storeLng ? Number(storeLng.replace(",", ".")) : null,
|
||||
favorite: false,
|
||||
departments: defaultDepartments(storeId),
|
||||
});
|
||||
setStoreName("");
|
||||
setStoreAddress("");
|
||||
setStoreLat("");
|
||||
setStoreLng("");
|
||||
}
|
||||
|
||||
function useCurrentLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
setStatus("Standortfreigabe ist in diesem Browser nicht verfügbar.");
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setStoreLat(position.coords.latitude.toFixed(6));
|
||||
setStoreLng(position.coords.longitude.toFixed(6));
|
||||
setStatus("Standort übernommen. Bitte Marktname und Adresse ergänzen.");
|
||||
},
|
||||
() => setStatus("Standort konnte nicht gelesen werden."),
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
function toggleFavorite(store: StoreDraft) {
|
||||
if (!store.id) return;
|
||||
setStoreFavoriteMutation.mutate({ storeId: store.id, favorite: !store.favorite });
|
||||
}
|
||||
|
||||
function submitChange(store: StoreDraft, requestType: "new_store" | "structure_update" | "location_update" = "structure_update") {
|
||||
submitStoreChangeRequest.mutate({
|
||||
storeId: store.id ?? null,
|
||||
requestType,
|
||||
title: `${store.name}: Änderung übernehmen`,
|
||||
note: changeNotes[store.id ?? store.name] || null,
|
||||
store: {
|
||||
name: store.name,
|
||||
address: store.address ?? null,
|
||||
placeId: store.placeId ?? null,
|
||||
latitude: store.latitude ?? null,
|
||||
longitude: store.longitude ?? null,
|
||||
},
|
||||
departments: store.departments,
|
||||
});
|
||||
}
|
||||
|
||||
function addDepartment(store: StoreDraft) {
|
||||
const name = departmentNames[store.id ?? store.name]?.trim();
|
||||
if (!name) return;
|
||||
const maxOrder = Math.max(0, ...store.departments.map((department) => department.sortOrder));
|
||||
saveDraft({
|
||||
...store,
|
||||
departments: [
|
||||
...store.departments,
|
||||
{ id: uid(`${store.id ?? "store"}-dept`), name, sortOrder: maxOrder + 1 },
|
||||
],
|
||||
});
|
||||
setDepartmentNames((current) => ({ ...current, [store.id ?? store.name]: "" }));
|
||||
}
|
||||
|
||||
function removeDepartment(store: StoreDraft, departmentId: string) {
|
||||
if (store.departments.length <= 1) return;
|
||||
saveDraft({ ...store, departments: store.departments.filter((department) => department.id !== departmentId) });
|
||||
}
|
||||
|
||||
function moveDepartment(store: StoreDraft, departmentId: string, direction: -1 | 1) {
|
||||
const sorted = [...store.departments].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const index = sorted.findIndex((department) => department.id === departmentId);
|
||||
const targetIndex = index + direction;
|
||||
if (index < 0 || targetIndex < 0 || targetIndex >= sorted.length) return;
|
||||
const current = sorted[index]!;
|
||||
const target = sorted[targetIndex]!;
|
||||
sorted[index] = { ...current, sortOrder: target.sortOrder };
|
||||
sorted[targetIndex] = { ...target, sortOrder: current.sortOrder };
|
||||
saveDraft({ ...store, departments: sorted });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Märkte & Laufwege</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Speichere nahe Lebensmittelläden, pinne ihren Standort und sortiere die Abteilungen so, wie du wirklich durch den Markt läufst.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1.4fr_120px_120px_130px]">
|
||||
<FormField label="Marktname">
|
||||
<input value={storeName} onChange={(event) => setStoreName(event.target.value)} placeholder="z. B. Kaufland" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Adresse">
|
||||
<input value={storeAddress} onChange={(event) => setStoreAddress(event.target.value)} placeholder="Straße, Ort" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Breite">
|
||||
<input value={storeLat} onChange={(event) => setStoreLat(event.target.value)} placeholder="48.3705" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Länge">
|
||||
<input value={storeLng} onChange={(event) => setStoreLng(event.target.value)} placeholder="10.8978" className={inputClassName} />
|
||||
</FormField>
|
||||
<div className="flex items-end gap-2">
|
||||
<button type="button" onClick={useCurrentLocation} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-100 text-gray-700" aria-label="Standort übernehmen" title="Standort übernehmen">
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button type="button" onClick={addStore} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Markt speichern" title="Markt speichern">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<a href={mapsSearchUrl(storeName || "Supermarkt", storeAddress)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-teal-50 px-4 py-2 text-xs font-black text-[#0F766E]">
|
||||
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
||||
In Google Maps suchen
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{stores.map((store) => {
|
||||
const canEdit = store.ownerUserId !== null;
|
||||
const draft: StoreDraft = {
|
||||
id: store.id,
|
||||
name: store.name,
|
||||
address: store.address,
|
||||
placeId: store.placeId,
|
||||
latitude: store.latitude,
|
||||
longitude: store.longitude,
|
||||
favorite: store.favorite,
|
||||
departments: store.departments.map((department) => ({
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
sortOrder: department.sortOrder,
|
||||
})),
|
||||
};
|
||||
const departmentKey = store.id;
|
||||
return (
|
||||
<section key={store.id} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-950">{store.name}</h2>
|
||||
<p className="text-sm font-bold text-gray-400">
|
||||
{store.departments.length} Abteilungen
|
||||
{!canEdit ? " · globale Vorlage" : " · eigener Markt"}
|
||||
</p>
|
||||
{store.address ? <p className="mt-1 text-sm font-bold text-gray-500">{store.address}</p> : null}
|
||||
{store.latitude != null && store.longitude != null ? (
|
||||
<a href={`https://www.google.com/maps/search/?api=1&query=${store.latitude},${store.longitude}`} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs font-black text-[#0F766E]">
|
||||
<MapPinIcon className="h-4 w-4" />
|
||||
Pin öffnen
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => toggleFavorite(draft)} className={`rounded-full p-2 ${store.favorite ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-300"}`} aria-label="Favorit" title="Favorit">
|
||||
<StarIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button type="button" onClick={() => deleteStoreMutation.mutate({ id: store.id })} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Markt löschen" title="Markt löschen">
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{[...store.departments].sort((a, b) => a.sortOrder - b.sortOrder).map((department, index) => (
|
||||
<div key={department.id} className="grid grid-cols-[40px_1fr_96px_40px] items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-white text-sm font-black text-gray-400">{index + 1}</span>
|
||||
<span className="text-sm font-black text-gray-950">{department.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{canEdit ? (
|
||||
<>
|
||||
<button type="button" onClick={() => moveDepartment(draft, department.id, -1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">hoch</button>
|
||||
<button type="button" onClick={() => moveDepartment(draft, department.id, 1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">runter</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<button type="button" onClick={() => removeDepartment(draft, department.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Abteilung löschen" title="Abteilung löschen">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
) : <span />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="mt-4 grid grid-cols-[1fr_44px] gap-2">
|
||||
<input
|
||||
value={departmentNames[departmentKey] ?? ""}
|
||||
onChange={(event) => setDepartmentNames((current) => ({ ...current, [departmentKey]: event.target.value }))}
|
||||
placeholder="Neue Abteilung"
|
||||
className={inputClassName}
|
||||
/>
|
||||
<button type="button" onClick={() => addDepartment(draft)} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Abteilung hinzufügen" title="Abteilung hinzufügen">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 rounded-3xl bg-gray-50 p-4">
|
||||
<FormField label="Änderung einreichen" hint={canEdit ? "Eigene Märkte können vom Admin als globale Vorlage übernommen werden." : "Globale Vorlagen werden nach Admin-Prüfung aktualisiert."}>
|
||||
<textarea value={changeNotes[departmentKey] ?? ""} onChange={(event) => setChangeNotes((current) => ({ ...current, [departmentKey]: event.target.value }))} placeholder="Was hat sich im Markt geändert?" className={`${inputClassName} min-h-20`} />
|
||||
</FormField>
|
||||
<button type="button" onClick={() => submitChange(draft)} className="mt-3 w-full rounded-full bg-[#0F766E] px-4 py-3 text-sm font-black text-white">
|
||||
Änderung zur Prüfung einreichen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Meine Änderungsanträge</h2>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{(requestsQuery.data ?? []).slice(0, 8).map((request) => (
|
||||
<div key={request.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold">
|
||||
<span className="min-w-0 truncate text-gray-950">{request.title}</span>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${request.status === "accepted" ? "bg-emerald-100 text-emerald-700" : request.status === "rejected" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{request.status === "accepted" ? "übernommen" : request.status === "rejected" ? "abgelehnt" : "in Prüfung"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!(requestsQuery.data ?? []).length ? <p className="text-sm font-bold text-gray-400">Noch keine Änderungsanträge.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
apps/admin/app/(authed)/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
SparklesIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import {
|
||||
ActivityLevel,
|
||||
HealthGoal,
|
||||
HealthProfile,
|
||||
WeightLossPace,
|
||||
buildCoachRecommendations,
|
||||
buildGoalsFromProfile,
|
||||
loadHellthData,
|
||||
saveHellthData,
|
||||
} from "@/lib/hellth-data";
|
||||
|
||||
type FormState = {
|
||||
goal: HealthGoal;
|
||||
age: string;
|
||||
heightCm: string;
|
||||
currentWeightKg: string;
|
||||
targetWeightKg: string;
|
||||
activityLevel: ActivityLevel;
|
||||
pace: WeightLossPace;
|
||||
mealsPerDay: string;
|
||||
dietStyle: HealthProfile["dietStyle"];
|
||||
cookingTime: HealthProfile["cookingTime"];
|
||||
budgetFocus: boolean;
|
||||
excludedFoods: string;
|
||||
biggestChallenge: string;
|
||||
medicalNote: boolean;
|
||||
};
|
||||
|
||||
const initialForm: FormState = {
|
||||
goal: "lose_weight",
|
||||
age: "",
|
||||
heightCm: "",
|
||||
currentWeightKg: "",
|
||||
targetWeightKg: "",
|
||||
activityLevel: "moderate",
|
||||
pace: "normal",
|
||||
mealsPerDay: "3",
|
||||
dietStyle: "mixed",
|
||||
cookingTime: "medium",
|
||||
budgetFocus: false,
|
||||
excludedFoods: "",
|
||||
biggestChallenge: "",
|
||||
medicalNote: false,
|
||||
};
|
||||
|
||||
function numberFrom(value: string) {
|
||||
return Number(value.replace(",", ".")) || 0;
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-3xl border p-4 text-left transition ${
|
||||
active ? "border-[#0F766E] bg-emerald-50 ring-2 ring-emerald-100" : "border-gray-100 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<p className="text-base font-black text-gray-950">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">{description}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const [savedProfile, setSavedProfile] = useState<HealthProfile | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userKey) return;
|
||||
const profile = loadHellthData(userKey).healthProfile;
|
||||
if (profile) {
|
||||
setSavedProfile(profile);
|
||||
setForm({
|
||||
goal: profile.goal,
|
||||
age: String(profile.age),
|
||||
heightCm: String(profile.heightCm),
|
||||
currentWeightKg: String(profile.currentWeightKg),
|
||||
targetWeightKg: profile.targetWeightKg ? String(profile.targetWeightKg) : "",
|
||||
activityLevel: profile.activityLevel,
|
||||
pace: profile.pace,
|
||||
mealsPerDay: String(profile.mealsPerDay),
|
||||
dietStyle: profile.dietStyle,
|
||||
cookingTime: profile.cookingTime,
|
||||
budgetFocus: profile.budgetFocus,
|
||||
excludedFoods: profile.excludedFoods,
|
||||
biggestChallenge: profile.biggestChallenge,
|
||||
medicalNote: profile.medicalNote,
|
||||
});
|
||||
}
|
||||
}, [userKey]);
|
||||
|
||||
function saveProfile() {
|
||||
if (!userKey) return;
|
||||
const age = numberFrom(form.age);
|
||||
const heightCm = numberFrom(form.heightCm);
|
||||
const currentWeightKg = numberFrom(form.currentWeightKg);
|
||||
const targetWeightKg = numberFrom(form.targetWeightKg);
|
||||
|
||||
if (age < 16 || heightCm < 120 || currentWeightKg < 35) {
|
||||
setError("Bitte Alter, Größe und aktuelles Gewicht plausibel ausfüllen.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.goal === "lose_weight" && targetWeightKg > 0 && targetWeightKg >= currentWeightKg) {
|
||||
setError("Für Abnehmen sollte das Zielgewicht unter dem aktuellen Gewicht liegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
const profile: HealthProfile = {
|
||||
completedAt: new Date().toISOString(),
|
||||
goal: form.goal,
|
||||
age,
|
||||
heightCm,
|
||||
currentWeightKg,
|
||||
targetWeightKg: targetWeightKg || undefined,
|
||||
activityLevel: form.activityLevel,
|
||||
pace: form.pace,
|
||||
mealsPerDay: Math.max(1, Math.min(6, numberFrom(form.mealsPerDay) || 3)),
|
||||
dietStyle: form.dietStyle,
|
||||
cookingTime: form.cookingTime,
|
||||
budgetFocus: form.budgetFocus,
|
||||
excludedFoods: form.excludedFoods.trim(),
|
||||
biggestChallenge: form.biggestChallenge.trim(),
|
||||
medicalNote: form.medicalNote,
|
||||
};
|
||||
const goals = buildGoalsFromProfile(profile);
|
||||
const coachRecommendations = buildCoachRecommendations(profile, goals);
|
||||
const data = loadHellthData(userKey);
|
||||
saveHellthData({ ...data, healthProfile: profile, goals, coachRecommendations }, userKey);
|
||||
setSavedProfile(profile);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const previewProfile: HealthProfile | null =
|
||||
numberFrom(form.age) >= 16 && numberFrom(form.heightCm) >= 120 && numberFrom(form.currentWeightKg) >= 35
|
||||
? {
|
||||
completedAt: new Date().toISOString(),
|
||||
goal: form.goal,
|
||||
age: numberFrom(form.age),
|
||||
heightCm: numberFrom(form.heightCm),
|
||||
currentWeightKg: numberFrom(form.currentWeightKg),
|
||||
targetWeightKg: numberFrom(form.targetWeightKg) || undefined,
|
||||
activityLevel: form.activityLevel,
|
||||
pace: form.pace,
|
||||
mealsPerDay: numberFrom(form.mealsPerDay) || 3,
|
||||
dietStyle: form.dietStyle,
|
||||
cookingTime: form.cookingTime,
|
||||
budgetFocus: form.budgetFocus,
|
||||
excludedFoods: form.excludedFoods,
|
||||
biggestChallenge: form.biggestChallenge,
|
||||
medicalNote: form.medicalNote,
|
||||
}
|
||||
: null;
|
||||
const previewGoals = previewProfile ? buildGoalsFromProfile(previewProfile) : null;
|
||||
const previewRecommendations = previewProfile && previewGoals ? buildCoachRecommendations(previewProfile, previewGoals) : [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-[#0F766E] text-white">
|
||||
<SparklesIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Erster Start</p>
|
||||
<h1 className="text-4xl font-black tracking-normal text-gray-950">Dein Health-Profil</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 max-w-3xl text-sm font-semibold text-gray-500">
|
||||
Hellth Hub nutzt diese Antworten, um alltagstaugliche Ziele und konkrete Vorschläge zu erzeugen. Die Werte sind Startpunkte und werden später über deinen echten Verlauf verbessert.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-[1fr_0.8fr]">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">1. Ziel</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<OptionButton active={form.goal === "lose_weight"} label="Abnehmen" description="Fokus auf moderates Defizit und Sättigung." onClick={() => setForm((current) => ({ ...current, goal: "lose_weight" }))} />
|
||||
<OptionButton active={form.goal === "maintain"} label="Gewicht halten" description="Routine, Energie und Stabilität verbessern." onClick={() => setForm((current) => ({ ...current, goal: "maintain" }))} />
|
||||
<OptionButton active={form.goal === "healthier"} label="Gesünder leben" description="Mehr Struktur, bessere Lebensmittelauswahl." onClick={() => setForm((current) => ({ ...current, goal: "healthier" }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">2. Ausgangslage</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<input value={form.age} onChange={(event) => setForm((current) => ({ ...current, age: event.target.value }))} placeholder="Alter" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={form.heightCm} onChange={(event) => setForm((current) => ({ ...current, heightCm: event.target.value }))} placeholder="Größe cm" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={form.currentWeightKg} onChange={(event) => setForm((current) => ({ ...current, currentWeightKg: event.target.value }))} placeholder="Aktuelles Gewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={form.targetWeightKg} onChange={(event) => setForm((current) => ({ ...current, targetWeightKg: event.target.value }))} placeholder="Zielgewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<OptionButton active={form.activityLevel === "low"} label="Wenig aktiv" description="Viel Sitzen, wenig Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "low" }))} />
|
||||
<OptionButton active={form.activityLevel === "moderate"} label="Moderat" description="Alltag mit etwas Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "moderate" }))} />
|
||||
<OptionButton active={form.activityLevel === "active"} label="Aktiv" description="Regelmäßiges Training oder aktiver Job." onClick={() => setForm((current) => ({ ...current, activityLevel: "active" }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">3. Alltag</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<OptionButton active={form.pace === "gentle"} label="Sanft" description="Kleines Defizit, leichter Alltag." onClick={() => setForm((current) => ({ ...current, pace: "gentle" }))} />
|
||||
<OptionButton active={form.pace === "normal"} label="Normal" description="Guter Startpunkt für Abnehmen." onClick={() => setForm((current) => ({ ...current, pace: "normal" }))} />
|
||||
<OptionButton active={form.pace === "ambitious"} label="Ambitioniert" description="Mehr Struktur, genauer beobachten." onClick={() => setForm((current) => ({ ...current, pace: "ambitious" }))} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<input value={form.mealsPerDay} onChange={(event) => setForm((current) => ({ ...current, mealsPerDay: event.target.value }))} placeholder="Mahlzeiten pro Tag" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<select value={form.dietStyle} onChange={(event) => setForm((current) => ({ ...current, dietStyle: event.target.value as FormState["dietStyle"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
<option value="mixed">Mischkost</option>
|
||||
<option value="flexible">Flexibel</option>
|
||||
<option value="vegetarian">Vegetarisch</option>
|
||||
<option value="vegan">Vegan</option>
|
||||
<option value="low_carb">Low Carb</option>
|
||||
</select>
|
||||
<select value={form.cookingTime} onChange={(event) => setForm((current) => ({ ...current, cookingTime: event.target.value as FormState["cookingTime"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
<option value="low">Wenig Kochzeit</option>
|
||||
<option value="medium">Mittlere Kochzeit</option>
|
||||
<option value="high">Koche gern</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-black">
|
||||
<input type="checkbox" checked={form.budgetFocus} onChange={(event) => setForm((current) => ({ ...current, budgetFocus: event.target.checked }))} />
|
||||
Budget wichtig
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<textarea value={form.excludedFoods} onChange={(event) => setForm((current) => ({ ...current, excludedFoods: event.target.value }))} placeholder="Allergien, Ausschlüsse, No-Gos" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<textarea value={form.biggestChallenge} onChange={(event) => setForm((current) => ({ ...current, biggestChallenge: event.target.value }))} placeholder="Was macht Abnehmen oder gesünder essen aktuell schwer?" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
</div>
|
||||
<label className="mt-4 flex items-start gap-3 rounded-3xl bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-900">
|
||||
<input type="checkbox" checked={form.medicalNote} onChange={(event) => setForm((current) => ({ ...current, medicalNote: event.target.checked }))} className="mt-1" />
|
||||
Es gibt Diagnosen, Medikamente, Schwangerschaft, Essstörungs-Vorgeschichte oder andere gesundheitliche Themen, die beachtet werden sollten.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="sticky top-6 rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Auswertung</h2>
|
||||
{previewGoals ? (
|
||||
<>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.calories}</p><p className="text-xs font-bold text-gray-400">kcal/Tag</p></div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.drinkMl}</p><p className="text-xs font-bold text-gray-400">ml Wasser</p></div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.weightKg}</p><p className="text-xs font-bold text-gray-400">kg Ziel</p></div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{previewRecommendations.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-3xl bg-emerald-50 p-4">
|
||||
<p className="font-black text-emerald-950">{item.title}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-emerald-800">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-sm font-semibold text-gray-400">Fülle Alter, Größe und Gewicht aus, dann siehst du hier die ersten Vorschläge.</p>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 flex gap-2 rounded-3xl bg-red-50 p-4 text-sm font-semibold text-red-700">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{savedProfile ? (
|
||||
<div className="mt-4 flex gap-2 rounded-3xl bg-green-50 p-4 text-sm font-semibold text-green-700">
|
||||
<CheckCircleIcon className="h-5 w-5 shrink-0" />
|
||||
Profil gespeichert. Deine Ziele und Coach-Vorschläge sind jetzt userbezogen aktiv.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="button" onClick={saveProfile} className="mt-5 flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-4 text-sm font-black text-white">
|
||||
Profil speichern
|
||||
<ArrowRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{savedProfile ? (
|
||||
<Link href="/" className="mt-3 flex w-full items-center justify-center rounded-full bg-emerald-600 px-5 py-4 text-sm font-black text-white">
|
||||
Zur Tagesansicht
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
757
apps/admin/app/(authed)/page.tsx
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BeakerIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
FireIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type MealType = "Frühstück" | "Mittagessen" | "Abendessen" | "Snack";
|
||||
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
|
||||
|
||||
type FoodEntry = {
|
||||
id: string;
|
||||
mealType: MealType;
|
||||
name: string;
|
||||
amount: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
};
|
||||
|
||||
type DrinkEntry = {
|
||||
id: string;
|
||||
amountMl: number;
|
||||
};
|
||||
|
||||
type PlannedMeal = {
|
||||
id: string;
|
||||
mealType: MealType;
|
||||
name: string;
|
||||
note?: string;
|
||||
portions?: number;
|
||||
plannedCalories?: number;
|
||||
};
|
||||
|
||||
type DayData = {
|
||||
foods: FoodEntry[];
|
||||
drinks: DrinkEntry[];
|
||||
weightKg?: number;
|
||||
plannedMeals: PlannedMeal[];
|
||||
};
|
||||
|
||||
const mealTypes: MealType[] = ["Frühstück", "Mittagessen", "Abendessen", "Snack"];
|
||||
const mealTypeToApi: Record<MealType, ApiMealType> = {
|
||||
Frühstück: "breakfast",
|
||||
Mittagessen: "lunch",
|
||||
Abendessen: "dinner",
|
||||
Snack: "snack",
|
||||
};
|
||||
const mealTypeFromApi: Record<ApiMealType, MealType> = {
|
||||
breakfast: "Frühstück",
|
||||
lunch: "Mittagessen",
|
||||
dinner: "Abendessen",
|
||||
snack: "Snack",
|
||||
};
|
||||
|
||||
const fallbackGoals = {
|
||||
drinkMl: 2500,
|
||||
calories: 2000,
|
||||
carbs: 200,
|
||||
fat: 67,
|
||||
protein: 150,
|
||||
fastingHours: 12,
|
||||
};
|
||||
|
||||
function toDateKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseDateKey(key: string) {
|
||||
return new Date(`${key}T12:00:00`);
|
||||
}
|
||||
|
||||
function addDays(key: string, delta: number) {
|
||||
const date = parseDateKey(key);
|
||||
date.setDate(date.getDate() + delta);
|
||||
return toDateKey(date);
|
||||
}
|
||||
|
||||
function emptyDay(): DayData {
|
||||
return {
|
||||
foods: [],
|
||||
drinks: [],
|
||||
plannedMeals: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(key: string) {
|
||||
return parseDateKey(key).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function progress(value: number, goal: number) {
|
||||
if (goal <= 0) return 0;
|
||||
return Math.min(100, Math.round((value / goal) * 100));
|
||||
}
|
||||
|
||||
function foodSwatch(name: string) {
|
||||
const colors = [
|
||||
"from-amber-100 to-orange-200 text-orange-700",
|
||||
"from-emerald-100 to-lime-200 text-emerald-700",
|
||||
"from-sky-100 to-blue-200 text-blue-700",
|
||||
"from-rose-100 to-pink-200 text-rose-700",
|
||||
];
|
||||
const index = name.length % colors.length;
|
||||
return colors[index] ?? colors[0];
|
||||
}
|
||||
|
||||
function RecipeAvatar({ name }: { name: string }) {
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className={`flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${foodSwatch(name)} text-lg font-black shadow-inner`}>
|
||||
{initials || "HH"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekStrip({
|
||||
selectedDate,
|
||||
onSelect,
|
||||
}: {
|
||||
selectedDate: string;
|
||||
onSelect: (date: string) => void;
|
||||
}) {
|
||||
const days = useMemo(() => {
|
||||
const selected = parseDateKey(selectedDate);
|
||||
const day = selected.getDay() === 0 ? 7 : selected.getDay();
|
||||
const monday = new Date(selected);
|
||||
monday.setDate(selected.getDate() - day + 1);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + index);
|
||||
return toDateKey(date);
|
||||
});
|
||||
}, [selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{days.map((dateKey) => {
|
||||
const date = parseDateKey(dateKey);
|
||||
const active = dateKey === selectedDate;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={dateKey}
|
||||
onClick={() => onSelect(dateKey)}
|
||||
className={`flex min-h-16 flex-col items-center justify-center rounded-2xl text-sm transition ${
|
||||
active
|
||||
? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100"
|
||||
: "text-gray-400 hover:bg-white/60"
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
|
||||
<span className="text-xl font-black">{date.getDate()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroBar({ carbs, fat, protein }: { carbs: number; fat: number; protein: number }) {
|
||||
const total = Math.max(1, carbs + fat + protein);
|
||||
return (
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="bg-amber-400" style={{ width: `${(carbs / total) * 100}%` }} />
|
||||
<div className="bg-blue-500" style={{ width: `${(fat / total) * 100}%` }} />
|
||||
<div className="bg-green-500" style={{ width: `${(protein / total) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterGlasses({
|
||||
amount,
|
||||
goal,
|
||||
onAdd,
|
||||
locked,
|
||||
}: {
|
||||
amount: number;
|
||||
goal: number;
|
||||
onAdd: () => void;
|
||||
locked: boolean;
|
||||
}) {
|
||||
const glasses = Math.max(6, Math.ceil(goal / 250));
|
||||
const filled = Math.min(glasses, Math.floor(amount / 250));
|
||||
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-6 gap-3 sm:grid-cols-10">
|
||||
{Array.from({ length: glasses }, (_, index) => {
|
||||
const isFilled = index < filled;
|
||||
const isNext = index === filled && !locked;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={locked || (!isNext && !isFilled)}
|
||||
onClick={isNext ? onAdd : undefined}
|
||||
className={`relative h-16 rounded-b-2xl rounded-t-md border border-blue-50 ${
|
||||
isFilled ? "bg-blue-500" : "bg-blue-50"
|
||||
} ${isNext ? "text-gray-950" : "text-transparent"} disabled:cursor-default`}
|
||||
aria-label={isNext ? "250 ml Wasser hinzufügen" : undefined}
|
||||
title={isNext ? "250 ml Wasser hinzufügen" : undefined}
|
||||
>
|
||||
<span className="absolute left-3 top-2 h-9 w-2 rounded-full bg-white/35" />
|
||||
{isNext ? <PlusIcon className="absolute inset-0 m-auto h-6 w-6" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanList({
|
||||
meals,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: {
|
||||
meals: PlannedMeal[];
|
||||
onRemove: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
}) {
|
||||
if (meals.length === 0) {
|
||||
return (
|
||||
<div className="mt-5 rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">
|
||||
Noch keine Mahlzeit geplant.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-4">
|
||||
{meals.map((meal) => (
|
||||
<div key={meal.id} className="flex items-center gap-4 border-b border-gray-100 pb-4 last:border-0 last:pb-0">
|
||||
<RecipeAvatar name={meal.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-lg font-black text-gray-950">{meal.name}</p>
|
||||
<p className="text-sm font-semibold text-gray-400">
|
||||
{meal.mealType}
|
||||
{meal.portions ? ` - ${meal.portions.toLocaleString("de-DE")} Portion` : ""}
|
||||
{meal.plannedCalories ? ` - ${meal.plannedCalories.toLocaleString("de-DE")} kcal` : ""}
|
||||
{meal.note ? ` - ${meal.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{canRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(meal.id)}
|
||||
className="rounded-full bg-gray-100 p-2 text-gray-500 hover:text-red-600"
|
||||
aria-label="Planung löschen"
|
||||
title="Planung löschen"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const todayKey = toDateKey(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(todayKey);
|
||||
const [quickMode, setQuickMode] = useState<"meal" | "plan" | "drink" | "weight">("meal");
|
||||
const [foodForm, setFoodForm] = useState({
|
||||
mealType: "Frühstück" as MealType,
|
||||
name: "",
|
||||
amount: "",
|
||||
calories: "",
|
||||
protein: "",
|
||||
});
|
||||
const [weightForm, setWeightForm] = useState("");
|
||||
const [planForm, setPlanForm] = useState({
|
||||
mealType: "Mittagessen" as MealType,
|
||||
name: "",
|
||||
note: "",
|
||||
portions: "1",
|
||||
plannedCalories: "",
|
||||
});
|
||||
|
||||
const overviewQuery = trpc.health.overview.useQuery({ date: selectedDate });
|
||||
const statsSummaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: todayKey });
|
||||
const weeklyBudget = statsSummaryQuery.data?.week ?? null;
|
||||
const coachRecommendations = overviewQuery.data?.coachRecommendations ?? [];
|
||||
|
||||
function invalidateDay() {
|
||||
utils.health.overview.invalidate({ date: selectedDate });
|
||||
utils.health.statsSummary.invalidate({ referenceDate: todayKey });
|
||||
}
|
||||
|
||||
const addMealMutation = trpc.health.addMealLog.useMutation({ onSuccess: invalidateDay });
|
||||
const addDrinkMutation = trpc.health.addDrinkLog.useMutation({ onSuccess: invalidateDay });
|
||||
const saveWeightMutation = trpc.health.upsertWeightLog.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateDay();
|
||||
utils.health.weightHistory.invalidate();
|
||||
},
|
||||
});
|
||||
const saveMealPlanMutation = trpc.health.upsertMealPlan.useMutation({ onSuccess: invalidateDay });
|
||||
const deleteMealMutation = trpc.health.deleteMealLog.useMutation({ onSuccess: invalidateDay });
|
||||
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({ onSuccess: invalidateDay });
|
||||
|
||||
const goals = overviewQuery.data?.goals
|
||||
? {
|
||||
drinkMl: overviewQuery.data.goals.drinkMl,
|
||||
calories: overviewQuery.data.goals.calories,
|
||||
carbs: Number(overviewQuery.data.goals.carbsG) || fallbackGoals.carbs,
|
||||
fat: Number(overviewQuery.data.goals.fatG) || fallbackGoals.fat,
|
||||
protein: Number(overviewQuery.data.goals.proteinG) || fallbackGoals.protein,
|
||||
fastingHours: overviewQuery.data.goals.fastingHours,
|
||||
}
|
||||
: fallbackGoals;
|
||||
|
||||
const day: DayData = overviewQuery.data?.day
|
||||
? {
|
||||
foods: overviewQuery.data.day.mealLogs.map((meal) => ({
|
||||
id: meal.id,
|
||||
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
|
||||
name: meal.name,
|
||||
amount: meal.amountLabel ?? "1 Portion",
|
||||
calories: meal.calories,
|
||||
protein: Number(meal.proteinG) || 0,
|
||||
})),
|
||||
drinks: overviewQuery.data.day.drinkLogs.map((drink) => ({
|
||||
id: drink.id,
|
||||
amountMl: drink.amountMl,
|
||||
})),
|
||||
weightKg: overviewQuery.data.day.weightLog ? Number(overviewQuery.data.day.weightLog.weightKg) : undefined,
|
||||
plannedMeals: overviewQuery.data.day.mealPlans.map((meal) => ({
|
||||
id: meal.id,
|
||||
mealType: mealTypeFromApi[meal.mealType as ApiMealType] ?? "Snack",
|
||||
name: meal.name,
|
||||
note: meal.note ?? undefined,
|
||||
portions: Number(meal.portions) || 1,
|
||||
plannedCalories: Number(meal.plannedCalories) || 0,
|
||||
})),
|
||||
}
|
||||
: emptyDay();
|
||||
|
||||
const isFuture = selectedDate > todayKey;
|
||||
const isToday = selectedDate === todayKey;
|
||||
const isEditable = selectedDate <= todayKey;
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const calories = day.foods.reduce((sum, food) => sum + (Number(food.calories) || 0), 0);
|
||||
const protein = day.foods.reduce((sum, food) => sum + (Number(food.protein) || 0), 0);
|
||||
const drinkMl = day.drinks.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0);
|
||||
const carbs = Math.round((calories * 0.45) / 4);
|
||||
const fat = Math.round((calories * 0.3) / 9);
|
||||
return { calories, protein, drinkMl, carbs, fat };
|
||||
}, [day.drinks, day.foods]);
|
||||
|
||||
const remainingCalories = Math.max(0, goals.calories - totals.calories);
|
||||
const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2));
|
||||
|
||||
function addFoodEntry() {
|
||||
if (!isEditable || !foodForm.name.trim()) return;
|
||||
addMealMutation.mutate({
|
||||
date: selectedDate,
|
||||
mealType: mealTypeToApi[foodForm.mealType],
|
||||
name: foodForm.name.trim(),
|
||||
amountLabel: foodForm.amount.trim() || "1 Portion",
|
||||
calories: Number(foodForm.calories) || 0,
|
||||
proteinG: Number(foodForm.protein) || 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
});
|
||||
setFoodForm((current) => ({ ...current, name: "", amount: "", calories: "", protein: "" }));
|
||||
}
|
||||
|
||||
function addDrinkEntry() {
|
||||
if (!isEditable) return;
|
||||
addDrinkMutation.mutate({ date: selectedDate, amountMl: 250 });
|
||||
}
|
||||
|
||||
function saveWeight() {
|
||||
if (!isEditable) return;
|
||||
const weightKg = Number(weightForm);
|
||||
if (!weightKg || weightKg <= 0) return;
|
||||
saveWeightMutation.mutate({ date: selectedDate, weightKg, note: null });
|
||||
setWeightForm("");
|
||||
}
|
||||
|
||||
function addPlannedMeal() {
|
||||
if (!planForm.name.trim()) return;
|
||||
saveMealPlanMutation.mutate({
|
||||
date: selectedDate,
|
||||
mealType: mealTypeToApi[planForm.mealType],
|
||||
name: planForm.name.trim(),
|
||||
portions: Number(planForm.portions) || 1,
|
||||
plannedCalories: Number(planForm.plannedCalories) || 0,
|
||||
note: planForm.note.trim() || null,
|
||||
recipeId: null,
|
||||
});
|
||||
setPlanForm((current) => ({ ...current, name: "", note: "", portions: "1", plannedCalories: "" }));
|
||||
}
|
||||
|
||||
function removeEntry(kind: "foods" | "plannedMeals", id: string) {
|
||||
if (kind === "foods") deleteMealMutation.mutate({ id });
|
||||
if (kind === "plannedMeals") deletePlanMutation.mutate({ id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl pb-8">
|
||||
<div className="relative -mx-4 -mt-4 overflow-hidden bg-[#45A85A] px-4 pb-8 pt-7 text-white sm:-mx-6 sm:px-6 lg:mx-0 lg:mt-0 lg:rounded-[2rem]">
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-pink-200/70" />
|
||||
<div className="absolute -left-16 top-24 h-44 w-44 rounded-full bg-teal-100/70" />
|
||||
<div className="relative flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDate((current) => addDays(current, -1))}
|
||||
className="rounded-full bg-white/15 p-3 backdrop-blur"
|
||||
aria-label="Einen Tag zurück"
|
||||
title="Einen Tag zurück"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold uppercase opacity-80">{isToday ? "Heute" : isFuture ? "Planung" : "Rückblick"}</p>
|
||||
<h1 className="text-4xl font-black tracking-normal">{formatDate(selectedDate).split(",")[0]}</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDate((current) => addDays(current, 1))}
|
||||
className="rounded-full bg-white/15 p-3 backdrop-blur"
|
||||
aria-label="Einen Tag vor"
|
||||
title="Einen Tag vor"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-8 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-bold opacity-90">Hellth Hub</p>
|
||||
<p className="mt-1 max-w-xs text-sm font-medium opacity-75">
|
||||
{isFuture
|
||||
? "Zukunftstage sind für Tracking gesperrt, aber für den Essensplan frei."
|
||||
: "Tracking und Tagesziele für deinen Alltag."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/20 text-5xl backdrop-blur">HH</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-8">
|
||||
<WeekStrip selectedDate={selectedDate} onSelect={setSelectedDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-3 space-y-4 lg:-mt-6">
|
||||
{weeklyBudget ? (
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-400">Diese Woche</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">Flexibles Kalorienbudget ohne Tagesstress.</p>
|
||||
</div>
|
||||
<a href="/wochenbudget" className="rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white">Planen</a>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.baseBudget.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-gray-400">Ziel kcal</p></div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{weeklyBudget.eatenCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-gray-400">gegessen/geplant</p></div>
|
||||
<div className="rounded-3xl bg-emerald-50 p-4"><p className="text-2xl font-black text-emerald-700">+{weeklyBudget.activityCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-emerald-700">Sportbonus</p></div>
|
||||
<div className="rounded-3xl bg-blue-50 p-4"><p className="text-2xl font-black text-blue-700">{weeklyBudget.availableCalories.toLocaleString("de-DE")}</p><p className="text-xs font-bold text-blue-700">verfügbar</p></div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<FireIcon className="h-5 w-5 text-orange-500" />
|
||||
<p className="text-lg font-black">Kalorien übrig</p>
|
||||
</div>
|
||||
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
|
||||
{remainingCalories.toLocaleString("de-DE")} <span className="text-3xl text-gray-300">kcal</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
|
||||
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100"
|
||||
aria-label="Eintrag hinzufügen"
|
||||
title="Eintrag hinzufügen"
|
||||
>
|
||||
<PlusIcon className="h-8 w-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<MacroBar carbs={goals.carbs} fat={goals.fat} protein={goals.protein} />
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Kohlenhydr.</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.carbs}<span className="text-gray-300">/{goals.carbs} g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Fett</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.fat}<span className="text-gray-300">/{goals.fat} g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Proteine</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{Math.round(totals.protein)}<span className="text-gray-300">/{goals.protein} g</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
|
||||
<span>Ziel: {goals.calories.toLocaleString("de-DE")} kcal</span>
|
||||
<EllipsisHorizontalIcon className="h-7 w-7" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{coachRecommendations.length > 0 ? (
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-400">Coach</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">Persönliche Vorschläge aus deinem Fragebogen.</p>
|
||||
</div>
|
||||
<a href="/onboarding" className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-950">
|
||||
Anpassen
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-3">
|
||||
{coachRecommendations.slice(0, 3).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-3xl p-4 ${
|
||||
item.priority === "high"
|
||||
? "bg-emerald-50 text-emerald-950"
|
||||
: item.priority === "medium"
|
||||
? "bg-blue-50 text-blue-950"
|
||||
: "bg-gray-50 text-gray-950"
|
||||
}`}
|
||||
>
|
||||
<p className="font-black">{item.title}</p>
|
||||
<p className="mt-2 text-sm font-semibold opacity-75">{item.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-black text-gray-400">Ernährungsplan</h2>
|
||||
<a href="/planung" className="rounded-full border border-gray-200 px-5 py-2 text-sm font-black text-gray-900 hover:bg-gray-50 dark:text-neutral-100">
|
||||
Alle ansehen
|
||||
</a>
|
||||
</div>
|
||||
<PlanList
|
||||
meals={day.plannedMeals.slice(0, 5)}
|
||||
onRemove={(id) => removeEntry("plannedMeals", id)}
|
||||
canRemove={day.plannedMeals.length > 0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<BeakerIcon className="h-5 w-5 text-blue-500" />
|
||||
<h2 className="text-xl font-black">Wasser</h2>
|
||||
</div>
|
||||
<p className="mt-5 text-5xl font-black text-gray-950 dark:text-neutral-100">
|
||||
{totals.drinkMl} <span className="text-3xl text-gray-300">ml</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addDrinkEntry}
|
||||
disabled={!isEditable || addDrinkMutation.isPending}
|
||||
className="grid h-20 w-20 place-items-center rounded-full bg-gray-50 text-gray-950 ring-[10px] ring-gray-100 disabled:opacity-40"
|
||||
aria-label="250 ml Wasser hinzufügen"
|
||||
title="250 ml Wasser hinzufügen"
|
||||
>
|
||||
<PlusIcon className="h-8 w-8" />
|
||||
</button>
|
||||
</div>
|
||||
<WaterGlasses amount={totals.drinkMl} goal={goals.drinkMl} onAdd={addDrinkEntry} locked={!isEditable || addDrinkMutation.isPending} />
|
||||
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
|
||||
<span>Tagesziel: {goals.drinkMl} ml</span>
|
||||
<EllipsisHorizontalIcon className="h-7 w-7" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<ClockIcon className="h-5 w-5 text-orange-500" />
|
||||
<h2 className="text-xl font-black">Fasten</h2>
|
||||
</div>
|
||||
<p className="mt-5 text-4xl font-black text-gray-200">Bereit zum Starten?</p>
|
||||
<p className="mt-5 text-sm font-bold text-gray-400">Ziel: {goals.fastingHours} h</p>
|
||||
</div>
|
||||
<button type="button" className="grid h-20 w-20 place-items-center rounded-full bg-gray-100 text-sm font-black text-gray-950">
|
||||
START
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<ScaleIcon className="h-5 w-5 text-rose-500" />
|
||||
<h2 className="text-xl font-black">Gewicht</h2>
|
||||
</div>
|
||||
<p className="mt-5 text-4xl font-black text-gray-950 dark:text-neutral-100">
|
||||
{day.weightKg ? `${day.weightKg} kg` : "-"}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-bold text-gray-400">{isEditable ? "Ein Tageswert reicht." : "Für Zukunftstage gesperrt."}</p>
|
||||
</div>
|
||||
<div className="grid h-20 w-20 place-items-center rounded-full bg-green-50 text-2xl font-black text-green-600">
|
||||
{nutritionScore}
|
||||
</div>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<div className="mt-6 grid grid-cols-[1fr_96px] gap-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={weightForm}
|
||||
onChange={(event) => setWeightForm(event.target.value)}
|
||||
placeholder={day.weightKg ? String(day.weightKg) : "kg"}
|
||||
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={saveWeight} disabled={saveWeightMutation.isPending} className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-black text-gray-400">{isFuture ? "Gericht planen" : "Schnellerfassung"}</h2>
|
||||
{isFuture ? <span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-bold text-amber-700">Tracking gesperrt</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 rounded-full bg-gray-100 p-1 sm:grid-cols-4">
|
||||
{(["meal", "plan", "drink", "weight"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setQuickMode(mode)}
|
||||
disabled={isFuture && mode !== "plan"}
|
||||
className={`rounded-full px-4 py-2 text-sm font-black disabled:opacity-40 ${
|
||||
quickMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{mode === "meal" ? "Essen" : mode === "plan" ? "Planen" : mode === "drink" ? "Wasser" : "Gewicht"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quickMode === "meal" ? (
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-12">
|
||||
<select value={foodForm.mealType} onChange={(event) => setFoodForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
|
||||
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
|
||||
</select>
|
||||
<input value={foodForm.name} onChange={(event) => setFoodForm((current) => ({ ...current, name: event.target.value }))} placeholder="z. B. Skyr mit Haferflocken" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
|
||||
<input value={foodForm.amount} onChange={(event) => setFoodForm((current) => ({ ...current, amount: event.target.value }))} placeholder="Menge" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2" />
|
||||
<input type="number" value={foodForm.calories} onChange={(event) => setFoodForm((current) => ({ ...current, calories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
|
||||
<input type="number" value={foodForm.protein} onChange={(event) => setFoodForm((current) => ({ ...current, protein: event.target.value }))} placeholder="Protein" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
|
||||
<button type="button" onClick={addFoodEntry} disabled={!isEditable || addMealMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-2">
|
||||
Protokollieren
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickMode === "plan" ? (
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-12">
|
||||
<select value={planForm.mealType} onChange={(event) => setPlanForm((current) => ({ ...current, mealType: event.target.value as MealType }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-2">
|
||||
{mealTypes.map((mealType) => <option key={mealType}>{mealType}</option>)}
|
||||
</select>
|
||||
<input value={planForm.name} onChange={(event) => setPlanForm((current) => ({ ...current, name: event.target.value }))} placeholder="Geplantes Gericht" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-4" />
|
||||
<input value={planForm.note} onChange={(event) => setPlanForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz / Reste" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-3" />
|
||||
<input type="number" min="0.25" step="0.25" value={planForm.portions} onChange={(event) => setPlanForm((current) => ({ ...current, portions: event.target.value }))} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
|
||||
<input type="number" value={planForm.plannedCalories} onChange={(event) => setPlanForm((current) => ({ ...current, plannedCalories: event.target.value }))} placeholder="kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm lg:col-span-1" />
|
||||
<button type="button" onClick={addPlannedMeal} disabled={saveMealPlanMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40 lg:col-span-1">
|
||||
Planen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickMode === "drink" ? (
|
||||
<button type="button" onClick={addDrinkEntry} disabled={!isEditable || addDrinkMutation.isPending} className="mt-5 w-full rounded-2xl bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-40">
|
||||
250 ml Wasser eintragen
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{quickMode === "weight" ? (
|
||||
<div className="mt-5 grid grid-cols-[1fr_120px] gap-3">
|
||||
<input type="number" step="0.1" value={weightForm} onChange={(event) => setWeightForm(event.target.value)} placeholder="kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
||||
<button type="button" onClick={saveWeight} disabled={!isEditable || saveWeightMutation.isPending} className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{day.foods.length > 0 ? (
|
||||
<div className="mt-6 space-y-3">
|
||||
{day.foods.map((food) => (
|
||||
<div key={food.id} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
||||
<RecipeAvatar name={food.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-black text-gray-950">{food.name}</p>
|
||||
<p className="text-xs font-bold text-gray-400">{food.mealType} - {food.amount} - {food.calories ?? 0} kcal</p>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<button type="button" onClick={() => removeEntry("foods", food.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Eintrag löschen" title="Eintrag löschen">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickMode(isFuture ? "plan" : "meal")}
|
||||
className="fixed bottom-24 right-5 z-40 grid h-20 w-20 place-items-center rounded-full bg-gray-950 text-white shadow-2xl lg:hidden"
|
||||
aria-label="Neuen Eintrag starten"
|
||||
title="Neuen Eintrag starten"
|
||||
>
|
||||
<PlusIcon className="h-9 w-9" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
apps/admin/app/(authed)/planung/page.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Cog6ToothIcon,
|
||||
PlusIcon,
|
||||
ShoppingCartIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CatalogFood,
|
||||
MealType,
|
||||
PortionUnit,
|
||||
Recipe,
|
||||
calcRecipe,
|
||||
todayKey,
|
||||
weekDates,
|
||||
} from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type ApiMealType = "breakfast" | "lunch" | "dinner" | "snack";
|
||||
const mealTypes: MealType[] = ["Frühstück", "Mittagessen", "Abendessen", "Snack"];
|
||||
const mealTypeToApi: Record<MealType, ApiMealType> = {
|
||||
Frühstück: "breakfast",
|
||||
Mittagessen: "lunch",
|
||||
Abendessen: "dinner",
|
||||
Snack: "snack",
|
||||
};
|
||||
const mealTypeFromApi: Record<ApiMealType, MealType> = {
|
||||
breakfast: "Frühstück",
|
||||
lunch: "Mittagessen",
|
||||
dinner: "Abendessen",
|
||||
snack: "Snack",
|
||||
};
|
||||
|
||||
function toNumber(value: unknown) {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
function parseDateKey(key: string) {
|
||||
return new Date(`${key}T12:00:00`);
|
||||
}
|
||||
|
||||
function apiFoodToCatalog(food: {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
category: string;
|
||||
kcalPer100g: unknown;
|
||||
proteinPer100g: unknown;
|
||||
carbsPer100g: unknown;
|
||||
fatPer100g: unknown;
|
||||
servingG: unknown;
|
||||
preferredUnit: PortionUnit | null;
|
||||
densityGPerMl: unknown | null;
|
||||
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
|
||||
barcode: string | null;
|
||||
availableEverywhere: boolean;
|
||||
}): CatalogFood {
|
||||
return {
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
brand: food.brand ?? undefined,
|
||||
category: food.category,
|
||||
kcalPer100g: toNumber(food.kcalPer100g),
|
||||
proteinPer100g: toNumber(food.proteinPer100g),
|
||||
carbsPer100g: toNumber(food.carbsPer100g),
|
||||
fatPer100g: toNumber(food.fatPer100g),
|
||||
servingG: toNumber(food.servingG) || 100,
|
||||
preferredUnit: food.preferredUnit ?? "g",
|
||||
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
|
||||
source: food.source,
|
||||
barcode: food.barcode ?? undefined,
|
||||
availableEverywhere: food.availableEverywhere,
|
||||
};
|
||||
}
|
||||
|
||||
function apiRecipeToRecipe(recipe: {
|
||||
id: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
minutes: number;
|
||||
notes: string | null;
|
||||
ingredients: Array<{
|
||||
foodId: string;
|
||||
amountG: unknown;
|
||||
displayAmount: unknown | null;
|
||||
displayUnit: PortionUnit | null;
|
||||
portionId: string | null;
|
||||
}>;
|
||||
}): Recipe {
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
portions: recipe.portions,
|
||||
minutes: recipe.minutes,
|
||||
notes: recipe.notes ?? undefined,
|
||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
||||
foodId: ingredient.foodId,
|
||||
amountG: toNumber(ingredient.amountG),
|
||||
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
|
||||
unit: ingredient.displayUnit ?? undefined,
|
||||
portionId: ingredient.portionId ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function foodSwatch(name: string) {
|
||||
const colors = [
|
||||
"from-amber-100 to-orange-200 text-orange-700",
|
||||
"from-emerald-100 to-lime-200 text-emerald-700",
|
||||
"from-sky-100 to-blue-200 text-blue-700",
|
||||
"from-rose-100 to-pink-200 text-rose-700",
|
||||
];
|
||||
return colors[name.length % colors.length] ?? colors[0];
|
||||
}
|
||||
|
||||
function RecipeAvatar({ name }: { name: string }) {
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${foodSwatch(name)} text-base font-black shadow-inner`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanungPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedDate, setSelectedDate] = useState(todayKey());
|
||||
const [recipeId, setRecipeId] = useState("");
|
||||
const [mealType, setMealType] = useState<MealType>("Mittagessen");
|
||||
const [portions, setPortions] = useState("1");
|
||||
const [plannedCalories, setPlannedCalories] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const days = useMemo(() => weekDates(selectedDate), [selectedDate]);
|
||||
const startsOn = days[0]!;
|
||||
const endsOn = days[6]!;
|
||||
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
|
||||
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
|
||||
const plansQuery = trpc.health.mealPlansRange.useQuery({ startsOn, endsOn });
|
||||
const savePlanMutation = trpc.health.upsertMealPlan.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
|
||||
setStatus("Gericht geplant.");
|
||||
},
|
||||
});
|
||||
const deletePlanMutation = trpc.health.deleteMealPlan.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.mealPlansRange.invalidate({ startsOn, endsOn });
|
||||
setStatus("Eintrag entfernt.");
|
||||
},
|
||||
});
|
||||
const generateShoppingListMutation = trpc.health.generateShoppingList.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.shoppingLists.invalidate();
|
||||
setStatus("Einkaufsliste aus der aktuellen Woche erstellt.");
|
||||
},
|
||||
});
|
||||
|
||||
const foods = useMemo(() => (foodsQuery.data ?? []).map(apiFoodToCatalog), [foodsQuery.data]);
|
||||
const recipes = useMemo(() => (recipesQuery.data ?? []).map(apiRecipeToRecipe), [recipesQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recipeId || !recipes[0]) return;
|
||||
setRecipeId(recipes[0].id);
|
||||
}, [recipeId, recipes]);
|
||||
|
||||
const selectedMeals = useMemo(
|
||||
() => (plansQuery.data ?? []).filter((meal) => meal.date === selectedDate),
|
||||
[plansQuery.data, selectedDate],
|
||||
);
|
||||
const selectedRecipe = useMemo(() => recipes.find((item) => item.id === recipeId) ?? null, [recipeId, recipes]);
|
||||
const calculatedCalories = useMemo(() => {
|
||||
if (!selectedRecipe) return 0;
|
||||
const totals = calcRecipe(selectedRecipe, foods, selectedRecipe.portions);
|
||||
return Math.round(totals.kcal * (Number(portions) || 1));
|
||||
}, [foods, portions, selectedRecipe]);
|
||||
|
||||
const mealsByType = useMemo(() => {
|
||||
return mealTypes.reduce<Record<MealType, typeof selectedMeals>>((groups, type) => {
|
||||
groups[type] = selectedMeals.filter((meal) => mealTypeFromApi[meal.mealType as ApiMealType] === type);
|
||||
return groups;
|
||||
}, {} as Record<MealType, typeof selectedMeals>);
|
||||
}, [selectedMeals]);
|
||||
|
||||
const rangeLabel = `${parseDateKey(startsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })} - ${parseDateKey(endsOn).toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}`;
|
||||
|
||||
function addPlannedMeal() {
|
||||
const recipe = selectedRecipe;
|
||||
if (!recipe) return;
|
||||
savePlanMutation.mutate({
|
||||
date: selectedDate,
|
||||
mealType: mealTypeToApi[mealType],
|
||||
recipeId: recipe.id,
|
||||
name: recipe.name,
|
||||
portions: Number(portions) || 1,
|
||||
plannedCalories: Number(plannedCalories) || calculatedCalories,
|
||||
note: recipe.notes ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshShoppingList() {
|
||||
generateShoppingListMutation.mutate({
|
||||
startsOn,
|
||||
endsOn,
|
||||
name: `Einkauf ${rangeLabel}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||||
<header className="space-y-6 px-1 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Zurück" title="Zurück">
|
||||
<ArrowLeftIcon className="h-7 w-7" />
|
||||
</Link>
|
||||
<Link href="/einstellungen" className="rounded-full p-2 text-gray-950 hover:bg-white" aria-label="Einstellungen" title="Einstellungen">
|
||||
<Cog6ToothIcon className="h-7 w-7" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-5xl font-black tracking-normal text-gray-950">Ernährungsplan</h1>
|
||||
<p className="mt-3 text-xl font-black text-gray-400">{rangeLabel}</p>
|
||||
</div>
|
||||
<button type="button" onClick={refreshShoppingList} className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 px-5 py-3 text-base font-black text-gray-950">
|
||||
<ShoppingCartIcon className="h-5 w-5" />
|
||||
Einkaufsliste erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{days.map((dateKey) => {
|
||||
const date = parseDateKey(dateKey);
|
||||
const active = dateKey === selectedDate;
|
||||
const count = (plansQuery.data ?? []).filter((meal) => meal.date === dateKey).length;
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(dateKey)}
|
||||
className={`flex min-h-20 flex-col items-center justify-center rounded-3xl text-sm ${
|
||||
active ? "bg-white text-gray-950 shadow-sm ring-1 ring-gray-100" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className="font-black uppercase">{date.toLocaleDateString("de-DE", { weekday: "narrow" })}</span>
|
||||
<span className="text-2xl font-black">{date.getDate()}</span>
|
||||
{count ? <span className="mt-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-700">{count}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_150px_100px_110px_52px]">
|
||||
<select value={recipeId} onChange={(event) => setRecipeId(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
{recipes.map((recipe) => (
|
||||
<option key={recipe.id} value={recipe.id}>
|
||||
{recipe.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={mealType} onChange={(event) => setMealType(event.target.value as MealType)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||||
{mealTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="Portionen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<input value={plannedCalories} onChange={(event) => setPlannedCalories(event.target.value)} placeholder={`${calculatedCalories || 0} kcal`} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||||
<button type="button" onClick={addPlannedMeal} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Planen" title="Planen">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{status ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{mealTypes.map((type) => (
|
||||
<section key={type} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-400">{type}</h2>
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{mealsByType[type].map((meal) => {
|
||||
const recipe = recipes.find((item) => item.id === meal.recipeId);
|
||||
const totals = recipe ? calcRecipe(recipe, foods, recipe.portions) : null;
|
||||
const calories = Number(meal.plannedCalories) || (totals ? Math.round(totals.kcal * Number(meal.portions)) : 0);
|
||||
return (
|
||||
<div key={meal.id} className="flex items-center gap-4 py-4">
|
||||
<RecipeAvatar name={meal.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-lg font-black leading-tight text-gray-950">{meal.name}</p>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">
|
||||
{Number(meal.portions)} Portion(en)
|
||||
{calories ? ` · ${calories} kcal geplant` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => deletePlanMutation.mutate({ id: meal.id })} className="grid h-10 w-10 place-items-center rounded-2xl bg-gray-100 text-gray-400" aria-label="Entfernen" title="Entfernen">
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!mealsByType[type].length ? <p className="py-4 text-sm font-bold text-gray-300">Noch nichts geplant.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
192
apps/admin/app/(authed)/rezepte/eier-muffins/page.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ClockIcon,
|
||||
FireIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import {
|
||||
RecipeRating,
|
||||
loadHellthData,
|
||||
recipeRatingSummary,
|
||||
saveHellthData,
|
||||
uid,
|
||||
} from "@/lib/hellth-data";
|
||||
|
||||
const recipeId = "recipe-eggs";
|
||||
|
||||
function MacroBar() {
|
||||
return (
|
||||
<div className="flex h-4 overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="w-[22%] bg-amber-400" />
|
||||
<div className="w-[33%] bg-blue-500" />
|
||||
<div className="flex-1 bg-green-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipePage() {
|
||||
const { data: session } = useSession();
|
||||
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||||
const displayName = session?.user?.name || session?.user?.email || "Du";
|
||||
const [ratings, setRatings] = useState<RecipeRating[]>([]);
|
||||
const [rating, setRating] = useState(5);
|
||||
const [comment, setComment] = useState("");
|
||||
const summary = useMemo(() => recipeRatingSummary(recipeId, ratings), [ratings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userKey) return;
|
||||
const data = loadHellthData(userKey);
|
||||
setRatings(data.recipeRatings);
|
||||
const own = data.recipeRatings.find((item) => item.recipeId === recipeId && item.userKey === userKey);
|
||||
if (own) {
|
||||
setRating(own.rating);
|
||||
setComment(own.comment ?? "");
|
||||
}
|
||||
}, [userKey]);
|
||||
|
||||
function saveRating() {
|
||||
if (!userKey) return;
|
||||
const data = loadHellthData(userKey);
|
||||
const nextRating: RecipeRating = {
|
||||
id: data.recipeRatings.find((item) => item.recipeId === recipeId && item.userKey === userKey)?.id ?? uid("rating"),
|
||||
recipeId,
|
||||
userKey,
|
||||
displayName,
|
||||
rating,
|
||||
comment: comment.trim() || undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const nextRatings = [
|
||||
nextRating,
|
||||
...data.recipeRatings.filter((item) => !(item.recipeId === recipeId && item.userKey === userKey)),
|
||||
];
|
||||
saveHellthData({ ...data, recipeRatings: nextRatings }, userKey);
|
||||
setRatings(nextRatings);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pb-24">
|
||||
<section className="rounded-[2.5rem] bg-white px-6 pb-8 pt-5 shadow-sm ring-1 ring-gray-100 sm:px-10">
|
||||
<div className="mx-auto mb-10 h-2 w-14 rounded-full bg-gray-200" />
|
||||
<div className="mx-auto flex h-56 w-56 items-center justify-center rounded-full bg-gradient-to-br from-amber-100 to-orange-200 text-6xl font-black text-orange-700 shadow-inner">
|
||||
EM
|
||||
</div>
|
||||
|
||||
<h1 className="mt-10 text-center text-5xl font-black leading-none tracking-normal text-gray-950">
|
||||
Eier-Muffins mit Paprika
|
||||
</h1>
|
||||
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<FireIcon className="mx-auto h-9 w-9 text-green-500" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">523 <span className="text-xl">kcal</span></p>
|
||||
<p className="text-sm font-black text-gray-400">Pro Portion</p>
|
||||
</div>
|
||||
<div>
|
||||
<ClockIcon className="mx-auto h-9 w-9 text-orange-500" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">20<span className="text-xl"> Min</span></p>
|
||||
<p className="text-sm font-black text-gray-400">Kochzeit</p>
|
||||
</div>
|
||||
<div>
|
||||
<ScaleIcon className="mx-auto h-9 w-9 text-blue-400" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">88<span className="text-xl">/100</span></p>
|
||||
<p className="text-sm font-black text-gray-400">Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[2rem] bg-amber-50 p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">User-Bewertung</h2>
|
||||
<p className="mt-1 text-sm font-bold text-amber-800">
|
||||
{summary.count ? `${summary.average}/5 aus ${summary.count} Bewertung(en)` : "Noch keine Bewertungen."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setRating(value)}
|
||||
className={`text-3xl ${value <= rating ? "text-amber-500" : "text-amber-200"}`}
|
||||
aria-label={`${value} Sterne`}
|
||||
title={`${value} Sterne`}
|
||||
>
|
||||
*
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Kurzer Kommentar: schmeckt gut, sättigt, Meal-Prep geeignet..."
|
||||
className="mt-4 min-h-24 w-full rounded-3xl border border-amber-100 bg-white px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
<button type="button" onClick={saveRating} className="mt-3 rounded-full bg-gray-950 px-5 py-3 text-sm font-black text-white">
|
||||
Bewertung speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-10 h-px bg-gray-100" />
|
||||
|
||||
<h2 className="text-xl font-black text-gray-400">Makros</h2>
|
||||
<div className="mt-6">
|
||||
<MacroBar />
|
||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-400">Kohlenhydr.</p>
|
||||
<p className="mt-2 text-4xl font-black text-gray-950">20 <span className="text-gray-300">g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-400">Fett</p>
|
||||
<p className="mt-2 text-4xl font-black text-gray-950">30 <span className="text-gray-300">g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-400">Proteine</p>
|
||||
<p className="mt-2 text-4xl font-black text-gray-950">41 <span className="text-gray-300">g</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-10 h-px bg-gray-100" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-black text-gray-400">Zutaten</h2>
|
||||
<button type="button" className="text-lg font-black text-gray-400">1 Portionen</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[2rem] bg-gray-50 p-5">
|
||||
{[
|
||||
["Paprika", "150 g"],
|
||||
["Eier", "3 Stück"],
|
||||
["Hirtenkäse", "60 g"],
|
||||
["Zwiebel", "1/2 Stück"],
|
||||
].map(([name, amount]) => (
|
||||
<div key={name} className="flex items-center justify-between border-b border-gray-200 py-4 last:border-0">
|
||||
<p className="text-lg font-black text-gray-950">{name}</p>
|
||||
<p className="text-lg font-black text-gray-400">{amount}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-20 z-40 mx-auto grid max-w-md grid-cols-2 gap-3 px-5 lg:bottom-6">
|
||||
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-4 text-base font-black text-gray-950 shadow-sm">
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
Tauschen
|
||||
</button>
|
||||
<Link href="/" className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-4 text-base font-black text-white shadow-sm">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Protokollieren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
apps/admin/app/(authed)/rezepte/page.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
"use client";
|
||||
|
||||
import { PlusIcon, StarIcon } from "@heroicons/react/24/outline";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormField, inputClassName } from "@/app/_components/form-field";
|
||||
import {
|
||||
CatalogFood,
|
||||
PortionUnit,
|
||||
Recipe,
|
||||
amountToGrams,
|
||||
calcRecipe,
|
||||
classifyRecipe,
|
||||
describeIngredientAmount,
|
||||
unitLabel,
|
||||
} from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
const recipeUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
|
||||
|
||||
function toNumber(value: unknown) {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
function toCatalogFood(food: {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
category: string;
|
||||
kcalPer100g: unknown;
|
||||
proteinPer100g: unknown;
|
||||
carbsPer100g: unknown;
|
||||
fatPer100g: unknown;
|
||||
servingG: unknown;
|
||||
preferredUnit: PortionUnit | null;
|
||||
densityGPerMl: unknown | null;
|
||||
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
|
||||
barcode: string | null;
|
||||
availableEverywhere: boolean;
|
||||
portions?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
unit: PortionUnit;
|
||||
amount: unknown;
|
||||
gramsEquivalent: unknown;
|
||||
}>;
|
||||
}): CatalogFood {
|
||||
return {
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
brand: food.brand ?? undefined,
|
||||
category: food.category,
|
||||
kcalPer100g: toNumber(food.kcalPer100g),
|
||||
proteinPer100g: toNumber(food.proteinPer100g),
|
||||
carbsPer100g: toNumber(food.carbsPer100g),
|
||||
fatPer100g: toNumber(food.fatPer100g),
|
||||
servingG: toNumber(food.servingG) || 100,
|
||||
preferredUnit: food.preferredUnit ?? "g",
|
||||
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
|
||||
portions: food.portions?.map((portion) => ({
|
||||
id: portion.id,
|
||||
label: portion.label,
|
||||
unit: portion.unit,
|
||||
amount: toNumber(portion.amount),
|
||||
gramsEquivalent: toNumber(portion.gramsEquivalent),
|
||||
})),
|
||||
source: food.source,
|
||||
barcode: food.barcode ?? undefined,
|
||||
availableEverywhere: food.availableEverywhere,
|
||||
};
|
||||
}
|
||||
|
||||
function recipeFromApi(recipe: {
|
||||
id: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
minutes: number;
|
||||
notes: string | null;
|
||||
ingredients: Array<{
|
||||
foodId: string;
|
||||
amountG: unknown;
|
||||
displayAmount: unknown | null;
|
||||
displayUnit: PortionUnit | null;
|
||||
portionId: string | null;
|
||||
}>;
|
||||
}): Recipe {
|
||||
return {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
portions: recipe.portions,
|
||||
minutes: recipe.minutes,
|
||||
notes: recipe.notes ?? undefined,
|
||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
||||
foodId: ingredient.foodId,
|
||||
amountG: toNumber(ingredient.amountG),
|
||||
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
|
||||
unit: ingredient.displayUnit ?? undefined,
|
||||
portionId: ingredient.portionId ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function MacroBar({ distribution }: { distribution: { carbs: number; fat: number; protein: number } }) {
|
||||
return (
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="bg-amber-400" style={{ width: `${distribution.carbs}%` }} />
|
||||
<div className="bg-blue-500" style={{ width: `${distribution.fat}%` }} />
|
||||
<div className="bg-emerald-500" style={{ width: `${distribution.protein}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipeNutritionInsight({ recipe, foods }: { recipe: Recipe; foods: CatalogFood[] }) {
|
||||
const totals = calcRecipe(recipe, foods);
|
||||
const classification = classifyRecipe(recipe, foods);
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classification.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<MacroBar distribution={classification.distribution} />
|
||||
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{classification.criteria.map((criterion) => (
|
||||
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
|
||||
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
|
||||
<span className="text-gray-400">{criterion.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RezeptePage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [name, setName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [minutes, setMinutes] = useState("20");
|
||||
const [portions, setPortions] = useState("2");
|
||||
const [mealPrepDays, setMealPrepDays] = useState("1");
|
||||
const [foodId, setFoodId] = useState("");
|
||||
const [amount, setAmount] = useState("100");
|
||||
const [unit, setUnit] = useState<PortionUnit>("g");
|
||||
const [portionId, setPortionId] = useState("");
|
||||
const [draftIngredients, setDraftIngredients] = useState<Recipe["ingredients"]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [suggestMealPrepDays, setSuggestMealPrepDays] = useState("0");
|
||||
const [suggestCooksPerDay, setSuggestCooksPerDay] = useState("1");
|
||||
|
||||
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
|
||||
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
|
||||
const recommendationsQuery = trpc.health.recipeRecommendations.useQuery({
|
||||
mealPrepDays: Number(suggestMealPrepDays) || 0,
|
||||
cooksPerDay: Number(suggestCooksPerDay) || 1,
|
||||
limit: 8,
|
||||
});
|
||||
const submissionsQuery = trpc.health.myRecipeSubmissions.useQuery();
|
||||
const saveRecipeMutation = trpc.health.saveRecipe.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.listRecipes.invalidate();
|
||||
await utils.health.recipeRecommendations.invalidate();
|
||||
setName("");
|
||||
setNotes("");
|
||||
setDraftIngredients([]);
|
||||
setStatus("Rezept gespeichert.");
|
||||
},
|
||||
});
|
||||
const submitRecipeMutation = trpc.health.submitRecipeSuggestion.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
await utils.health.myRecipeSubmissions.invalidate();
|
||||
await utils.health.recipeRecommendations.invalidate();
|
||||
await utils.health.clubLeaderboard.invalidate();
|
||||
await utils.health.activityWeek.invalidate();
|
||||
setName("");
|
||||
setNotes("");
|
||||
setDraftIngredients([]);
|
||||
setStatus(`Rezeptvorschlag wurde zur Prüfung eingereicht. +${result.points} Hellfireclub-Punkte.`);
|
||||
},
|
||||
});
|
||||
|
||||
const foods = useMemo(() => (foodsQuery.data ?? []).map(toCatalogFood), [foodsQuery.data]);
|
||||
const recipes = useMemo(() => (recipesQuery.data ?? []).map(recipeFromApi), [recipesQuery.data]);
|
||||
const foodById = useMemo(() => new Map(foods.map((food) => [food.id, food])), [foods]);
|
||||
const selectedFood = foodId ? foodById.get(foodId) : foods[0];
|
||||
const draftRecipe = useMemo<Recipe>(() => ({
|
||||
id: "draft",
|
||||
name: name.trim() || "Entwurf",
|
||||
portions: Number(portions) || 1,
|
||||
minutes: Number(minutes) || 0,
|
||||
notes: notes.trim() || undefined,
|
||||
ingredients: draftIngredients,
|
||||
}), [draftIngredients, minutes, name, notes, portions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (foodId || !foods[0]) return;
|
||||
setFoodId(foods[0].id);
|
||||
setUnit(foods[0].preferredUnit ?? "g");
|
||||
}, [foodId, foods]);
|
||||
|
||||
function recipePayload() {
|
||||
return {
|
||||
name: name.trim(),
|
||||
portions: Number(portions) || 1,
|
||||
minutes: Number(minutes) || 0,
|
||||
notes: notes.trim() || null,
|
||||
mealPrepDays: Number(mealPrepDays) || 0,
|
||||
ingredients: draftIngredients.map((ingredient, index) => ({
|
||||
foodId: ingredient.foodId,
|
||||
amountG: ingredient.amountG,
|
||||
displayAmount: ingredient.amount ?? null,
|
||||
displayUnit: ingredient.unit ?? null,
|
||||
portionId: ingredient.portionId ?? null,
|
||||
sortOrder: index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
if (!selectedFood) return;
|
||||
const amountNumber = Number(amount.replace(",", ".")) || 1;
|
||||
const amountG = amountToGrams(selectedFood, amountNumber, unit, portionId || undefined);
|
||||
setDraftIngredients((current) => [
|
||||
...current,
|
||||
{
|
||||
foodId: selectedFood.id,
|
||||
amount: amountNumber,
|
||||
unit,
|
||||
portionId: portionId || undefined,
|
||||
amountG: Math.round(amountG * 10) / 10,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function saveRecipe() {
|
||||
if (!name.trim() || draftIngredients.length === 0) return;
|
||||
saveRecipeMutation.mutate({ ...recipePayload(), isPublic: false });
|
||||
}
|
||||
|
||||
function submitRecipeSuggestion() {
|
||||
if (!name.trim() || draftIngredients.length === 0) return;
|
||||
submitRecipeMutation.mutate(recipePayload());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Gerichte & Rezepte</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Zutaten aus dem Lebensmittelkatalog, Alltagseinheiten, Portionen, Nährwerte und Meal-Prep-Notizen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
|
||||
<div className="space-y-4">
|
||||
{recipes.map((recipe) => {
|
||||
const totals = calcRecipe(recipe, foods);
|
||||
return (
|
||||
<article key={recipe.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-black text-gray-950">{recipe.name}</p>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">
|
||||
{recipe.minutes} min · {recipe.portions} Portionen
|
||||
{recipe.notes ? ` · ${recipe.notes}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="rounded-full bg-green-50 px-4 py-2 text-sm font-black text-green-700">{totals.kcal} kcal</div>
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-4 py-2 text-sm font-black text-amber-700">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
Bewertung folgt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH</p></div>
|
||||
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
|
||||
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<RecipeNutritionInsight recipe={recipe} foods={foods} />
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
{recipe.ingredients.map((ingredient, index) => (
|
||||
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 text-sm font-bold text-gray-500">
|
||||
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name ?? "Unbekannt"}</span>
|
||||
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{!recipes.length && !recipesQuery.isLoading ? (
|
||||
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
|
||||
Noch keine Rezepte gespeichert.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Vorschläge für dich</h2>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Mealprep Tage</span>
|
||||
<select value={suggestMealPrepDays} onChange={(event) => setSuggestMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
|
||||
<option value="0">kein Fokus</option>
|
||||
<option value="2">2 Tage</option>
|
||||
<option value="3">3 Tage</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
|
||||
<select value={suggestCooksPerDay} onChange={(event) => setSuggestCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
|
||||
<option value="1">1x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(recommendationsQuery.data ?? []).map((recommendation) => (
|
||||
<article key={recommendation.id} className="rounded-3xl bg-gray-50 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-black text-gray-950">{recommendation.name}</p>
|
||||
<p className="mt-1 text-xs font-bold text-gray-400">
|
||||
{recommendation.minutes} min · Mealprep {recommendation.mealPrepDays || 1} Tag(e)
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-[#0F766E]">{recommendation.score}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
||||
<div><p className="text-sm font-black">{recommendation.nutrition.kcal}</p><p className="text-[10px] font-bold text-gray-400">kcal</p></div>
|
||||
<div><p className="text-sm font-black">{recommendation.nutrition.carbs} g</p><p className="text-[10px] font-bold text-gray-400">KH</p></div>
|
||||
<div><p className="text-sm font-black">{recommendation.nutrition.fat} g</p><p className="text-[10px] font-bold text-gray-400">Fett</p></div>
|
||||
<div><p className="text-sm font-black">{recommendation.nutrition.protein} g</p><p className="text-[10px] font-bold text-gray-400">Eiweiß</p></div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[...recommendation.tags, ...recommendation.reasons].slice(0, 5).map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-500">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!recommendationsQuery.isFetching && !(recommendationsQuery.data ?? []).length ? (
|
||||
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">
|
||||
Noch keine passenden Vorschläge. Prüfe deine Profil-Präferenzen oder lege mehr Rezepte an.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Neues Gericht</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<FormField label="Name des Gerichts">
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="z. B. Protein Bowl" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Notiz">
|
||||
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Reste, Meal-Prep-Hinweis oder Zubereitungsidee" className={`${inputClassName} min-h-20`} />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<FormField label="Minuten">
|
||||
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} placeholder="20" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Portionen">
|
||||
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="2" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Mealprep">
|
||||
<input value={mealPrepDays} onChange={(event) => setMealPrepDays(event.target.value)} placeholder="Tage" className={inputClassName} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FormField label="Lebensmittel">
|
||||
<select
|
||||
value={foodId}
|
||||
onChange={(event) => {
|
||||
const nextFood = foodById.get(event.target.value);
|
||||
setFoodId(event.target.value);
|
||||
setUnit(nextFood?.preferredUnit ?? "g");
|
||||
setPortionId("");
|
||||
}}
|
||||
className={inputClassName}
|
||||
>
|
||||
{foods.map((food) => <option key={food.id} value={food.id}>{food.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-[1fr_112px_48px] gap-2">
|
||||
<FormField label="Menge">
|
||||
<input value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="100" className={inputClassName} />
|
||||
</FormField>
|
||||
<FormField label="Einheit">
|
||||
<select value={unit} onChange={(event) => setUnit(event.target.value as PortionUnit)} className={inputClassName}>
|
||||
{recipeUnits.map((recipeUnit) => <option key={recipeUnit} value={recipeUnit}>{unitLabel(recipeUnit)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<button type="button" onClick={addIngredient} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Zutat hinzufügen" title="Zutat hinzufügen">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{selectedFood?.portions?.length ? (
|
||||
<FormField label="Portionsgröße">
|
||||
<select value={portionId} onChange={(event) => setPortionId(event.target.value)} className={inputClassName}>
|
||||
<option value="">Freie Menge verwenden</option>
|
||||
{selectedFood.portions.map((portion) => (
|
||||
<option key={portion.id} value={portion.id}>{portion.label} ({portion.amount} {unitLabel(portion.unit)} = {portion.gramsEquivalent} g)</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
{draftIngredients.length ? draftIngredients.map((ingredient, index) => (
|
||||
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 py-1 text-sm font-bold text-gray-500">
|
||||
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name}</span>
|
||||
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
|
||||
</div>
|
||||
)) : <p className="text-sm font-bold text-gray-400">Noch keine Zutaten.</p>}
|
||||
</div>
|
||||
{draftIngredients.length ? <RecipeNutritionInsight recipe={draftRecipe} foods={foods} /> : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<button type="button" onClick={saveRecipe} disabled={saveRecipeMutation.isPending || !foods.length} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
|
||||
Privat speichern
|
||||
</button>
|
||||
<button type="button" onClick={submitRecipeSuggestion} disabled={submitRecipeMutation.isPending || !foods.length} className="rounded-full bg-[#0F766E] px-4 py-4 text-sm font-black text-white disabled:opacity-50">
|
||||
Zur Prüfung einreichen
|
||||
</button>
|
||||
</div>
|
||||
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
|
||||
{saveRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{saveRecipeMutation.error.message}</p> : null}
|
||||
{submitRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{submitRecipeMutation.error.message}</p> : null}
|
||||
{submissionsQuery.data?.length ? (
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-black text-gray-950">Meine Vorschläge</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{submissionsQuery.data.slice(0, 5).map((submission) => (
|
||||
<div key={submission.id} className="flex items-center justify-between gap-3 text-sm font-bold text-gray-500">
|
||||
<span className="min-w-0 truncate">{submission.name}</span>
|
||||
<span className={`rounded-full px-3 py-1 text-xs ${
|
||||
submission.status === "accepted"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: submission.status === "rejected"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "in Prüfung"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
226
apps/admin/app/(authed)/rezepte/pruefung/page.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CatalogFood, PortionUnit, Recipe, calcRecipe, classifyRecipe } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type SubmissionStatus = "pending" | "accepted" | "rejected";
|
||||
type SubmissionForReview = {
|
||||
id: string;
|
||||
name: string;
|
||||
portions: number;
|
||||
minutes: number;
|
||||
notes: string | null;
|
||||
ingredients: Array<{
|
||||
id: string;
|
||||
foodId: string;
|
||||
amountG: unknown;
|
||||
displayAmount: unknown | null;
|
||||
displayUnit: PortionUnit | null;
|
||||
portionId: string | null;
|
||||
food: Parameters<typeof foodFromSubmission>[0] | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function toNumber(value: unknown) {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
function foodFromSubmission(food: {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
category: string;
|
||||
kcalPer100g: unknown;
|
||||
proteinPer100g: unknown;
|
||||
carbsPer100g: unknown;
|
||||
fatPer100g: unknown;
|
||||
servingG: unknown;
|
||||
preferredUnit: PortionUnit | null;
|
||||
densityGPerMl: unknown | null;
|
||||
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
|
||||
barcode: string | null;
|
||||
availableEverywhere: boolean;
|
||||
}): CatalogFood {
|
||||
return {
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
brand: food.brand ?? undefined,
|
||||
category: food.category,
|
||||
kcalPer100g: toNumber(food.kcalPer100g),
|
||||
proteinPer100g: toNumber(food.proteinPer100g),
|
||||
carbsPer100g: toNumber(food.carbsPer100g),
|
||||
fatPer100g: toNumber(food.fatPer100g),
|
||||
servingG: toNumber(food.servingG) || 100,
|
||||
preferredUnit: food.preferredUnit ?? "g",
|
||||
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
|
||||
source: food.source,
|
||||
barcode: food.barcode ?? undefined,
|
||||
availableEverywhere: food.availableEverywhere,
|
||||
};
|
||||
}
|
||||
|
||||
function SubmissionNutrition({ submission }: { submission: SubmissionForReview }) {
|
||||
const foods = submission.ingredients.map((ingredient) => ingredient.food ? foodFromSubmission(ingredient.food) : null).filter(Boolean) as CatalogFood[];
|
||||
const recipe: Recipe = {
|
||||
id: submission.id,
|
||||
name: submission.name,
|
||||
portions: submission.portions,
|
||||
minutes: submission.minutes,
|
||||
notes: submission.notes ?? undefined,
|
||||
ingredients: submission.ingredients.map((ingredient) => ({
|
||||
foodId: ingredient.foodId,
|
||||
amountG: toNumber(ingredient.amountG),
|
||||
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
|
||||
unit: ingredient.displayUnit ?? undefined,
|
||||
portionId: ingredient.portionId ?? undefined,
|
||||
})),
|
||||
};
|
||||
const totals = calcRecipe(recipe, foods);
|
||||
const classification = classifyRecipe(recipe, foods);
|
||||
return (
|
||||
<div className="mt-5 rounded-3xl bg-gray-50 p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classification.tags.map((tag) => <span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">{tag}</span>)}
|
||||
</div>
|
||||
<div className="mt-4 flex h-3 overflow-hidden rounded-full bg-white">
|
||||
<div className="bg-amber-400" style={{ width: `${classification.distribution.carbs}%` }} />
|
||||
<div className="bg-blue-500" style={{ width: `${classification.distribution.fat}%` }} />
|
||||
<div className="bg-emerald-500" style={{ width: `${classification.distribution.protein}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
|
||||
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{classification.criteria.map((criterion) => (
|
||||
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
|
||||
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
|
||||
<span className="text-gray-400">{criterion.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RezeptPruefungPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [status, setStatus] = useState<SubmissionStatus>("pending");
|
||||
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
|
||||
const submissionsQuery = trpc.health.recipeSubmissionsForReview.useQuery({ status });
|
||||
const reviewMutation = trpc.health.reviewRecipeSubmission.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.recipeSubmissionsForReview.invalidate({ status });
|
||||
await utils.health.listRecipes.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const submissions = useMemo(() => submissionsQuery.data ?? [], [submissionsQuery.data]);
|
||||
|
||||
function review(id: string, action: "accept" | "reject") {
|
||||
reviewMutation.mutate({
|
||||
id,
|
||||
action,
|
||||
reviewNote: reviewNotes[id]?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Moderation</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Rezeptvorschläge prüfen</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Akzeptierte Vorschläge werden als öffentliche Rezepte in den Rezeptkatalog übernommen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
|
||||
{(["pending", "accepted", "rejected"] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setStatus(item)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-black ${
|
||||
status === item ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{item === "pending" ? "Offen" : item === "accepted" ? "Akzeptiert" : "Abgelehnt"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{submissions.map((submission) => (
|
||||
<article key={submission.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-2xl font-black text-gray-950">{submission.name}</p>
|
||||
<p className="mt-1 text-sm font-bold text-gray-400">
|
||||
{submission.minutes} min · {submission.portions} Portionen
|
||||
{submission.mealPrepDays ? ` · ${submission.mealPrepDays} Meal-Prep-Tag(e)` : ""}
|
||||
</p>
|
||||
{submission.notes ? <p className="mt-3 max-w-2xl text-sm font-semibold text-gray-500">{submission.notes}</p> : null}
|
||||
</div>
|
||||
<span className={`rounded-full px-4 py-2 text-sm font-black ${
|
||||
submission.status === "accepted"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: submission.status === "rejected"
|
||||
? "bg-red-50 text-red-700"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
}`}>
|
||||
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "offen"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 divide-y divide-gray-100 rounded-3xl bg-gray-50 px-4">
|
||||
{submission.ingredients.map((ingredient) => (
|
||||
<div key={ingredient.id} className="flex justify-between gap-3 py-3 text-sm font-bold text-gray-500">
|
||||
<span className="min-w-0 truncate">{ingredient.food?.name ?? "Unbekanntes Lebensmittel"}</span>
|
||||
<span>{toNumber(ingredient.displayAmount) || toNumber(ingredient.amountG)} {ingredient.displayUnit ?? "g"} · {Math.round(toNumber(ingredient.amountG))} g</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SubmissionNutrition submission={submission} />
|
||||
|
||||
{submission.status === "pending" ? (
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-[1fr_150px_150px]">
|
||||
<input
|
||||
value={reviewNotes[submission.id] ?? ""}
|
||||
onChange={(event) => setReviewNotes((current) => ({ ...current, [submission.id]: event.target.value }))}
|
||||
placeholder="Prüfnotiz optional"
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
||||
/>
|
||||
<button type="button" onClick={() => review(submission.id, "reject")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-red-50 px-4 py-3 text-sm font-black text-red-700 disabled:opacity-50">
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button type="button" onClick={() => review(submission.id, "accept")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#0F766E] px-4 py-3 text-sm font-black text-white disabled:opacity-50">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
) : submission.reviewNote ? (
|
||||
<p className="mt-4 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold text-gray-500">{submission.reviewNote}</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
|
||||
{!submissions.length && !submissionsQuery.isLoading ? (
|
||||
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
|
||||
Keine Rezeptvorschläge in dieser Ansicht.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
262
apps/admin/app/(authed)/sicherheit/2fa-setup/page.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function TwoFactorSetupPage() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const securityQuery = trpc.security.status.useQuery();
|
||||
const [password, setPassword] = useState("");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const qrCodeUrl = useMemo(() => {
|
||||
if (!totpUri) return null;
|
||||
const sanitized = totpUri.trim();
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=16&data=${encodeURIComponent(
|
||||
sanitized,
|
||||
)}`;
|
||||
}, [totpUri]);
|
||||
const manualSecret = useMemo(() => {
|
||||
if (!totpUri) return null;
|
||||
const match = totpUri.match(/[?&]secret=([^&]+)/i);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}, [totpUri]);
|
||||
|
||||
const requiresSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
|
||||
|
||||
async function handleEnableTwoFactor() {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = (await authClient.twoFactor.enable({
|
||||
password,
|
||||
issuer: "Hellth Hub",
|
||||
})) as unknown as {
|
||||
error?: { message?: string };
|
||||
data?: { totpURI?: string; backupCodes?: string[] };
|
||||
totpURI?: string;
|
||||
backupCodes?: string[];
|
||||
};
|
||||
|
||||
if (result?.error) {
|
||||
setErrorMessage(result.error.message ?? "2FA konnte nicht aktiviert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data ?? result;
|
||||
setTotpUri(data.totpURI ?? null);
|
||||
setBackupCodes(data.backupCodes ?? []);
|
||||
setStatusMessage(
|
||||
"OTP vorbereitet. Bitte QR-Code scannen und danach den 6-stelligen Code bestätigen.",
|
||||
);
|
||||
setPassword("");
|
||||
await securityQuery.refetch();
|
||||
} catch {
|
||||
setErrorMessage("2FA konnte derzeit nicht aktiviert werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyTotp() {
|
||||
setErrorMessage(null);
|
||||
setStatusMessage(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!totpUri) {
|
||||
setErrorMessage(
|
||||
"OTP ist noch nicht vorbereitet. Bitte zuerst in Schritt 1 mit deinem Passwort aktivieren.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await authClient.twoFactor.verifyTotp({
|
||||
code: otpCode.trim(),
|
||||
})) as unknown as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (result?.error) {
|
||||
const message = result.error.message ?? "OTP-Code ist ungültig.";
|
||||
if (message.toLowerCase().includes("totp not enabled")) {
|
||||
setErrorMessage(
|
||||
"TOTP ist noch nicht aktiviert. Bitte Schritt 1 (OTP aktivieren) ausführen.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage("2FA erfolgreich aktiviert.");
|
||||
setOtpCode("");
|
||||
const refreshed = await securityQuery.refetch();
|
||||
if (!refreshed.data?.requiresTwoFactorSetup) {
|
||||
router.push("/");
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage("OTP-Code konnte nicht verifiziert werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h1 className="text-xl font-bold text-[#333333]">Sicherheits-Setup</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Für Admin-Accounts ist 2FA verpflichtend. Bitte richte jetzt OTP (Authenticator-App)
|
||||
ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#333333]">1. OTP aktivieren</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Gib dein aktuelles Passwort ein, um die OTP-Einrichtung zu starten.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Aktuelles Passwort"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleEnableTwoFactor}
|
||||
disabled={loading || !password}
|
||||
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Aktiviere..." : "OTP aktivieren"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#333333]">2. QR-Code scannen</h2>
|
||||
{!qrCodeUrl && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Nach der Aktivierung erscheint hier der QR-Code für deine Authenticator-App.
|
||||
</p>
|
||||
)}
|
||||
{qrCodeUrl && (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="OTP QR Code"
|
||||
className="w-72 h-72 rounded-lg bg-white p-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 break-all">{totpUri}</p>
|
||||
{manualSecret && (
|
||||
<div className="text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
Falls Scannen nicht klappt, manuell eingeben:{" "}
|
||||
<code className="font-mono">{manualSecret}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#333333]">3. Backup-Codes sichern</h2>
|
||||
{backupCodes.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Nach Aktivierung werden hier Backup-Codes angezeigt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{backupCodes.map((code) => (
|
||||
<code
|
||||
key={code}
|
||||
className="px-3 py-2 rounded bg-gray-100 text-sm font-mono text-gray-800"
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#333333]">4. OTP-Code bestätigen</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Bitte den aktuellen 6-stelligen Code aus deiner Authenticator-App eingeben.
|
||||
</p>
|
||||
<input
|
||||
className="w-full max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm tracking-[0.3em]"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(event) =>
|
||||
setOtpCode(event.target.value.replace(/\D/g, "").slice(0, 6))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={handleVerifyTotp}
|
||||
disabled={loading || otpCode.length !== 6 || !totpUri}
|
||||
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
OTP bestätigen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="bg-white border border-amber-200 rounded-xl p-5 space-y-2">
|
||||
<h2 className="text-base font-semibold text-amber-800">Passkey Hinweis</h2>
|
||||
<p className="text-sm text-amber-700">
|
||||
Passkey ist jetzt verfügbar. Du kannst in den Einstellungen unter Sicherheit einen
|
||||
Passkey hinterlegen. Für Admin-Accounts bleibt OTP weiterhin verpflichtend.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{statusMessage && (
|
||||
<div className="text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-800">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => securityQuery.refetch()}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm"
|
||||
>
|
||||
Status aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
disabled={requiresSetup}
|
||||
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
Zum Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!session && (
|
||||
<div className="text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-600">
|
||||
Keine aktive Session gefunden. Bitte erneut einloggen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
apps/admin/app/(authed)/statistiken/page.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowTrendingDownIcon,
|
||||
BoltIcon,
|
||||
ChartBarIcon,
|
||||
FireIcon,
|
||||
ScaleIcon,
|
||||
SparklesIcon,
|
||||
TrophyIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMemo } from "react";
|
||||
import { HealthStatsSummary, todayKey } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
function formatDate(dateKey: string) {
|
||||
return new Date(`${dateKey}T12:00:00`).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
detail,
|
||||
tone = "gray",
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
tone?: "gray" | "green" | "blue" | "amber" | "rose";
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const tones = {
|
||||
gray: "bg-gray-50 text-gray-950",
|
||||
green: "bg-emerald-50 text-emerald-800",
|
||||
blue: "bg-blue-50 text-blue-800",
|
||||
amber: "bg-amber-50 text-amber-800",
|
||||
rose: "bg-rose-50 text-rose-800",
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`rounded-[2rem] p-5 ${tones[tone]}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-black uppercase opacity-60">{title}</p>
|
||||
<Icon className="h-6 w-6 opacity-70" />
|
||||
</div>
|
||||
<p className="mt-5 text-4xl font-black tracking-normal">{value}</p>
|
||||
<p className="mt-2 text-sm font-bold opacity-70">{detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBars({ summary }: { summary: HealthStatsSummary }) {
|
||||
const maxCalories = Math.max(...summary.days.map((day) => day.calories + day.activityCalories), summary.week.dailyGoal, 1);
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid grid-cols-14 gap-1">
|
||||
{summary.days.map((day) => {
|
||||
const height = Math.max(8, Math.round((day.calories / maxCalories) * 100));
|
||||
const activityHeight = Math.round((day.activityCalories / maxCalories) * 100);
|
||||
return (
|
||||
<div key={day.date} className="flex min-h-44 flex-col justify-end gap-1">
|
||||
<div className="flex h-32 flex-col justify-end rounded-full bg-gray-100 p-1">
|
||||
{activityHeight > 0 ? (
|
||||
<div className="rounded-full bg-emerald-400" style={{ height: `${Math.max(6, activityHeight)}%` }} />
|
||||
) : null}
|
||||
<div className="rounded-full bg-gray-950" style={{ height: `${height}%` }} />
|
||||
</div>
|
||||
<p className="text-center text-[10px] font-bold text-gray-400">{formatDate(day.date)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectionCard({ summary }: { summary: HealthStatsSummary }) {
|
||||
if (!summary.projection) {
|
||||
return (
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<ScaleIcon className="h-7 w-7 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
|
||||
<p className="mt-2 text-sm font-semibold text-gray-400">
|
||||
Noch zu wenig Gewichtsdaten für eine faire Prognose. Ab drei Messungen über mindestens eine Woche wird hier ein vorsichtiger Trend angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<ArrowTrendingDownIcon className="h-7 w-7 text-emerald-600" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-black text-gray-950">Prognose</h2>
|
||||
<p className="mt-2 text-sm font-semibold text-gray-500">{summary.projection.message}</p>
|
||||
{summary.projection.targetDate ? (
|
||||
<p className="mt-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-black text-emerald-800">
|
||||
Hochrechnung Zieltermin: {new Date(`${summary.projection.targetDate}T12:00:00`).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-xs font-bold text-gray-400">
|
||||
Vertrauen: {summary.projection.confidence === "medium" ? "mittel" : "niedrig"} · nur als Orientierung, nicht als Urteil.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatistikenPage() {
|
||||
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: todayKey() });
|
||||
const summary = summaryQuery.data as HealthStatsSummary | undefined;
|
||||
|
||||
const weekStatus = useMemo(() => {
|
||||
if (!summary) return "Sammelt Daten";
|
||||
if (summary.week.balance > 800) return "entspannt im Rahmen";
|
||||
if (summary.week.balance >= -300) return "ziemlich genau auf Kurs";
|
||||
return "leicht über Plan, gut ausgleichbar";
|
||||
}, [summary]);
|
||||
|
||||
if (!summary) {
|
||||
return <div className="mx-auto max-w-6xl pb-24 text-sm font-bold text-gray-400">Statistiken werden geladen...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-emerald-300">Statistiken</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal">Fortschritt, der sich gut anfühlt</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
|
||||
Trends, kleine Siege und Prognosen, wenn die Daten dafür stark genug sind.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-28 w-28 place-items-center rounded-full bg-emerald-500 text-white ring-[12px] ring-emerald-500/20">
|
||||
<ChartBarIcon className="h-14 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard title="Woche" value={weekStatus} detail={`${summary.week.availableCalories.toLocaleString("de-DE")} kcal verfügbar`} tone="green" icon={FireIcon} />
|
||||
<StatCard title="Protein" value={`${summary.averages.protein} g`} detail="Durchschnitt der letzten 14 Tage" tone="blue" icon={SparklesIcon} />
|
||||
<StatCard title="Trinken" value={`${summary.averages.drinkMl} ml`} detail={`${summary.streaks.waterDays} Tag(e) Zielserie`} tone="gray" icon={BoltIcon} />
|
||||
<StatCard title="Bewegung" value={`${summary.averages.activityCalories} kcal`} detail={`${summary.streaks.activityDays} Tag(e) in Folge aktiv`} tone="amber" icon={TrophyIcon} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-gray-950">14-Tage-Verlauf</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">Schwarz: Essen/Planung · Grün: Sportbonus</p>
|
||||
</div>
|
||||
<p className="rounded-full bg-gray-100 px-4 py-2 text-sm font-black text-gray-600">
|
||||
Ø {summary.averages.calories.toLocaleString("de-DE")} kcal
|
||||
</p>
|
||||
</div>
|
||||
<MiniBars summary={summary} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrophyIcon className="h-7 w-7 text-amber-500" />
|
||||
<h2 className="text-xl font-black text-gray-950">Kleine Siege</h2>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{summary.wins.map((win) => (
|
||||
<p key={win} className="rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold text-gray-600">
|
||||
{win}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<ProjectionCard summary={summary} />
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Wochenbudget</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
<p className="text-2xl font-black">{summary.week.plannedBudget.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs font-bold text-gray-400">geplantes Budget</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-gray-50 p-4">
|
||||
<p className="text-2xl font-black">{summary.week.eatenCalories.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs font-bold text-gray-400">gegessen/geplant</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-emerald-50 p-4">
|
||||
<p className="text-2xl font-black text-emerald-700">+{summary.week.activityCalories.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs font-bold text-emerald-700">Sportbonus</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-blue-50 p-4">
|
||||
<p className="text-2xl font-black text-blue-700">{summary.week.availableCalories.toLocaleString("de-DE")}</p>
|
||||
<p className="text-xs font-bold text-blue-700">Restbudget</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
109
apps/admin/app/(authed)/wearables/page.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
BoltIcon,
|
||||
CheckCircleIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
const plannedImports = [
|
||||
"Schritte und aktive Minuten",
|
||||
"Trainingseinheiten von der Pixel Watch",
|
||||
"Aktivitätskalorien als optionaler Sportbonus",
|
||||
"Dublettenprüfung über externe Aktivitäts-IDs",
|
||||
"Punktevergabe für importierte Aktivitäten",
|
||||
];
|
||||
|
||||
export default function WearablesPage() {
|
||||
const connectionsQuery = trpc.health.wearableConnections.useQuery();
|
||||
const connections = connectionsQuery.data ?? [];
|
||||
const fitbit = connections.find((connection) => connection.provider === "fitbit");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5 pb-24">
|
||||
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-emerald-300">Pixel Watch & Fitbit</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal">Wearables</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm font-semibold text-white/60">
|
||||
Importiere Aktivitäten aus deiner Uhr und verwandle sie in Sportbonus, Aktivitätspunkte und bessere Wochenprognosen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-28 w-28 place-items-center rounded-full bg-white text-gray-950 ring-[12px] ring-white/10">
|
||||
<DevicePhoneMobileIcon className="h-14 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-950">Fitbit</h2>
|
||||
<p className="mt-2 text-sm font-semibold text-gray-400">
|
||||
Empfohlener erster Weg für die Pixel Watch. Google Fit wird nicht mehr als neue Basis aufgebaut; Health Connect bleibt als spätere Android-Brücke vorgesehen.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-black ${fitbit ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
|
||||
{fitbit ? "verbunden" : "vorbereitet"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-3xl bg-gray-50 p-4">
|
||||
{fitbit ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleIcon className="h-7 w-7 text-emerald-600" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-950">Fitbit-Verbindung aktiv</p>
|
||||
<p className="text-xs font-bold text-gray-400">
|
||||
Letzter Sync: {fitbit.lastSyncAt ? new Date(fitbit.lastSyncAt).toLocaleString("de-DE") : "noch nicht synchronisiert"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-7 w-7 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-gray-950">OAuth-Verbindung ist der nächste technische Schritt</p>
|
||||
<p className="mt-1 text-xs font-bold text-gray-400">
|
||||
Die Tabellen und API-Struktur sind vorbereitet. Für den echten Login fehlen noch Fitbit Client-ID, Secret und Callback-Route.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<button type="button" disabled className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-40">
|
||||
<DevicePhoneMobileIcon className="h-5 w-5" />
|
||||
Fitbit verbinden
|
||||
</button>
|
||||
<button type="button" disabled className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-100 px-4 py-4 text-sm font-black text-gray-950 disabled:opacity-50">
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
Jetzt synchronisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<BoltIcon className="h-8 w-8 text-emerald-500" />
|
||||
<h2 className="text-xl font-black text-gray-950">Geplanter Import</h2>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{plannedImports.map((item) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
||||
<CheckCircleIcon className="h-5 w-5 text-emerald-600" />
|
||||
<span className="text-sm font-bold text-gray-600">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
apps/admin/app/(authed)/wochenbudget/page.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
|
||||
import { useMemo, useState } from "react";
|
||||
import { todayKey } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
function fmt(value: number) {
|
||||
return Math.round(value).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function prognosis(balance: number) {
|
||||
if (balance >= 1000) return { label: "entspannt im Ziel", color: "text-emerald-700 bg-emerald-50" };
|
||||
if (balance >= -300) return { label: "gut steuerbar", color: "text-blue-700 bg-blue-50" };
|
||||
return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" };
|
||||
}
|
||||
|
||||
export default function WochenbudgetPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedDate, setSelectedDate] = useState(todayKey());
|
||||
const [extraDate, setExtraDate] = useState(todayKey());
|
||||
const [extraCalories, setExtraCalories] = useState("800");
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
|
||||
const summary = summaryQuery.data?.week ?? null;
|
||||
const prediction = summary ? prognosis(summary.balance) : null;
|
||||
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.statsSummary.invalidate({ referenceDate: selectedDate });
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
|
||||
|
||||
function updatePlan(date: string, plannedCalories: number, note?: string) {
|
||||
upsertPlanMutation.mutate({
|
||||
date,
|
||||
plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
|
||||
note: note ?? dayByDate.get(date)?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function applyRestaurantDistribution() {
|
||||
if (!summary) return;
|
||||
const extra = Math.max(0, Number(extraCalories) || 0);
|
||||
const days = summary.days;
|
||||
const targetDay = days.find((day) => day.date === extraDate);
|
||||
if (!targetDay) return;
|
||||
|
||||
const otherDays = days.filter((day) => day.date !== extraDate);
|
||||
const savingPerOtherDay = otherDays.length ? Math.ceil(extra / otherDays.length) : 0;
|
||||
|
||||
updatePlan(extraDate, targetDay.plannedCalories + extra, targetDay.note ?? "Restaurant / höherer Tag");
|
||||
for (const day of otherDays) {
|
||||
updatePlan(day.date, Math.max(1200, day.plannedCalories - savingPerOtherDay), day.note);
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary || !prediction) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black text-gray-400">Wochenbudget wird geladen.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Wochenbudget</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche.
|
||||
</p>
|
||||
</div>
|
||||
<input type="date" value={selectedDate} onChange={(event) => setSelectedDate(event.target.value || todayKey())} className="rounded-full border border-gray-200 px-4 py-3 text-sm font-bold" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<FireIcon className="h-8 w-8 text-orange-500" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.baseBudget)}</p>
|
||||
<p className="text-sm font-bold text-gray-400">Wochenziel</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<CalendarDaysIcon className="h-8 w-8 text-blue-500" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.eatenCalories)}</p>
|
||||
<p className="text-sm font-bold text-gray-400">gegessen/geplant</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<SparklesIcon className="h-8 w-8 text-emerald-500" />
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p>
|
||||
<p className="text-sm font-bold text-gray-400">Sportbonus</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div>
|
||||
<p className="mt-3 text-3xl font-black text-gray-950">{fmt(summary.availableCalories)}</p>
|
||||
<p className="text-sm font-bold text-gray-400">noch verfügbar</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
|
||||
<div className="mt-5 grid gap-3">
|
||||
{summary.days.map((day) => {
|
||||
const date = new Date(`${day.date}T12:00:00`);
|
||||
const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
|
||||
return (
|
||||
<div key={day.date} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[130px_1fr_160px_120px] md:items-center">
|
||||
<div>
|
||||
<p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p>
|
||||
<p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-white">
|
||||
<div className={`h-full rounded-full ${over ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${Math.min(100, (day.eatenCalories / Math.max(1, day.plannedCalories + day.activityCalories)) * 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-bold text-gray-400">
|
||||
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
|
||||
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
|
||||
{day.note ? ` - ${day.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={day.plannedCalories}
|
||||
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold"
|
||||
/>
|
||||
<p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}>
|
||||
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="text-xl font-black text-gray-950">Restaurantbesuch einplanen</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-400">
|
||||
Beispiel: Samstag +800 kcal. Die App verteilt den Ausgleich auf die restlichen Tage.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_160px_180px]">
|
||||
<input type="date" value={extraDate} onChange={(event) => setExtraDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
|
||||
<input value={extraCalories} onChange={(event) => setExtraCalories(event.target.value)} placeholder="+ kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
|
||||
<button type="button" onClick={applyRestaurantDistribution} disabled={upsertPlanMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
Verteilen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saved ? <div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">Gespeichert</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
apps/admin/app/(authed)/ziele/page.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { BeakerIcon, ClockIcon, FireIcon, ScaleIcon } from "@heroicons/react/24/outline";
|
||||
import { useEffect, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type GoalsForm = {
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
drinkMl: number;
|
||||
weightKg: number;
|
||||
fastingHours: number;
|
||||
weeklyCalories: number;
|
||||
sportAddsCalories: boolean;
|
||||
};
|
||||
|
||||
const defaultGoals: GoalsForm = {
|
||||
calories: 2000,
|
||||
proteinG: 150,
|
||||
carbsG: 200,
|
||||
fatG: 67,
|
||||
drinkMl: 2500,
|
||||
weightKg: 120,
|
||||
fastingHours: 12,
|
||||
weeklyCalories: 14000,
|
||||
sportAddsCalories: true,
|
||||
};
|
||||
|
||||
const fields: Array<{ key: keyof Omit<GoalsForm, "sportAddsCalories">; label: string; unit: string; icon: typeof FireIcon }> = [
|
||||
{ key: "calories", label: "Kalorien", unit: "kcal", icon: FireIcon },
|
||||
{ key: "weeklyCalories", label: "Wochenbudget", unit: "kcal", icon: FireIcon },
|
||||
{ key: "proteinG", label: "Protein", unit: "g", icon: ScaleIcon },
|
||||
{ key: "carbsG", label: "Kohlenhydrate", unit: "g", icon: ScaleIcon },
|
||||
{ key: "fatG", label: "Fett", unit: "g", icon: ScaleIcon },
|
||||
{ key: "drinkMl", label: "Trinkziel", unit: "ml", icon: BeakerIcon },
|
||||
{ key: "weightKg", label: "Gewichtsziel", unit: "kg", icon: ScaleIcon },
|
||||
{ key: "fastingHours", label: "Fasten", unit: "h", icon: ClockIcon },
|
||||
];
|
||||
|
||||
function todayKey() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function ZielePage() {
|
||||
const utils = trpc.useUtils();
|
||||
const overviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
|
||||
const saveGoalsMutation = trpc.health.upsertGoals.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.health.overview.invalidate();
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1600);
|
||||
},
|
||||
});
|
||||
const [goals, setGoals] = useState<GoalsForm>(defaultGoals);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const apiGoals = overviewQuery.data?.goals;
|
||||
if (!apiGoals) return;
|
||||
const calories = apiGoals.calories ?? defaultGoals.calories;
|
||||
setGoals({
|
||||
calories,
|
||||
proteinG: apiGoals.proteinG ?? defaultGoals.proteinG,
|
||||
carbsG: apiGoals.carbsG ?? defaultGoals.carbsG,
|
||||
fatG: apiGoals.fatG ?? defaultGoals.fatG,
|
||||
drinkMl: apiGoals.drinkMl ?? defaultGoals.drinkMl,
|
||||
weightKg: Number(apiGoals.weightKg ?? defaultGoals.weightKg),
|
||||
fastingHours: apiGoals.fastingHours ?? defaultGoals.fastingHours,
|
||||
weeklyCalories: apiGoals.weeklyCalories ?? calories * 7,
|
||||
sportAddsCalories: apiGoals.sportAddsCalories ?? true,
|
||||
});
|
||||
}, [overviewQuery.data?.goals]);
|
||||
|
||||
function saveGoals() {
|
||||
saveGoalsMutation.mutate({
|
||||
drinkMl: goals.drinkMl,
|
||||
calories: goals.calories,
|
||||
carbsG: goals.carbsG,
|
||||
fatG: goals.fatG,
|
||||
proteinG: goals.proteinG,
|
||||
weightKg: goals.weightKg || null,
|
||||
fastingHours: goals.fastingHours,
|
||||
weeklyCalories: goals.weeklyCalories || goals.calories * 7,
|
||||
sportAddsCalories: goals.sportAddsCalories,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-5 pb-24">
|
||||
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
||||
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Ziele</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Alltagstaugliche Zielwerte statt perfekter Wissenschaft. Diese Werte werden jetzt serverseitig pro Benutzer gespeichert.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
{overviewQuery.isLoading ? (
|
||||
<div className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Ziele werden geladen...</div>
|
||||
) : null}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{fields.map((field) => (
|
||||
<label key={field.key} className="grid gap-3 py-4 sm:grid-cols-[44px_1fr_180px] sm:items-center">
|
||||
<field.icon className="h-8 w-8 text-[#0F766E]" />
|
||||
<span className="text-xl font-black text-gray-950">{field.label}</span>
|
||||
<div className="grid grid-cols-[1fr_52px] items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={goals[field.key]}
|
||||
onChange={(event) => setGoals((current) => ({ ...current, [field.key]: Number(event.target.value) || 0 }))}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-right text-sm font-bold"
|
||||
/>
|
||||
<span className="text-sm font-black text-gray-400">{field.unit}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-4 flex items-center justify-between gap-3 rounded-3xl bg-gray-50 px-4 py-4 text-sm font-black text-gray-950">
|
||||
Sportkalorien erhöhen das Tagesbudget
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={goals.sportAddsCalories}
|
||||
onChange={(event) => setGoals((current) => ({ ...current, sportAddsCalories: event.target.checked }))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveGoals}
|
||||
disabled={saveGoalsMutation.isPending}
|
||||
className="mt-6 w-full rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
||||
>
|
||||
{saveGoalsMutation.isPending ? "Speichere..." : "Ziele speichern"}
|
||||
</button>
|
||||
{saved ? <p className="mt-3 text-center text-sm font-bold text-green-700">Gespeichert.</p> : null}
|
||||
{saveGoalsMutation.error ? <p className="mt-3 text-center text-sm font-bold text-red-600">{saveGoalsMutation.error.message}</p> : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
apps/admin/app/_components/auth-guard.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { hasCompletedHealthProfile } from "@/lib/hellth-data";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const PLATFORM_ALLOWED_PREFIXES = [
|
||||
"/",
|
||||
"/aktivitaeten",
|
||||
"/benutzer",
|
||||
"/einstellungen",
|
||||
"/erinnerungen",
|
||||
"/hellfireclub",
|
||||
"/einkaufsliste",
|
||||
"/lebensmittel",
|
||||
"/maerkte",
|
||||
"/onboarding",
|
||||
"/planung",
|
||||
"/rezepte",
|
||||
"/ziele",
|
||||
"/wochenbudget",
|
||||
"/sicherheit/2fa-setup",
|
||||
];
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const platformAdminEmails = (
|
||||
process.env.NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS ?? "admin@onl1.eu"
|
||||
)
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const userRole = ((session?.user as { role?: string } | undefined)?.role ?? "").toLowerCase();
|
||||
const userEmail = (session?.user?.email ?? "").toLowerCase();
|
||||
const isPlatformAdmin =
|
||||
userRole === "admin" ||
|
||||
userRole === "platform_admin" ||
|
||||
platformAdminEmails.includes(userEmail);
|
||||
const securityQuery = trpc.security.status.useQuery(undefined, {
|
||||
enabled: !!session && !isPending,
|
||||
retry: false,
|
||||
});
|
||||
const requiresTwoFactorSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPending && session) {
|
||||
if (securityQuery.isLoading) return;
|
||||
|
||||
if (requiresTwoFactorSetup && !pathname.startsWith("/sicherheit/2fa-setup")) {
|
||||
router.push("/sicherheit/2fa-setup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlatformAdmin) {
|
||||
const isAllowed = PLATFORM_ALLOWED_PREFIXES.some((prefix) =>
|
||||
prefix === "/" ? pathname === "/" : pathname.startsWith(prefix),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const userKey = session.user.id ?? session.user.email ?? null;
|
||||
const onboardingComplete = hasCompletedHealthProfile(userKey);
|
||||
if (!onboardingComplete && !pathname.startsWith("/onboarding")) {
|
||||
router.push("/onboarding");
|
||||
return;
|
||||
}
|
||||
if (pathname.startsWith("/benutzer") || pathname.startsWith("/einstellungen")) {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
session,
|
||||
isPending,
|
||||
isPlatformAdmin,
|
||||
pathname,
|
||||
router,
|
||||
requiresTwoFactorSetup,
|
||||
securityQuery.isLoading,
|
||||
]);
|
||||
|
||||
if (isPending || (!!session && securityQuery.isLoading)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-[#0F766E] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-gray-400">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
22
apps/admin/app/_components/data-page-layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { PageHeader } from "./page-header";
|
||||
|
||||
interface DataPageLayoutProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataPageLayout({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
}: DataPageLayoutProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title} description={description} action={action} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/admin/app/_components/empty-state.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
interface EmptyStateProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ message }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="py-16 text-center text-sm text-gray-400">{message}</div>
|
||||
);
|
||||
}
|
||||
20
apps/admin/app/_components/form-field.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
type FormFieldProps = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FormField({ label, hint, children, className = "" }: FormFieldProps) {
|
||||
return (
|
||||
<label className={`block space-y-1.5 ${className}`}>
|
||||
<span className="block text-xs font-black uppercase text-gray-400">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="block text-xs font-bold text-gray-400">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputClassName = "w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-950 outline-none transition focus:border-[#0F766E] focus:ring-2 focus:ring-[#0F766E]/10 disabled:bg-gray-50 disabled:text-gray-400";
|
||||
31
apps/admin/app/_components/help-link.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
const HELP_BASE_URL = (process.env.NEXT_PUBLIC_HELP_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
function guessHelpTopicFromPath(pathname: string) {
|
||||
const segment = pathname.split("/").filter(Boolean)[0];
|
||||
return segment || "dashboard";
|
||||
}
|
||||
|
||||
export function HelpLink({ topicOverride }: { topicOverride?: string }) {
|
||||
const pathname = usePathname();
|
||||
const topic = topicOverride ?? guessHelpTopicFromPath(pathname || "/");
|
||||
const href = HELP_BASE_URL ? `${HELP_BASE_URL}/t/${encodeURIComponent(topic)}` : `/einstellungen`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={HELP_BASE_URL ? "_blank" : undefined}
|
||||
rel={HELP_BASE_URL ? "noreferrer" : undefined}
|
||||
title="Hilfe zu diesem Bereich"
|
||||
aria-label="Hilfe zu diesem Bereich"
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-1.5 text-gray-500 transition-colors hover:border-[#0F766E] hover:text-[#0F766E]"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
59
apps/admin/app/_components/mobile-nav.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
BellAlertIcon,
|
||||
BoltIcon,
|
||||
ChartBarIcon,
|
||||
Cog6ToothIcon,
|
||||
FireIcon,
|
||||
SparklesIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const items = [
|
||||
{ href: "/", label: "Tag", icon: CalendarDaysIcon },
|
||||
{ href: "/planung", label: "Plan", icon: SparklesIcon },
|
||||
{ href: "/statistiken", label: "Stats", icon: ChartBarIcon },
|
||||
{ href: "/hellfireclub", label: "Club", icon: FireIcon },
|
||||
];
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-gray-200 bg-white/95 px-4 py-3 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/95 lg:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#0F766E] text-xs font-bold text-white">
|
||||
HH
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-none text-gray-900 dark:text-neutral-100">Hellth Hub</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">Daily Health</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200 bg-white/95 px-2 pb-[env(safe-area-inset-bottom)] pt-1 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/95 lg:hidden">
|
||||
{items.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={`${item.href}-${item.label}`}
|
||||
href={item.href}
|
||||
className={`flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-md text-xs font-medium ${
|
||||
active ? "text-[#0F766E]" : "text-gray-500 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
apps/admin/app/_components/news-blocknote-editor.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import { BlockNoteView } from "@blocknote/mantine";
|
||||
import { de } from "@blocknote/core/locales";
|
||||
import { useCreateBlockNote } from "@blocknote/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
|
||||
|
||||
type NewsBlockNoteEditorProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
minHeightClassName?: string;
|
||||
ensureLeadingHeading?: boolean;
|
||||
onChange: (next: string) => void;
|
||||
};
|
||||
|
||||
function normalizeHtml(html: string) {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "<p></p>" || trimmed === "<p><br></p>") return "";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function plainTextFromHtml(html: string) {
|
||||
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function uploadEditorImage(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/uploads/image`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as { url?: string; error?: string };
|
||||
if (!response.ok || !payload.url) {
|
||||
throw new Error(payload.error ?? "Bild-Upload fehlgeschlagen");
|
||||
}
|
||||
|
||||
return payload.url;
|
||||
}
|
||||
|
||||
export function NewsBlockNoteEditor({
|
||||
label,
|
||||
value,
|
||||
placeholder = "Hier den Beitrag schreiben...",
|
||||
minHeightClassName = "min-h-[480px]",
|
||||
ensureLeadingHeading = false,
|
||||
onChange,
|
||||
}: NewsBlockNoteEditorProps) {
|
||||
const editor = useCreateBlockNote({
|
||||
dictionary: {
|
||||
...de,
|
||||
placeholders: {
|
||||
...(de as { placeholders?: Record<string, string> }).placeholders,
|
||||
default: placeholder,
|
||||
emptyDocument: placeholder,
|
||||
},
|
||||
},
|
||||
uploadFile: uploadEditorImage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentHtml = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
|
||||
const targetHtml = normalizeHtml(value || "");
|
||||
if (currentHtml === targetHtml) return;
|
||||
|
||||
const starterBlocks = [
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
] as never;
|
||||
|
||||
try {
|
||||
const blocks = targetHtml ? editor.tryParseHTMLToBlocks(targetHtml) : [];
|
||||
if (blocks.length > 0) {
|
||||
editor.replaceBlocks(editor.document, blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackText = plainTextFromHtml(targetHtml);
|
||||
if (!fallbackText) {
|
||||
editor.replaceBlocks(editor.document, ensureLeadingHeading ? starterBlocks : []);
|
||||
return;
|
||||
}
|
||||
|
||||
editor.replaceBlocks(editor.document, [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: fallbackText,
|
||||
} as never,
|
||||
]);
|
||||
} catch {
|
||||
const fallbackText = plainTextFromHtml(targetHtml);
|
||||
if (!fallbackText) {
|
||||
if (ensureLeadingHeading) {
|
||||
editor.replaceBlocks(editor.document, starterBlocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
editor.replaceBlocks(editor.document, [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: fallbackText,
|
||||
} as never,
|
||||
]);
|
||||
}
|
||||
}, [editor, ensureLeadingHeading, value]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label ? <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label> : null}
|
||||
<div
|
||||
className={`rounded-md border border-gray-200 overflow-hidden bg-white ${minHeightClassName}`}
|
||||
>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
onChange={() => {
|
||||
const html = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
|
||||
onChange(html);
|
||||
}}
|
||||
slashMenu
|
||||
formattingToolbar
|
||||
sideMenu
|
||||
tableHandles
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/admin/app/_components/page-header.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { HelpLink } from "./help-link";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
helpTopic?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action, helpTopic }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-neutral-400 mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action ? <div>{action}</div> : null}
|
||||
<HelpLink topicOverride={helpTopic} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/admin/app/_components/providers.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { httpBatchLink } from "@trpc/client";
|
||||
import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
|
||||
function resolveApiUrl() {
|
||||
const configured = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (configured) return configured;
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("NEXT_PUBLIC_API_URL muss in Produktion gesetzt sein");
|
||||
}
|
||||
return "http://localhost:3002";
|
||||
}
|
||||
|
||||
const API_URL = resolveApiUrl();
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${API_URL}/api/trpc`,
|
||||
transformer: superjson,
|
||||
fetch: (url, opts) => {
|
||||
const headers = new Headers(opts?.headers ?? {});
|
||||
|
||||
return fetch(url, {
|
||||
...opts,
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
defaultColorScheme="light"
|
||||
theme={{
|
||||
primaryColor: "teal",
|
||||
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
86
apps/admin/app/_components/reminder-runner.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { ReminderKind, loadHellthData } from "@/lib/hellth-data";
|
||||
|
||||
const labels: Record<ReminderKind, string> = {
|
||||
water: "Wasser",
|
||||
snack: "Snack",
|
||||
movement: "Bewegung",
|
||||
};
|
||||
|
||||
function minutesOfDay(value: string) {
|
||||
const [hours = "0", minutes = "0"] = value.split(":");
|
||||
return Number(hours) * 60 + Number(minutes);
|
||||
}
|
||||
|
||||
function canNotifyNow(startTime: string, endTime: string) {
|
||||
const now = new Date();
|
||||
const current = now.getHours() * 60 + now.getMinutes();
|
||||
return current >= minutesOfDay(startTime) && current <= minutesOfDay(endTime);
|
||||
}
|
||||
|
||||
async function showReminder(kind: ReminderKind, message: string) {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return;
|
||||
if (Notification.permission !== "granted") return;
|
||||
|
||||
const title = `Hellth Hub: ${labels[kind]}`;
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, {
|
||||
body: message,
|
||||
tag: `hellth-${kind}`,
|
||||
badge: "/icon-192.png",
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to regular notification.
|
||||
}
|
||||
}
|
||||
|
||||
new Notification(title, { body: message, tag: `hellth-${kind}` });
|
||||
}
|
||||
|
||||
export function ReminderRunner() {
|
||||
const { data: session } = useSession();
|
||||
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||||
const lastSentRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
void navigator.serviceWorker.register("/hellth-sw.js").catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userKey) return;
|
||||
|
||||
function tick() {
|
||||
const data = loadHellthData(userKey);
|
||||
const reminders = data.reminders;
|
||||
if (!reminders.enabled) return;
|
||||
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||||
|
||||
const now = Date.now();
|
||||
(Object.keys(reminders.rules) as ReminderKind[]).forEach((kind) => {
|
||||
const rule = reminders.rules[kind];
|
||||
if (!rule.enabled) return;
|
||||
if (!canNotifyNow(rule.startTime, rule.endTime)) return;
|
||||
|
||||
const lastSentKey = `${userKey}:${kind}`;
|
||||
const lastSent = lastSentRef.current[lastSentKey] ?? 0;
|
||||
if (now - lastSent < rule.intervalMinutes * 60_000) return;
|
||||
|
||||
lastSentRef.current[lastSentKey] = now;
|
||||
void showReminder(kind, rule.message);
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
const interval = window.setInterval(tick, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [userKey]);
|
||||
|
||||
return null;
|
||||
}
|
||||
459
apps/admin/app/_components/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"use client";
|
||||
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type EditorMode = "basic" | "news";
|
||||
|
||||
type RichTextEditorProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
minHeightClassName?: string;
|
||||
mode?: EditorMode;
|
||||
onChange: (next: string) => void;
|
||||
};
|
||||
|
||||
type BlockAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
type SlashMenuState = {
|
||||
open: boolean;
|
||||
top: number;
|
||||
left: number;
|
||||
slashPos: number | null;
|
||||
};
|
||||
|
||||
function normalizeHtml(html: string) {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "<br>" || trimmed === "<p><br></p>") return "";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buttonClass(active = false) {
|
||||
return `px-2 py-1 rounded text-xs transition-colors ${
|
||||
active ? "bg-[#ECFDF5] text-[#0F766E]" : "text-gray-700 hover:bg-gray-200"
|
||||
}`;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
minHeightClassName = "min-h-[180px]",
|
||||
mode = "basic",
|
||||
onChange,
|
||||
}: RichTextEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
|
||||
const [slashMenu, setSlashMenu] = useState<SlashMenuState>({
|
||||
open: false,
|
||||
top: 0,
|
||||
left: 0,
|
||||
slashPos: null,
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder ?? "Inhalt schreiben...",
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
HTMLAttributes: { class: "nf-editor-table" },
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
],
|
||||
content: value || "",
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
onChange(normalizeHtml(ed.getHTML()));
|
||||
},
|
||||
});
|
||||
|
||||
const blockActions = useMemo<BlockAction[]>(() => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
{ id: "paragraph", label: "Absatz", run: () => editor.chain().focus().setParagraph().run() },
|
||||
{ id: "h2", label: "Überschrift H2", run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ id: "h3", label: "Überschrift H3", run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ id: "bullet", label: "Aufzählung", run: () => editor.chain().focus().toggleBulletList().run() },
|
||||
{ id: "ordered", label: "Nummerierte Liste", run: () => editor.chain().focus().toggleOrderedList().run() },
|
||||
{ id: "quote", label: "Zitat", run: () => editor.chain().focus().toggleBlockquote().run() },
|
||||
{
|
||||
id: "table",
|
||||
label: "Tabelle (3x3)",
|
||||
run: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
];
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const normalized = value || "";
|
||||
if (editor.getHTML() !== normalized) {
|
||||
editor.commands.setContent(normalized, { emitUpdate: false });
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || mode !== "news") return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!editor.isFocused) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setSlashMenu((prev) => ({ ...prev, open: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "/") return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!containerRef.current) return;
|
||||
const pos = editor.state.selection.from;
|
||||
const coords = editor.view.coordsAtPos(pos);
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
open: true,
|
||||
top: coords.bottom - containerRect.top + 6,
|
||||
left: Math.max(8, coords.left - containerRect.left),
|
||||
slashPos: pos,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [editor, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(event.target as Node)) {
|
||||
setBlockMenuOpen(false);
|
||||
setSlashMenu((prev) => ({ ...prev, open: false }));
|
||||
}
|
||||
}
|
||||
window.addEventListener("mousedown", onClickOutside);
|
||||
return () => window.removeEventListener("mousedown", onClickOutside);
|
||||
}, []);
|
||||
|
||||
function applyBlockAction(action: BlockAction) {
|
||||
if (!editor) return;
|
||||
|
||||
if (slashMenu.open && slashMenu.slashPos) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: slashMenu.slashPos - 1, to: slashMenu.slashPos })
|
||||
.run();
|
||||
}
|
||||
|
||||
action.run();
|
||||
setBlockMenuOpen(false);
|
||||
setSlashMenu((prev) => ({ ...prev, open: false, slashPos: null }));
|
||||
}
|
||||
|
||||
function handleLink() {
|
||||
if (!editor) return;
|
||||
const url = window.prompt("Link-URL eingeben (https://...)");
|
||||
if (url === null) return;
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: trimmedUrl }).run();
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showNewsBlockFeatures = mode === "news";
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<div className="border border-gray-200 rounded-md overflow-hidden bg-white">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5">
|
||||
{showNewsBlockFeatures && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockMenuOpen((value) => !value)}
|
||||
className="px-2 py-1 rounded text-xs font-medium text-gray-700 hover:bg-gray-200"
|
||||
title="Block-Menü"
|
||||
>
|
||||
Block +
|
||||
</button>
|
||||
{blockMenuOpen && (
|
||||
<div className="absolute top-8 left-0 z-20 w-56 rounded-md border border-gray-200 bg-white shadow-lg p-1">
|
||||
{blockActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => applyBlockAction(action)}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`${buttonClass(editor.isActive("bold"))} font-semibold`}
|
||||
title="Fett"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={`${buttonClass(editor.isActive("italic"))} italic`}
|
||||
title="Kursiv"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={`${buttonClass(editor.isActive("underline"))} underline`}
|
||||
title="Unterstrichen"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={`${buttonClass(editor.isActive("strike"))} line-through`}
|
||||
title="Durchgestrichen"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
className={buttonClass(editor.isActive("paragraph"))}
|
||||
title="Absatz"
|
||||
>
|
||||
P
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={buttonClass(editor.isActive("heading", { level: 2 }))}
|
||||
title="Überschrift 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={buttonClass(editor.isActive("heading", { level: 3 }))}
|
||||
title="Überschrift 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={buttonClass(editor.isActive("bulletList"))}
|
||||
title="Aufzählung"
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={buttonClass(editor.isActive("orderedList"))}
|
||||
title="Nummerierte Liste"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={buttonClass(editor.isActive("blockquote"))}
|
||||
title="Zitat"
|
||||
>
|
||||
Quote
|
||||
</button>
|
||||
|
||||
{showNewsBlockFeatures && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
className={buttonClass(editor.isActive("table"))}
|
||||
title="Tabelle einfügen"
|
||||
>
|
||||
Tabelle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||
className={buttonClass(false)}
|
||||
title="Zeile hinzufügen"
|
||||
>
|
||||
+Zeile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||
className={buttonClass(false)}
|
||||
title="Spalte hinzufügen"
|
||||
>
|
||||
+Spalte
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().deleteTable().run()}
|
||||
className={buttonClass(false)}
|
||||
title="Tabelle entfernen"
|
||||
>
|
||||
-Tabelle
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLink}
|
||||
className={buttonClass(editor.isActive("link"))}
|
||||
title="Link einfügen"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
||||
className={buttonClass(false)}
|
||||
title="Formatierung entfernen"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
className={buttonClass(editor.isActive({ textAlign: "left" }))}
|
||||
title="Linksbündig"
|
||||
>
|
||||
L
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
className={buttonClass(editor.isActive({ textAlign: "center" }))}
|
||||
title="Zentriert"
|
||||
>
|
||||
C
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
className={buttonClass(editor.isActive({ textAlign: "right" }))}
|
||||
title="Rechtsbündig"
|
||||
>
|
||||
R
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-gray-300 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
className={`${buttonClass(false)} disabled:opacity-40`}
|
||||
title="Rückgängig"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
className={`${buttonClass(false)} disabled:opacity-40`}
|
||||
title="Wiederholen"
|
||||
>
|
||||
Redo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={`w-full px-3 py-2 text-sm rounded-b-md focus-within:ring-1 focus-within:ring-[#0F766E] ${minHeightClassName} [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h3]:text-lg [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:ml-5 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:ml-5 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-gray-300 [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_a]:text-[#0F766E] [&_.ProseMirror_a]:underline [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:border [&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-gray-300 [&_.ProseMirror_td]:p-2 [&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-gray-300 [&_.ProseMirror_th]:p-2 [&_.ProseMirror_th]:bg-gray-100`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showNewsBlockFeatures && slashMenu.open && (
|
||||
<div
|
||||
className="absolute z-30 w-60 rounded-md border border-gray-200 bg-white shadow-lg p-1"
|
||||
style={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
>
|
||||
<p className="px-3 py-1 text-[11px] text-gray-500">Slash-Menü</p>
|
||||
{blockActions.map((action) => (
|
||||
<button
|
||||
key={`slash-${action.id}`}
|
||||
type="button"
|
||||
onClick={() => applyBlockAction(action)}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
apps/admin/app/_components/sidebar.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRightStartOnRectangleIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
BellAlertIcon,
|
||||
BoltIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpDownIcon,
|
||||
Cog6ToothIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
FireIcon,
|
||||
MoonIcon,
|
||||
ScaleIcon,
|
||||
ShoppingCartIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { signOut, useSession } from "@/lib/auth-client";
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
type NavItem = {
|
||||
href?: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
type NavGroup = {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Heute",
|
||||
items: [
|
||||
{ href: "/", label: "Tagesansicht", icon: CalendarDaysIcon },
|
||||
{ href: "/wochenbudget", label: "Wochenbudget", icon: ChartBarIcon },
|
||||
{ href: "/statistiken", label: "Statistiken", icon: ChartBarIcon },
|
||||
{ href: "/erinnerungen", label: "Erinnerungen", icon: BellAlertIcon },
|
||||
{ href: "/aktivitaeten", label: "Aktivitäten", icon: BoltIcon },
|
||||
{ href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon },
|
||||
{ href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon },
|
||||
{ href: "/gewicht", label: "Gewicht", icon: ScaleIcon },
|
||||
{ href: "/ziele", label: "Ziele", icon: ChartBarIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Ernährung",
|
||||
items: [
|
||||
{ href: "/planung", label: "Essensplanung", icon: SparklesIcon },
|
||||
{ href: "/lebensmittel", label: "Lebensmittel", icon: ShoppingCartIcon },
|
||||
{ href: "/maerkte", label: "Märkte", icon: ShoppingCartIcon },
|
||||
{ href: "/rezepte", label: "Rezepte", icon: SparklesIcon },
|
||||
{ href: "/rezepte/pruefung", label: "Rezeptprüfung", icon: SparklesIcon },
|
||||
{ href: "/einkaufsliste", label: "Einkaufsliste", icon: ShoppingCartIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
items: [
|
||||
{ href: "/benutzer", label: "Benutzer", icon: UsersIcon },
|
||||
{ href: "/aktivitaetsfeed", label: "Activity Feed", icon: BellAlertIcon },
|
||||
{ href: "/einstellungen", label: "Einstellungen", icon: Cog6ToothIcon },
|
||||
{ href: "/einstellungen/punkte", label: "Punktevergabe", icon: ChartBarIcon },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const { isDark, toggleDark } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
if (menuOpen) document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [menuOpen]);
|
||||
|
||||
function toggleGroup(label: string) {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut();
|
||||
if (session?.user?.id) {
|
||||
localStorage.removeItem(`hellth-theme:${session.user.id}`);
|
||||
}
|
||||
document.documentElement.classList.remove("dark");
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col border-r border-gray-200 bg-white dark:border-neutral-800 dark:bg-neutral-900 lg:flex">
|
||||
<div className="border-b border-gray-100 px-5 py-5 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-[36.8px] w-[36.8px] items-center justify-center rounded-lg bg-[#0F766E] text-xs font-bold text-white">
|
||||
HH
|
||||
</div>
|
||||
<div>
|
||||
<p className="leading-none text-sm font-semibold text-gray-900 dark:text-neutral-100">Hellth Hub</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">Home-Lab Health</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto px-3 py-4">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
className="mb-1.5 flex w-full items-center justify-between px-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-gray-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{group.label}
|
||||
<ChevronRightIcon className={`h-3 w-3 transition-transform ${!collapsedGroups.has(group.label) ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{!collapsedGroups.has(group.label) ? (
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
if (item.disabled || !item.href) {
|
||||
return (
|
||||
<li key={`${group.label}-${item.label}`}>
|
||||
<span className="group flex cursor-not-allowed items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-gray-300 dark:text-neutral-600">
|
||||
<item.icon className="h-4.5 w-4.5 shrink-0" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.hint ? <span className="text-[10px] uppercase">{item.hint}</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-all ${
|
||||
active
|
||||
? "bg-teal-50 text-[#0F766E] dark:bg-teal-950/40"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`h-4.5 w-4.5 shrink-0 ${active ? "text-[#0F766E]" : "text-gray-400 group-hover:text-gray-600"}`} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{active ? <ChevronRightIcon className="h-3.5 w-3.5 text-[#0F766E]" /> : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto border-t border-gray-100 px-3 py-2 dark:border-neutral-800">
|
||||
<div ref={menuRef} className="relative">
|
||||
{menuOpen ? (
|
||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="border-b border-gray-100 px-4 py-3 dark:border-neutral-800">
|
||||
<p className="truncate text-sm font-semibold text-gray-900 dark:text-neutral-100">
|
||||
{session?.user?.name ?? "Administrator"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-100 px-4 py-2.5 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isDark ? <SunIcon className="h-4 w-4 text-gray-400" /> : <MoonIcon className="h-4 w-4 text-gray-400" />}
|
||||
<span className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
{isDark ? "Helles Design" : "Dunkles Design"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDark}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
|
||||
isDark ? "bg-[#0F766E]" : "bg-gray-200"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Dunkles Design umschalten"
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
|
||||
isDark ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/einstellungen"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Cog6ToothIcon className="h-4 w-4 text-gray-400" />
|
||||
Einstellungen
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ArrowRightStartOnRectangleIcon className="h-4 w-4 text-gray-400" />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 hover:bg-gray-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#0F766E] text-xs font-bold text-white">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="truncate text-xs font-medium text-gray-900 dark:text-neutral-100">
|
||||
{session?.user?.name ?? "Administrator"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
|
||||
</div>
|
||||
<ChevronUpDownIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
67
apps/admin/app/_components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
type ThemeContextValue = { isDark: boolean; toggleDark: () => void };
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
isDark: false,
|
||||
toggleDark: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [localInit, setLocalInit] = useState(false);
|
||||
|
||||
// 1. Start neutral; the user profile below owns the persisted theme.
|
||||
useEffect(() => {
|
||||
setLocalInit(true);
|
||||
}, []);
|
||||
|
||||
// 2. Sync from DB once profile is loaded — overrides the localStorage value
|
||||
const { data: profile } = trpc.users.myProfile.useQuery(undefined, {
|
||||
enabled: localInit,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const setTheme = trpc.users.setTheme.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
const userThemeKey = `hellth-theme:${profile.id}`;
|
||||
const dbTheme = profile.theme;
|
||||
if (dbTheme === null || dbTheme === undefined) {
|
||||
const stored = localStorage.getItem(userThemeKey);
|
||||
const dark = stored === "dark";
|
||||
setIsDark(dark);
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
return;
|
||||
}
|
||||
const dark = dbTheme === "dark";
|
||||
setIsDark(dark);
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
localStorage.setItem(userThemeKey, dark ? "dark" : "light");
|
||||
}, [profile?.id, profile?.theme]);
|
||||
|
||||
function toggleDark() {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
const themeValue = next ? "dark" : "light";
|
||||
if (profile?.id) {
|
||||
localStorage.setItem(`hellth-theme:${profile.id}`, themeValue);
|
||||
}
|
||||
setTheme.mutate({ theme: themeValue });
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleDark }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
87
apps/admin/app/globals.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
/* Hellth Hub Farben */
|
||||
--color-brand: #0F766E;
|
||||
--color-brand-hover: #115E59;
|
||||
--color-brand-light: #ECFDF5;
|
||||
|
||||
--color-neutral-dark: #333333;
|
||||
--color-neutral-light: #F9F9F9;
|
||||
--color-accent: #2563EB;
|
||||
|
||||
/* Tailwind-Overrides */
|
||||
--color-background: #F9F9F9;
|
||||
--color-foreground: #333333;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bn-container[data-color-scheme="light"] {
|
||||
--bn-colors-menu-background: #ffffff;
|
||||
--bn-colors-menu-text: #111827;
|
||||
--bn-colors-menu-border: #e5e7eb;
|
||||
--bn-colors-hovered-background: #f3f4f6;
|
||||
}
|
||||
|
||||
.bn-menu-dropdown {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 14px 30px -16px rgba(17, 24, 39, 0.35);
|
||||
max-height: min(60vh, 560px);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.bn-menu-label {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bn-menu-item {
|
||||
border-radius: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bn-mantine .bn-menu-dropdown,
|
||||
.bn-mantine .mantine-Menu-dropdown {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 14px 30px -16px rgba(17, 24, 39, 0.35);
|
||||
max-height: min(60vh, 560px);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.bn-mantine .mantine-Menu-label {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bn-mantine .mantine-Menu-item {
|
||||
border-radius: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.bn-mantine .bn-editor {
|
||||
background: #ffffff;
|
||||
}
|
||||
22
apps/admin/app/layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Metadata } from "next";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@blocknote/mantine/style.css";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./_components/providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hellth Hub",
|
||||
description: "Health- und Ernährungstracking für dein Home-Lab",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
188
apps/admin/app/login/otp/page.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
|
||||
|
||||
function clearOtpStep() {
|
||||
try {
|
||||
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore storage access issues
|
||||
}
|
||||
}
|
||||
|
||||
export default function OtpLoginPage() {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [method, setMethod] = useState<"totp" | "backup">("totp");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const code = inputRef.current?.value?.trim() ?? "";
|
||||
const normalizedCode = method === "totp" ? code.replace(/\D/g, "") : code;
|
||||
|
||||
if (method === "totp" && normalizedCode.length !== 6) {
|
||||
setError("Bitte den 6-stelligen OTP-Code eingeben.");
|
||||
return;
|
||||
}
|
||||
if (method === "backup" && normalizedCode.length < 6) {
|
||||
setError("Bitte einen gültigen Backup-Code eingeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyResult =
|
||||
method === "totp"
|
||||
? await authClient.twoFactor.verifyTotp({ code: normalizedCode })
|
||||
: await authClient.twoFactor.verifyBackupCode({ code: normalizedCode });
|
||||
if (verifyResult.error) {
|
||||
const message =
|
||||
verifyResult.error.message ??
|
||||
(method === "totp"
|
||||
? "OTP-Code ungültig. Bitte erneut prüfen."
|
||||
: "Backup-Code ungültig. Bitte erneut prüfen.");
|
||||
|
||||
if (
|
||||
message.includes("session") ||
|
||||
message.includes("Session") ||
|
||||
message.includes("expired")
|
||||
) {
|
||||
setError(
|
||||
"OTP-Sitzung abgelaufen. Bitte erneut mit E-Mail und Passwort anmelden.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
method === "totp"
|
||||
? "OTP-Code ungültig. Bitte erneut prüfen."
|
||||
: "Backup-Code ungültig. Bitte erneut prüfen.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clearOtpStep();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError("2FA-Prüfung derzeit nicht möglich. Bitte erneut versuchen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-[#0F766E] text-lg font-bold text-white shadow-sm">
|
||||
HH
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[#333333]">Hellth Hub</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">OTP-Verifizierung</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod("totp");
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
method === "totp" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
Authenticator-Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod("backup");
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
method === "backup" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
Backup-Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 bg-[#ECFDF5] border border-red-100 px-3 py-2.5 rounded-lg">
|
||||
<span className="w-4 h-4 text-[#0F766E] shrink-0">!</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#333333] mb-1.5">
|
||||
{method === "totp" ? "OTP-Code (6-stellig)" : "Backup-Code"}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
suppressHydrationWarning
|
||||
name={method === "totp" ? "one-time-code" : "backup-code"}
|
||||
autoComplete={method === "totp" ? "one-time-code" : "off"}
|
||||
inputMode={method === "totp" ? "numeric" : "text"}
|
||||
pattern={method === "totp" ? "[0-9]{6}" : undefined}
|
||||
maxLength={method === "totp" ? 6 : 64}
|
||||
onInput={(e) => {
|
||||
const input = e.currentTarget;
|
||||
if (method === "totp") {
|
||||
input.value = input.value.replace(/\D/g, "").slice(0, 6);
|
||||
}
|
||||
}}
|
||||
placeholder={method === "totp" ? "123456" : "z. B. ABCD-EFGH"}
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
|
||||
/>
|
||||
{method === "backup" && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Jeder Backup-Code ist nur einmal gültig.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-[#0F766E] text-white text-sm font-semibold rounded-lg hover:bg-[#115E59] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? "Prüfen..." : method === "totp" ? "OTP prüfen" : "Backup-Code prüfen"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearOtpStep();
|
||||
router.replace("/login");
|
||||
}}
|
||||
className="w-full py-2.5 bg-white text-[#333333] text-sm font-semibold rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Zurück zu Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
apps/admin/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import { authClient, resolveApiBaseUrl, signIn } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
|
||||
const OTP_STEP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function hasActiveOtpStep(): boolean {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OTP_STEP_STORAGE_KEY);
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw) as { expiresAt?: number };
|
||||
if (!parsed?.expiresAt || Date.now() > parsed.expiresAt) {
|
||||
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setOtpStepActive() {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
OTP_STEP_STORAGE_KEY,
|
||||
JSON.stringify({ expiresAt: Date.now() + OTP_STEP_TTL_MS }),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage access issues
|
||||
}
|
||||
}
|
||||
|
||||
function clearOtpStep() {
|
||||
try {
|
||||
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore storage access issues
|
||||
}
|
||||
}
|
||||
|
||||
async function assertAuthApiReachable() {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${resolveApiBaseUrl().replace(/\/$/, "")}/api/health`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otpStepChecked, setOtpStepChecked] = useState(false);
|
||||
const [passkeyAutofillTried, setPasskeyAutofillTried] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveOtpStep()) {
|
||||
router.replace("/login/otp");
|
||||
return;
|
||||
}
|
||||
setOtpStepChecked(true);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!otpStepChecked || passkeyAutofillTried) return;
|
||||
|
||||
async function tryPasskeyAutofill() {
|
||||
setPasskeyAutofillTried(true);
|
||||
if (typeof window === "undefined" || !("PublicKeyCredential" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pk = window.PublicKeyCredential as typeof PublicKeyCredential & {
|
||||
isConditionalMediationAvailable?: () => Promise<boolean>;
|
||||
};
|
||||
if (typeof pk.isConditionalMediationAvailable !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await pk.isConditionalMediationAvailable();
|
||||
if (!available) return;
|
||||
|
||||
const result = await authClient.signIn.passkey({ autoFill: true });
|
||||
const hasTwoFactorChallenge =
|
||||
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
||||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
||||
|
||||
if (result.error) return;
|
||||
if (hasTwoFactorChallenge) {
|
||||
setOtpStepActive();
|
||||
router.push("/login/otp");
|
||||
return;
|
||||
}
|
||||
clearOtpStep();
|
||||
router.push("/");
|
||||
} catch {
|
||||
// Best-effort only; manual login remains available.
|
||||
}
|
||||
}
|
||||
|
||||
void tryPasskeyAutofill();
|
||||
}, [otpStepChecked, passkeyAutofillTried, router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await assertAuthApiReachable();
|
||||
const result = await signIn.email({ email, password });
|
||||
const hasTwoFactorChallenge =
|
||||
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
||||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
||||
|
||||
if (result.error) {
|
||||
setError("E-Mail oder Passwort ungültig.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactorChallenge) {
|
||||
setOtpStepActive();
|
||||
router.push("/login/otp");
|
||||
return;
|
||||
}
|
||||
|
||||
clearOtpStep();
|
||||
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError(
|
||||
"Login derzeit nicht möglich. Bitte prüfe, ob API/Auth auf Port 3002 läuft.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authClient.signIn.passkey();
|
||||
const hasTwoFactorChallenge =
|
||||
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
||||
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
||||
|
||||
if (result.error) {
|
||||
const message = (result.error.message ?? "").toLowerCase();
|
||||
if (message.includes("cancel")) {
|
||||
setError("Passkey-Vorgang abgebrochen.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
window.location.hostname !== "localhost" &&
|
||||
(message.includes("origin") || message.includes("rp") || message.includes("domain"))
|
||||
) {
|
||||
setError(
|
||||
"Passkey auf lokaler Entwicklung bitte unter http://localhost:3001 nutzen (nicht 127.0.0.1 oder ::1).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(result.error.message ?? "Passkey-Anmeldung fehlgeschlagen. Bitte erneut versuchen.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTwoFactorChallenge) {
|
||||
setOtpStepActive();
|
||||
router.push("/login/otp");
|
||||
return;
|
||||
}
|
||||
|
||||
clearOtpStep();
|
||||
router.push("/");
|
||||
} catch {
|
||||
setError("Passkey-Anmeldung derzeit nicht möglich.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
|
||||
{!otpStepChecked ? (
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#0F766E] border-t-transparent animate-spin" />
|
||||
) : (
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-[#0F766E] text-lg font-bold text-white shadow-sm">
|
||||
HH
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-[#333333]">Hellth Hub</h1>
|
||||
<p className="mt-2 text-sm text-gray-500">Health-Tracking für dein Home-Lab</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
|
||||
>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 bg-[#ECFDF5] border border-red-100 px-3 py-2.5 rounded-lg">
|
||||
<span className="w-4 h-4 text-[#0F766E] shrink-0">!</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#333333] mb-1.5">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username webauthn"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#333333] mb-1.5">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="************"
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-[#0F766E] text-white text-sm font-semibold rounded-lg hover:bg-[#115E59] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? "Anmelden..." : "Anmelden"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-white text-[#333333] text-sm font-semibold rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Mit Passkey anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/admin/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
import { twoFactorClient } from "better-auth/client/plugins";
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
|
||||
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
|
||||
const OTP_STEP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function setOtpStepActive() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
OTP_STEP_STORAGE_KEY,
|
||||
JSON.stringify({ expiresAt: Date.now() + OTP_STEP_TTL_MS }),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage access issues
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveApiBaseUrl() {
|
||||
const configured = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (configured) return configured;
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("NEXT_PUBLIC_API_URL muss in Produktion gesetzt sein");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const host = window.location.hostname;
|
||||
if (host.includes("::1")) {
|
||||
return "http://127.0.0.1:3002";
|
||||
}
|
||||
}
|
||||
|
||||
return "http://localhost:3002";
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: resolveApiBaseUrl(),
|
||||
plugins: [
|
||||
twoFactorClient({
|
||||
onTwoFactorRedirect: async () => {
|
||||
setOtpStepActive();
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login/otp") {
|
||||
window.location.assign("/login/otp");
|
||||
}
|
||||
},
|
||||
}),
|
||||
passkeyClient(),
|
||||
],
|
||||
});
|
||||
|
||||
export const { signIn, signOut, signUp, useSession } = authClient;
|
||||
288
apps/admin/lib/hellth-data.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
CatalogFood,
|
||||
Recipe,
|
||||
amountToGrams,
|
||||
buildShoppingItemsFromPlan,
|
||||
buildHealthStatsSummary,
|
||||
buildWeeklyBudgetSummary,
|
||||
calcFoodForAmount,
|
||||
calcRecipe,
|
||||
classifyRecipe,
|
||||
defaultHellthData,
|
||||
describeIngredientAmount,
|
||||
parseNutritionLabel,
|
||||
recipeMacroDistribution,
|
||||
redistributeWeeklyCalories,
|
||||
startOfWeek,
|
||||
unitLabel,
|
||||
weekDates,
|
||||
} from "./hellth-data";
|
||||
|
||||
const oliveOil: CatalogFood = {
|
||||
id: "oil",
|
||||
name: "Olivenöl",
|
||||
category: "Öl & Fett",
|
||||
kcalPer100g: 884,
|
||||
proteinPer100g: 0,
|
||||
carbsPer100g: 0,
|
||||
fatPer100g: 100,
|
||||
servingG: 15,
|
||||
preferredUnit: "tbsp",
|
||||
densityGPerMl: 0.91,
|
||||
portions: [
|
||||
{ id: "oil-tsp", label: "1 TL", unit: "tsp", amount: 1, gramsEquivalent: 4.6 },
|
||||
{ id: "oil-tbsp", label: "1 EL", unit: "tbsp", amount: 1, gramsEquivalent: 13.7 },
|
||||
],
|
||||
source: "manual",
|
||||
};
|
||||
|
||||
test("amountToGrams converts metric, liquid and portion units", () => {
|
||||
assert.equal(amountToGrams(oliveOil, 10, "g"), 10);
|
||||
assert.equal(amountToGrams(oliveOil, 100, "ml"), 91);
|
||||
assert.equal(Math.round(amountToGrams(oliveOil, 2, "tsp") * 10) / 10, 9.1);
|
||||
assert.equal(amountToGrams(oliveOil, 2, "tbsp", "oil-tbsp"), 27.4);
|
||||
});
|
||||
|
||||
test("unitLabel and describeIngredientAmount keep user-facing German labels", () => {
|
||||
assert.equal(unitLabel("piece"), "Stück");
|
||||
assert.equal(unitLabel("tbsp"), "EL");
|
||||
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7, amount: 1, unit: "tbsp" }), "1 EL");
|
||||
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7 }), "14 g");
|
||||
});
|
||||
|
||||
test("calcFoodForAmount and calcRecipe calculate calories and macros per portion", () => {
|
||||
const skyr: CatalogFood = {
|
||||
id: "skyr",
|
||||
name: "Skyr",
|
||||
category: "Milchprodukte",
|
||||
kcalPer100g: 64,
|
||||
proteinPer100g: 11,
|
||||
carbsPer100g: 4,
|
||||
fatPer100g: 0.2,
|
||||
servingG: 500,
|
||||
source: "manual",
|
||||
};
|
||||
const oats: CatalogFood = {
|
||||
id: "oats",
|
||||
name: "Haferflocken",
|
||||
category: "Getreide",
|
||||
kcalPer100g: 372,
|
||||
proteinPer100g: 13.5,
|
||||
carbsPer100g: 58.7,
|
||||
fatPer100g: 7,
|
||||
servingG: 50,
|
||||
source: "manual",
|
||||
};
|
||||
const recipe: Recipe = {
|
||||
id: "recipe",
|
||||
name: "Protein-Frühstück",
|
||||
portions: 2,
|
||||
minutes: 5,
|
||||
ingredients: [
|
||||
{ foodId: "skyr", amountG: 500 },
|
||||
{ foodId: "oats", amountG: 100 },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(calcFoodForAmount(skyr, 250), { kcal: 160, protein: 27.5, carbs: 10, fat: 0.5 });
|
||||
assert.deepEqual(calcRecipe(recipe, [skyr, oats]), {
|
||||
kcal: 346,
|
||||
protein: 34.3,
|
||||
carbs: 39.4,
|
||||
fat: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
|
||||
const oats: CatalogFood = {
|
||||
id: "oats",
|
||||
name: "Haferflocken",
|
||||
category: "Getreide",
|
||||
kcalPer100g: 372,
|
||||
proteinPer100g: 13.5,
|
||||
carbsPer100g: 58.7,
|
||||
fatPer100g: 7,
|
||||
servingG: 50,
|
||||
source: "manual",
|
||||
};
|
||||
const tofu: CatalogFood = {
|
||||
id: "tofu",
|
||||
name: "Tofu natur",
|
||||
category: "Protein",
|
||||
kcalPer100g: 140,
|
||||
proteinPer100g: 16,
|
||||
carbsPer100g: 2,
|
||||
fatPer100g: 8,
|
||||
servingG: 200,
|
||||
source: "manual",
|
||||
};
|
||||
const chicken: CatalogFood = {
|
||||
id: "chicken",
|
||||
name: "Hähnchenbrust",
|
||||
category: "Fleisch",
|
||||
kcalPer100g: 110,
|
||||
proteinPer100g: 23,
|
||||
carbsPer100g: 0,
|
||||
fatPer100g: 1.5,
|
||||
servingG: 180,
|
||||
source: "manual",
|
||||
};
|
||||
const veganRecipe: Recipe = {
|
||||
id: "vegan",
|
||||
name: "Tofu Bowl",
|
||||
portions: 1,
|
||||
ingredients: [
|
||||
{ foodId: "tofu", amountG: 200 },
|
||||
{ foodId: "oats", amountG: 50 },
|
||||
],
|
||||
};
|
||||
const chickenRecipe: Recipe = {
|
||||
id: "chicken",
|
||||
name: "Hähnchen Bowl",
|
||||
portions: 1,
|
||||
ingredients: [{ foodId: "chicken", amountG: 180 }],
|
||||
};
|
||||
|
||||
const vegan = classifyRecipe(veganRecipe, [oats, tofu, chicken]);
|
||||
const meat = classifyRecipe(chickenRecipe, [oats, tofu, chicken]);
|
||||
|
||||
assert.equal(vegan.vegetarian, true);
|
||||
assert.equal(vegan.vegan, true);
|
||||
assert.ok(vegan.tags.includes("vegan"));
|
||||
assert.ok(vegan.criteria.some((item) => item.label === "Protein sinnvoll" && item.ok));
|
||||
assert.equal(meat.vegetarian, false);
|
||||
assert.equal(recipeMacroDistribution({ protein: 25, carbs: 50, fat: 10 }).carbs, 51);
|
||||
});
|
||||
|
||||
test("parseNutritionLabel extracts German nutrition labels with comma decimals", () => {
|
||||
const parsed = parseNutritionLabel(`
|
||||
Durchschnittliche Nährwerte je 100 g
|
||||
Energie 57 kcal
|
||||
Fett 0,1 g
|
||||
Kohlenhydrate 3,8 g
|
||||
Eiweiß 10,0 g
|
||||
`);
|
||||
|
||||
assert.equal(parsed.kcalPer100g, 57);
|
||||
assert.equal(parsed.fatPer100g, 0.1);
|
||||
assert.equal(parsed.carbsPer100g, 3.8);
|
||||
assert.equal(parsed.proteinPer100g, 10);
|
||||
});
|
||||
|
||||
test("week helpers start on Monday and build seven dates", () => {
|
||||
assert.equal(startOfWeek("2026-05-31"), "2026-05-25");
|
||||
assert.deepEqual(weekDates("2026-05-31"), [
|
||||
"2026-05-25",
|
||||
"2026-05-26",
|
||||
"2026-05-27",
|
||||
"2026-05-28",
|
||||
"2026-05-29",
|
||||
"2026-05-30",
|
||||
"2026-05-31",
|
||||
]);
|
||||
});
|
||||
|
||||
test("weekly budget includes planned meals and activity calories", () => {
|
||||
const data = defaultHellthData();
|
||||
data.goals.calories = 2000;
|
||||
data.weeklyCaloriePlans = [{ date: "2026-05-30", plannedCalories: 2800, note: "Restaurant" }];
|
||||
data.activityLogs = [{ id: "run", date: "2026-05-25", name: "Laufen", minutes: 60, points: 100, calories: 550 }];
|
||||
data.plannedMeals = [
|
||||
{ id: "meal", date: "2026-05-25", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
|
||||
];
|
||||
|
||||
const summary = buildWeeklyBudgetSummary(data, "2026-05-25");
|
||||
|
||||
assert.equal(summary.baseBudget, 14000);
|
||||
assert.equal(summary.plannedBudget, 14800);
|
||||
assert.equal(summary.activityCalories, 550);
|
||||
assert.ok(summary.eatenCalories > 0);
|
||||
assert.equal(summary.days.find((day) => day.date === "2026-05-30")?.plannedCalories, 2800);
|
||||
});
|
||||
|
||||
test("redistributeWeeklyCalories raises one day and lowers the remaining week gently", () => {
|
||||
const data = defaultHellthData();
|
||||
data.goals.calories = 2000;
|
||||
|
||||
const plans = redistributeWeeklyCalories(data, "2026-05-30", 800, "2026-05-25");
|
||||
const saturday = plans.find((plan) => plan.date === "2026-05-30");
|
||||
const monday = plans.find((plan) => plan.date === "2026-05-25");
|
||||
|
||||
assert.equal(saturday?.plannedCalories, 2800);
|
||||
assert.equal(monday?.plannedCalories, 1866);
|
||||
assert.equal(saturday?.note, "bewusst höher geplant");
|
||||
});
|
||||
|
||||
test("buildShoppingItemsFromPlan aggregates recipe ingredients and preserves manual items", () => {
|
||||
const data = defaultHellthData();
|
||||
data.shoppingItems = [
|
||||
{ id: "manual", name: "Küchenrolle", category: "Manuell", amountG: 0, purchaseAmount: "1 Packung" },
|
||||
{ id: "old-egg", foodId: "food-egg", name: "Ei", category: "Protein", amountG: 10, checked: true },
|
||||
];
|
||||
data.plannedMeals = [
|
||||
{ id: "plan-a", date: "2026-05-26", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
|
||||
{ id: "plan-b", date: "2026-05-27", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 2 },
|
||||
];
|
||||
|
||||
const items = buildShoppingItemsFromPlan(data, ["2026-05-26"]);
|
||||
const egg = items.find((item) => item.foodId === "food-egg");
|
||||
|
||||
assert.equal(egg?.amountG, 180);
|
||||
assert.equal(egg?.checked, true);
|
||||
assert.deepEqual(egg?.recipeIds, ["recipe-eggs"]);
|
||||
assert.ok(items.some((item) => item.id === "manual"));
|
||||
});
|
||||
|
||||
test("buildHealthStatsSummary creates motivating averages, streaks and weight projection", () => {
|
||||
const data = defaultHellthData();
|
||||
data.goals.drinkMl = 2000;
|
||||
data.goals.protein = 120;
|
||||
data.goals.weightKg = 100;
|
||||
data.healthProfile = {
|
||||
completedAt: "2026-05-01T00:00:00.000Z",
|
||||
goal: "lose_weight",
|
||||
age: 40,
|
||||
heightCm: 180,
|
||||
currentWeightKg: 110,
|
||||
targetWeightKg: 100,
|
||||
activityLevel: "moderate",
|
||||
pace: "normal",
|
||||
mealsPerDay: 3,
|
||||
dietStyle: "mixed",
|
||||
cookingTime: "medium",
|
||||
budgetFocus: false,
|
||||
excludedFoods: "",
|
||||
biggestChallenge: "",
|
||||
medicalNote: false,
|
||||
};
|
||||
data.activityLogs = [
|
||||
{ id: "a1", date: "2026-05-25", name: "Spaziergang", minutes: 30, points: 60, calories: 140 },
|
||||
{ id: "a2", date: "2026-05-26", name: "Laufen", minutes: 40, points: 90, calories: 380 },
|
||||
];
|
||||
|
||||
const summary = buildHealthStatsSummary(
|
||||
data,
|
||||
{
|
||||
days: {
|
||||
"2026-05-13": { weightKg: 110 },
|
||||
"2026-05-19": { weightKg: 109 },
|
||||
"2026-05-26": {
|
||||
weightKg: 108,
|
||||
drinks: [{ amountMl: 1000 }, { amountMl: 1000 }],
|
||||
foods: [{ calories: 1800, protein: 100 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
"2026-05-26",
|
||||
);
|
||||
|
||||
assert.equal(summary.averages.activityCalories > 0, true);
|
||||
assert.equal(summary.streaks.waterDays, 1);
|
||||
assert.equal(summary.streaks.proteinDays, 1);
|
||||
assert.equal(summary.projection?.kind, "weight");
|
||||
assert.equal(summary.projection?.targetDate !== undefined, true);
|
||||
});
|
||||
|
||||
|
||||
1149
apps/admin/lib/hellth-data.ts
Normal file
107
apps/admin/lib/news-list-filters.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export type NewsLifecycleStatus = "draft" | "scheduled" | "published" | "archived";
|
||||
export type NewsSourceFilter = "all" | "native" | "imported";
|
||||
export type NewsPinnedFilter = "all" | "yes" | "no";
|
||||
|
||||
export type NewsListFilters = {
|
||||
q: string;
|
||||
statuses: NewsLifecycleStatus[];
|
||||
year: "all" | string;
|
||||
source: NewsSourceFilter;
|
||||
pinned: NewsPinnedFilter;
|
||||
showTeaser: boolean;
|
||||
page: number;
|
||||
};
|
||||
|
||||
export type NewsListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
teaser: string | null;
|
||||
source: "native" | "imported";
|
||||
isPinned: boolean;
|
||||
lifecycleStatus: NewsLifecycleStatus;
|
||||
publishedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
author: { name?: string | null; email?: string | null } | null;
|
||||
};
|
||||
|
||||
type SearchParamsLike = {
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
|
||||
const ALL_STATUSES: NewsLifecycleStatus[] = ["draft", "scheduled", "published", "archived"];
|
||||
const DEFAULT_STATUSES: NewsLifecycleStatus[] = ["draft", "scheduled", "published"];
|
||||
|
||||
function sanitizeStatuses(input: string | null): NewsLifecycleStatus[] {
|
||||
if (!input) return [...DEFAULT_STATUSES];
|
||||
const requested = input
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is NewsLifecycleStatus =>
|
||||
ALL_STATUSES.includes(value as NewsLifecycleStatus),
|
||||
);
|
||||
if (requested.length === 0) return [...DEFAULT_STATUSES];
|
||||
return Array.from(new Set(requested));
|
||||
}
|
||||
|
||||
export function parseNewsListFilters(params: SearchParamsLike): NewsListFilters {
|
||||
const rawPage = Number(params.get("page") ?? "1");
|
||||
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
|
||||
|
||||
const rawYear = params.get("year");
|
||||
const year = rawYear && rawYear !== "all" ? rawYear : "all";
|
||||
|
||||
const rawSource = params.get("source");
|
||||
const source: NewsSourceFilter =
|
||||
rawSource === "native" || rawSource === "imported" ? rawSource : "all";
|
||||
|
||||
const rawPinned = params.get("pinned");
|
||||
const pinned: NewsPinnedFilter = rawPinned === "yes" || rawPinned === "no" ? rawPinned : "all";
|
||||
|
||||
return {
|
||||
q: (params.get("q") ?? "").trim(),
|
||||
statuses: sanitizeStatuses(params.get("statuses")),
|
||||
year,
|
||||
source,
|
||||
pinned,
|
||||
showTeaser: params.get("showTeaser") !== "0",
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNewsListSearchParams(filters: NewsListFilters): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
if (filters.statuses.join(",") !== DEFAULT_STATUSES.join(",")) {
|
||||
params.set("statuses", filters.statuses.join(","));
|
||||
}
|
||||
if (filters.year !== "all") params.set("year", filters.year);
|
||||
if (filters.source !== "all") params.set("source", filters.source);
|
||||
if (filters.pinned !== "all") params.set("pinned", filters.pinned);
|
||||
if (!filters.showTeaser) params.set("showTeaser", "0");
|
||||
if (filters.page > 1) params.set("page", String(filters.page));
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function applyNewsListFilters(items: NewsListItem[], filters: NewsListFilters) {
|
||||
const q = filters.q.toLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (!filters.statuses.includes(item.lifecycleStatus)) return false;
|
||||
if (filters.source !== "all" && item.source !== filters.source) return false;
|
||||
if (filters.pinned === "yes" && !item.isPinned) return false;
|
||||
if (filters.pinned === "no" && item.isPinned) return false;
|
||||
|
||||
if (filters.year !== "all") {
|
||||
const baseDate = item.publishedAt ?? item.createdAt;
|
||||
const year = String(new Date(baseDate).getFullYear());
|
||||
if (year !== filters.year) return false;
|
||||
}
|
||||
|
||||
if (!q) return true;
|
||||
const haystack = `${item.title} ${item.teaser ?? ""} ${item.author?.name ?? ""} ${item.author?.email ?? ""}`
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
82
apps/admin/lib/page-editor.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export type LifecycleStatus = "draft" | "scheduled" | "published" | "archived";
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 100)
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function extractH1Title(html: string): string {
|
||||
const match = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (!match?.[1]) return "";
|
||||
return stripHtml(match[1]).slice(0, 160).trim();
|
||||
}
|
||||
|
||||
export function toDateValue(date?: Date | string | null) {
|
||||
if (!date) return "";
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function toTimeValue(date?: Date | string | null) {
|
||||
if (!date) return "";
|
||||
return new Date(date).toISOString().slice(11, 16);
|
||||
}
|
||||
|
||||
export function combineDateTime(date: string, time: string) {
|
||||
if (!date || !time) return undefined;
|
||||
return new Date(`${date}T${time}:00`);
|
||||
}
|
||||
|
||||
export function isSchedulingVisible(status: LifecycleStatus) {
|
||||
return status === "draft" || status === "scheduled";
|
||||
}
|
||||
|
||||
export function validatePublishInputs(
|
||||
status: LifecycleStatus,
|
||||
publishDate: string,
|
||||
publishTime: string,
|
||||
) {
|
||||
if (!isSchedulingVisible(status)) return null;
|
||||
|
||||
if (status === "scheduled" && (!publishDate || !publishTime)) {
|
||||
return "Für den Status Geplant sind Datum und Uhrzeit erforderlich.";
|
||||
}
|
||||
|
||||
if ((publishDate && !publishTime) || (!publishDate && publishTime)) {
|
||||
return "Bitte Datum und Uhrzeit gemeinsam setzen.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveSubmitStatus(
|
||||
status: LifecycleStatus,
|
||||
publishDate: string,
|
||||
publishTime: string,
|
||||
) {
|
||||
if (status === "draft" && publishDate && publishTime) return "scheduled" as const;
|
||||
return status;
|
||||
}
|
||||
|
||||
export function statusLabel(status: LifecycleStatus) {
|
||||
if (status === "archived") return "Archiviert";
|
||||
if (status === "published") return "Veröffentlicht";
|
||||
if (status === "scheduled") return "Geplant";
|
||||
return "Entwurf";
|
||||
}
|
||||
|
||||
export function statusClass(status: LifecycleStatus) {
|
||||
if (status === "archived") return "bg-gray-200 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300";
|
||||
if (status === "published") return "bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-400";
|
||||
if (status === "scheduled") return "bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-400";
|
||||
return "bg-gray-100 text-gray-500 dark:bg-neutral-700 dark:text-neutral-400";
|
||||
}
|
||||
4
apps/admin/lib/trpc.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { AppRouter } from "@hellth/api/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
6
apps/admin/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
24
apps/admin/next.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(self)" },
|
||||
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
||||
{ key: "Cross-Origin-Resource-Policy", value: "same-site" },
|
||||
{ key: "Content-Security-Policy", value: "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http: https:;" },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
54
apps/admin/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3001",
|
||||
"test": "node --import tsx --test lib/**/*.test.ts",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.4.18",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/utils": "^6.0.22",
|
||||
"@hellth/api": "workspace:*",
|
||||
"@tanstack/react-query": "^5.72.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-table-cell": "^3.20.0",
|
||||
"@tiptap/extension-table-header": "^3.20.0",
|
||||
"@tiptap/extension-table-row": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@trpc/client": "^11.1.0",
|
||||
"@trpc/react-query": "^11.1.0",
|
||||
"@trpc/server": "^11.1.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"superjson": "^2.2.0",
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
apps/admin/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
12
apps/admin/public/hellth-sw.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ("focus" in client) return client.focus();
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow("/");
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
});
|
||||
29
apps/admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
370
claude-info.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Hellth Hub Projektinfo
|
||||
|
||||
## Kurzüberblick
|
||||
|
||||
Hellth Hub ist eine mobile-optimierte Web-App für persönliches Health-, Ernährungs-, Aktivitäts- und Einkaufs-Tracking im Home-Lab-Betrieb.
|
||||
|
||||
Das Projekt wurde technisch aus einem kopierten Naturfreunde-Hub-Grundgerüst entwickelt. Naturfreunde-Funktionen wurden weitgehend entfernt oder ersetzt. Das alte Projekt dient nur noch als historische/technische Vorlage, nicht als fachliche Grundlage.
|
||||
|
||||
Wichtig: In deutscher UI und Dokumentation immer echte Umlaute verwenden, also `ä`, `ö`, `ü`, `ß` statt `ae`, `oe`, `ue`, `ss`.
|
||||
|
||||
## Arbeitsverzeichnis
|
||||
|
||||
Aktueller Projektpfad:
|
||||
|
||||
```text
|
||||
c:\EurOwiG\Entwicklung\forgejo\hellth-hub
|
||||
```
|
||||
|
||||
Das Monorepo liegt direkt im Repo-Root (`apps/`, `packages/`, `docker/` …). Der frühere `nf-hub/`-Unterordner wurde am 2026-05-27 entfernt und sein Inhalt nach oben gezogen.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Monorepo mit `pnpm`
|
||||
- Frontend/Admin: Next.js 16, React 19, Tailwind
|
||||
- API: Hono, tRPC, Better Auth
|
||||
- DB: PostgreSQL, Drizzle ORM
|
||||
- Docker: Postgres und pgAdmin, plus vorhandener App-Container
|
||||
- Auth: Better Auth mit Login, 2FA, Passkeys
|
||||
|
||||
## Wichtige Ports
|
||||
|
||||
- Admin-App lokal: `http://localhost:3001`
|
||||
- API lokal: `http://localhost:3002`
|
||||
- API Healthcheck: `http://localhost:3002/api/health`
|
||||
- Docker-App: `http://localhost:3000`
|
||||
- Postgres: `localhost:5432`
|
||||
- pgAdmin: `http://localhost:5050`
|
||||
|
||||
Hinweis: `http://localhost:3002` direkt kann `404` liefern. Das ist normal, weil die API unter `/api/...` hängt.
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
Docker/Postgres muss laufen:
|
||||
|
||||
```powershell
|
||||
docker ps
|
||||
```
|
||||
|
||||
Migrationen anwenden:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @hellth/db db:migrate
|
||||
```
|
||||
|
||||
API starten:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @hellth/api dev
|
||||
```
|
||||
|
||||
Admin starten:
|
||||
|
||||
```powershell
|
||||
pnpm --filter admin dev
|
||||
```
|
||||
|
||||
Seeds:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @hellth/api seed:health
|
||||
pnpm --filter @hellth/api seed:demo-recipes
|
||||
```
|
||||
|
||||
Der Demo-Recipe-Seed erzeugt 50 öffentliche Testrezepte mit IDs `demo-recipe-01` bis `demo-recipe-50`.
|
||||
|
||||
## Relevante Dateien und Bereiche
|
||||
|
||||
### Frontend
|
||||
|
||||
- `apps/admin/app/(authed)/page.tsx`
|
||||
Tagesansicht/Dashboard.
|
||||
|
||||
- `apps/admin/app/(authed)/lebensmittel/page.tsx`
|
||||
Lebensmittelkatalog, Suche, OCR/Barcode/Open Food Facts, Food-Votes.
|
||||
|
||||
- `apps/admin/app/(authed)/rezepte/page.tsx`
|
||||
Rezepte, private Rezepte, Rezeptvorschläge, Nährwert-Liveberechnung, persönliche Vorschläge.
|
||||
|
||||
- `apps/admin/app/(authed)/rezepte/pruefung/page.tsx`
|
||||
Admin-Prüfung von Rezeptvorschlägen.
|
||||
|
||||
- `apps/admin/app/(authed)/planung/page.tsx`
|
||||
Essensplanung.
|
||||
|
||||
- `apps/admin/app/(authed)/einkaufsliste/page.tsx`
|
||||
Einkaufslisten.
|
||||
|
||||
- `apps/admin/app/(authed)/maerkte/page.tsx`
|
||||
Märkte, Standort-Pins, Zonen/Laufwege, Änderungsanträge.
|
||||
|
||||
- `apps/admin/app/(authed)/aktivitaetsfeed/page.tsx`
|
||||
Admin Activity Feed für Rezeptvorschläge und Marktänderungen.
|
||||
|
||||
- `apps/admin/app/(authed)/hellfireclub/page.tsx`
|
||||
Community-/Aktivitätsbereich mit Punkten und gemeinsamer Aktivitätsplanung.
|
||||
|
||||
- `apps/admin/app/(authed)/statistiken/page.tsx`
|
||||
Statistiken, motivierende Auswertungen und Prognosen.
|
||||
|
||||
- `apps/admin/app/(authed)/gewicht/page.tsx`
|
||||
Gewichtshistorie.
|
||||
|
||||
- `apps/admin/app/(authed)/wearables/page.tsx`
|
||||
Wearables/Fitbit/Health-Connect-Vorbereitung.
|
||||
|
||||
- `apps/admin/app/(authed)/einstellungen/page.tsx`
|
||||
User-Profil, Sicherheit, 2FA, Passkeys, Rezept-/Ernährungspräferenzen.
|
||||
|
||||
- `apps/admin/app/(authed)/einstellungen/punkte/page.tsx`
|
||||
Globale Admin-Einstellung für Hellfireclub-/Aktivitätspunkte.
|
||||
|
||||
- `apps/admin/app/_components/form-field.tsx`
|
||||
Globales Formularfeld-Pattern für sichtbare Labels und konsistente Inputs. Neue Formulare sollen möglichst diese Komponente nutzen.
|
||||
|
||||
- `apps/admin/lib/hellth-data.ts`
|
||||
Frontend-nahe Health-Hilfslogik: Nährwertberechnung, Rezeptklassifizierung, Wochenbudget, Einkaufslisten, Statistiken.
|
||||
|
||||
### API
|
||||
|
||||
- `packages/api/src/trpc/routers/health.ts`
|
||||
Zentrale Health API. Enthält Foods, Recipes, Recipe Submissions, Stores, Store Change Requests, Activity Feed, Planning, Shopping, Weight, Stats, Hellfireclub, Points.
|
||||
|
||||
- `packages/api/src/trpc/routers/users.ts`
|
||||
Benutzerverwaltung und Profil.
|
||||
|
||||
- `packages/api/src/trpc/routers/security.ts`
|
||||
2FA/Passkey/Sicherheitsstatus.
|
||||
|
||||
- `packages/api/src/lib/auth.ts`
|
||||
Better Auth Setup.
|
||||
|
||||
- `packages/api/src/lib/health-coach.ts`
|
||||
Regelbasierter Ernährungs-/Health-Coach.
|
||||
|
||||
- `packages/api/src/scripts/seed-health.ts`
|
||||
Basisdaten für Lebensmittel, Märkte, Aktivitäten, Coach-Prinzipien.
|
||||
|
||||
- `packages/api/src/scripts/seed-demo-recipes.ts`
|
||||
50 Demo-Rezepte zum manuellen Testen.
|
||||
|
||||
### DB
|
||||
|
||||
- `packages/db/src/schema/health.ts`
|
||||
Zentrales Health-Schema.
|
||||
|
||||
- `packages/db/src/schema/auth.ts`
|
||||
Better-Auth-Tabellen.
|
||||
|
||||
- `packages/db/drizzle/*.sql`
|
||||
Migrationen.
|
||||
|
||||
Aktuell relevante neuere Migrationen:
|
||||
|
||||
- `0015_recipe_submissions.sql`
|
||||
- `0016_recipe_preferences.sql`
|
||||
- `0017_store_change_requests.sql`
|
||||
|
||||
## Aktuelle Kernfunktionen
|
||||
|
||||
### Dashboard / Daily View
|
||||
|
||||
- Tagesansicht für Mahlzeiten, Getränke, Gewicht und Tagesziele.
|
||||
- Zurückliegende Tage können ergänzt werden.
|
||||
- Zukunftstage sind für Essenplanung relevant.
|
||||
|
||||
### Lebensmittel
|
||||
|
||||
- Eigener Lebensmittelkatalog.
|
||||
- Nährwerte pro 100 g.
|
||||
- Portionsgrößen und Alltagseinheiten.
|
||||
- Open Food Facts Import.
|
||||
- Barcode-/Label-OCR-Vorbereitung.
|
||||
- Food-Votes:
|
||||
- `Mag ich`
|
||||
- `Nicht meins`
|
||||
- Food-Votes fließen in Rezeptvorschläge ein.
|
||||
|
||||
### Rezepte
|
||||
|
||||
- Private Rezepte möglich.
|
||||
- Rezeptvorschläge können zur Community/Admin-Prüfung eingereicht werden.
|
||||
- Akzeptierte Vorschläge werden öffentliche Rezepte.
|
||||
- Rezeptvorschläge bringen Hellfireclub-Punkte.
|
||||
- Live-Berechnung von kcal, Kohlenhydraten, Fett, Eiweiß.
|
||||
- Makroverteilung wird angezeigt.
|
||||
- Autokategorisierung:
|
||||
- kein Fleisch/Fisch → vegetarisch
|
||||
- keine tierischen Produkte → vegan
|
||||
- weitere Tags wie proteinreich, kalorienbewusst usw.
|
||||
|
||||
### Rezeptvorschläge
|
||||
|
||||
Vorschläge berücksichtigen vorrangig User-Profil:
|
||||
|
||||
- vegan/vegetarisch/Fleisch/Fisch Mehrfachauswahl
|
||||
- Mealprep 2 oder 3 Tage
|
||||
- 1x oder 2x kochen pro Tag
|
||||
- Fleisch/Fisch nie, maximal 1x am Tag oder flexibel
|
||||
- Food-Likes und Food-Dislikes
|
||||
- Kalorien, Protein und weitere Nährwerte
|
||||
|
||||
### Märkte und Einkauf
|
||||
|
||||
- User können Lebensmittelläden speichern.
|
||||
- Adresse, Koordinaten und Google-Maps-Suche/Pin-Link.
|
||||
- User kann Zonen/Abteilungen selbst sortieren.
|
||||
- Eigene Märkte zählen zunächst als User-Daten.
|
||||
- User kann Änderungen einreichen.
|
||||
- Admin kann Änderungen übernehmen; dadurch werden sie globale Vorlage.
|
||||
- Einkaufslisten nutzen Märkte und Abteilungen für bessere Laufweg-Sortierung.
|
||||
|
||||
### Admin Activity Feed
|
||||
|
||||
Pfad:
|
||||
|
||||
```text
|
||||
/aktivitaetsfeed
|
||||
```
|
||||
|
||||
Zeigt aktuell:
|
||||
|
||||
- Rezeptvorschläge
|
||||
- Marktänderungsanträge
|
||||
|
||||
Marktänderungen können direkt übernommen oder abgelehnt werden.
|
||||
|
||||
### Hellfireclub
|
||||
|
||||
- Aktivitätspunkte.
|
||||
- Gemeinsame, getrennte Aktivitäten.
|
||||
- Standort-/Bundesland-basierte Sichtbarkeit.
|
||||
- Highscore.
|
||||
|
||||
### Wochenbudget
|
||||
|
||||
- Tägliches Kalorienziel.
|
||||
- Wochenbudget.
|
||||
- Sportbonus.
|
||||
- flexible Planung, nicht strafend.
|
||||
|
||||
### Wearables
|
||||
|
||||
- Fitbit/Google Fit/Health Connect sind vorbereitet.
|
||||
- Fitbit-Aktivitätskatalog ist vorhanden.
|
||||
- Manuelle Aktivitäten sind möglich.
|
||||
|
||||
## Auth/Admin
|
||||
|
||||
Admin-User:
|
||||
|
||||
```text
|
||||
admin@onl1.eu
|
||||
```
|
||||
|
||||
Passwort steht nicht hier dokumentieren. Falls nötig über vorhandene Admin-/Seed-Skripte neu setzen.
|
||||
|
||||
Admin erzwingt 2FA. Es gibt zusätzliche Logs/Sicherheitsmechanik, falls 2FA-Status verloren geht.
|
||||
|
||||
## Tests und Checks
|
||||
|
||||
Admin-Tests:
|
||||
|
||||
```powershell
|
||||
pnpm --filter admin test
|
||||
```
|
||||
|
||||
API-Tests:
|
||||
|
||||
```powershell
|
||||
pnpm --filter @hellth/api test
|
||||
```
|
||||
|
||||
API-Typecheck:
|
||||
|
||||
```powershell
|
||||
pnpm exec tsc --noEmit --project packages/api/tsconfig.json
|
||||
```
|
||||
|
||||
Admin-Build:
|
||||
|
||||
```powershell
|
||||
$env:NEXT_PUBLIC_API_URL='http://localhost:3002'; pnpm --filter admin build
|
||||
```
|
||||
|
||||
Zuletzt erfolgreich:
|
||||
|
||||
- API-Tests: 28/28
|
||||
- Admin-Tests: 10/10
|
||||
- API-Typecheck: grün
|
||||
- Admin-Build: grün
|
||||
|
||||
## Lokale Dev-Logs
|
||||
|
||||
Wenn die Prozesse im Hintergrund gestartet wurden, liegen Logs hier:
|
||||
|
||||
```text
|
||||
.logs/api-dev.out.log
|
||||
.logs/api-dev.err.log
|
||||
.logs/admin-dev.out.log
|
||||
.logs/admin-dev.err.log
|
||||
```
|
||||
|
||||
## Wichtige Arbeitsregeln
|
||||
|
||||
- Bestehende User-Änderungen nicht zurücksetzen.
|
||||
- Keine destruktiven Git-Befehle ohne ausdrückliche Anweisung.
|
||||
- Für manuelle Code-Edits bevorzugt `apply_patch` verwenden.
|
||||
- Bei Suche `rg` bevorzugen.
|
||||
- UI mobile-first denken.
|
||||
- Keine Naturfreunde-Fachlogik wieder einführen.
|
||||
- Neue Formulare nach Möglichkeit mit `FormField` und `inputClassName` aus `apps/admin/app/_components/form-field.tsx` bauen.
|
||||
- Bei neuen DB-Funktionen Schema, Migration, API und UI zusammen denken.
|
||||
- Bei deutscher UI immer Umlaute korrekt schreiben.
|
||||
|
||||
## Aktuelle bekannte Besonderheiten
|
||||
|
||||
- `localhost:3002` direkt liefert 404; `/api/health` ist der richtige API-Test.
|
||||
- Docker stellt primär DB/pgAdmin bereit; lokale Entwicklung läuft über `pnpm --filter @hellth/api dev` und `pnpm --filter admin dev`.
|
||||
- Die Docker-App läuft zusätzlich auf `localhost:3000`, ist aber nicht der primäre lokale Dev-Workflow.
|
||||
|
||||
## Wichtige Orientierung für Claude: Repo-Konsistenz
|
||||
|
||||
Das Hellth-Hub-Monorepo liegt direkt im Repo-Root. Aktuell relevant sind:
|
||||
|
||||
- `apps/admin`
|
||||
- `packages/api`
|
||||
- `packages/db`
|
||||
- `docker/docker-compose.yml`
|
||||
- Health-spezifische Dateien unter `packages/api/src/trpc/routers/health.ts`
|
||||
- Health-Schema unter `packages/db/src/schema/health.ts`
|
||||
- Health-UI unter `apps/admin/app/(authed)/...`
|
||||
- UI-Referenzscreenshots unter `docs/inspiration/bitepal/` (kein Projektname, nur Inspiration)
|
||||
|
||||
### Bekannte Altlasten
|
||||
|
||||
Aufräum-Historie 2026-05-27:
|
||||
- Phase-1/2-Demo im Root entfernt
|
||||
- Nested `nf-hub/`-Ordner + separater Git-Verlauf (Remote `github.com/b4tzd/nf-hub`) entfernt
|
||||
- `Screenshots Bitepal/` → `docs/inspiration/bitepal/` verschoben
|
||||
- `.claude/rules.md` komplett neu geschrieben (vorher Naturfreunde-Portal-Regeln)
|
||||
- `.claude/settings.json` Paketname `@naturfreunde/db` → `@hellth/db`
|
||||
- `turbo.json` Tenant-/Member-Card-/Google-Wallet-Env-Vars entfernt
|
||||
- `.gitignore` aufgeräumt, Live-Logs gelöscht
|
||||
- `scripts/export-alte-website.py` gelöscht
|
||||
|
||||
Diese Altlast ist noch da und sollte **nicht angepackt werden**, weil sie die DB-State-Konsistenz brechen würde:
|
||||
|
||||
- `packages/db/drizzle/*.sql` und `meta/*.json` (Migrationen 0000-0008) enthalten noch Naturfreunde-Tabellen-DDL aus der Schema-Historie. Drizzle tracked sie per Datei + Hash - Umbenennen oder Löschen würde die DB inkonsistent machen. Wenn das stört, ist ein dedizierter "Schema-Reset" notwendig (alle Migrationen löschen, neue `0000_init.sql` aus aktuellem Schema generieren, DB neu aufbauen).
|
||||
|
||||
### Arbeitsregel für neue Aufgaben
|
||||
|
||||
Pfade in Antworten immer ohne `nf-hub/`-Präfix - das Monorepo liegt im Repo-Root:
|
||||
|
||||
```text
|
||||
apps/admin
|
||||
packages/api
|
||||
packages/db
|
||||
```
|
||||
|
||||
Keine Naturfreunde-Funktionalität wiederbeleben. Wenn eine Altlast aus der obigen Liste auftaucht: höchstens kurz erwähnen, dann im aktuellen Hellth-Hub-Code weiterarbeiten. Cleanup nur nach ausdrücklicher Benutzerfreigabe.
|
||||
40
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: hellth-hub
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: hellth-hub-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: hellth_hub
|
||||
POSTGRES_USER: hellth
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-hellth_dev}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hellth -d hellth_hub"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: hellth-hub-pgadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: info@herzeigen.eu
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
||||
volumes:
|
||||
- ./pgadmin/servers.json:/pgadmin4/servers.json:ro
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgadmin_data:
|
||||
14
docker/pgadmin/servers.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"Servers": {
|
||||
"1": {
|
||||
"Name": "Hellth Hub Dev",
|
||||
"Group": "Servers",
|
||||
"Host": "db",
|
||||
"Port": 5432,
|
||||
"MaintenanceDB": "hellth_hub",
|
||||
"Username": "hellth",
|
||||
"SSLMode": "prefer",
|
||||
"PassFile": "/pgpassfile"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
docs/INDEX.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Hellth Hub Dokumentation
|
||||
|
||||
## Einstieg
|
||||
|
||||
- `../README.md` - Setup, Module und Verifikation
|
||||
- `./projekt.md` - Produkt- und Architekturüberblick
|
||||
- `./security.md` - Sicherheitsleitlinien
|
||||
- `./design-guide.md` - UI- und Designgrundsätze
|
||||
- `./ai/quick-reference.md` - Kurzreferenz für Agents
|
||||
|
||||
## Leitplanken
|
||||
|
||||
Hellth Hub ist keine Community-Portal-Software mehr. Alte Altprojekt-, Tenant- und öffentliche Website-Funktionen sind entfernt oder deaktiviert. Neue Arbeit fokussiert Health-Tracking, Ernährung, Planung, Ziele, Aktivitäten, Erinnerungen, Einkaufslisten und Home-Lab-Betrieb.
|
||||
3
docs/ai/INDEX.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# AI-Dokumentation
|
||||
|
||||
- `quick-reference.md` enthält die Kurzregeln für Arbeit an Hellth Hub.
|
||||
20
docs/ai/quick-reference.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Quick Reference - Hellth Hub
|
||||
|
||||
## Produkt
|
||||
|
||||
Hellth Hub ist eine Health- und Ernährungstracking-App für Home-Lab-Betrieb. Fokus: Alltagstauglichkeit, Abnehmen, gesünder leben, Planung, Motivation und persönliche Ziele.
|
||||
|
||||
## Technik
|
||||
|
||||
- Frontend: `apps/admin`
|
||||
- API: `packages/api`
|
||||
- Datenbank: `packages/db`
|
||||
- Docker: `docker/docker-compose.yml`
|
||||
|
||||
## Regeln
|
||||
|
||||
- Umlaute korrekt schreiben.
|
||||
- Keine alten Altprojekt-/Tenant-/Community-Portal-Reste einführen.
|
||||
- Einstellungen und Health-Daten pro Benutzer speichern.
|
||||
- Auth, 2FA und Admin-Rechte nicht schwächen.
|
||||
- Tests für Health-Berechnungen, Auth-nahe Änderungen und Datenownership ergänzen.
|
||||
20
docs/design-guide.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Design Guide - Hellth Hub
|
||||
|
||||
## Richtung
|
||||
|
||||
Hellth Hub soll mobil, ruhig, schnell erfassbar und alltagstauglich wirken. Die App hilft beim Abnehmen und gesünder leben, ohne Schuldgefühle zu erzeugen.
|
||||
|
||||
## UI-Grundsätze
|
||||
|
||||
- Mobile-first, Desktop responsive.
|
||||
- Tagesansicht als primärer Einstieg.
|
||||
- Große Touch-Ziele für Essen, Trinken, Aktivitäten und Einkauf.
|
||||
- Klare Fortschrittsanzeigen für Tagesziele und Wochenbudget.
|
||||
- Gesundheitsfeedback positiv, konkret und handlungsorientiert formulieren.
|
||||
- Keine alten Altprojekt-Farben, Logos oder Portal-Begriffe verwenden.
|
||||
|
||||
## Sprache
|
||||
|
||||
- Umlaute immer korrekt schreiben.
|
||||
- Kurze, motivierende Texte.
|
||||
- Keine strafenden Labels wie „versagt“, „schlecht“ oder „zu viel gegessen“.
|
||||
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/inspiration/bitepal/Rezept/Screenshot_20260516-114336.png
Normal file
|
After Width: | Height: | Size: 483 KiB |
BIN
docs/inspiration/bitepal/Rezept/Screenshot_20260516-114347.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/inspiration/bitepal/Rezept/Screenshot_20260516-114359.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 160 KiB |
29
docs/projekt.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Hellth Hub - Projektbeschreibung
|
||||
|
||||
Hellth Hub ist eine mobile-first Web-App für persönliches Health- und Ernährungstracking im Home-Lab. Die Anwendung nutzt das stabile Grundgerüst aus Admin-UI, Auth, 2FA, Benutzerverwaltung, API, Datenbank und Docker, ist fachlich aber vollständig auf Ernährung, Ziele, Aktivität und Alltagstauglichkeit ausgerichtet.
|
||||
|
||||
## Kernbereiche
|
||||
|
||||
- Tagesansicht: Mahlzeiten, Getränke, Gewicht, Aktivitäten, Ziele und Coach-Hinweise.
|
||||
- Lebensmittel: eigener Katalog mit Nährwerten pro 100 g, Portionen, Favoriten, Barcode-/OCR-Vorbereitung.
|
||||
- Rezepte: Zutaten aus Lebensmitteln, flexible Einheiten, Portionen, Bewertungen und Meal-Prep.
|
||||
- Planung: Tages- und Wochenplanung mit Mahlzeiten und Zukunftslogik.
|
||||
- Wochenbudget: flexible Kalorienplanung inklusive Sportbonus.
|
||||
- Einkauf: automatische Einkaufslisten nach Markt, Abteilung und Laufweg.
|
||||
- Ziele und Onboarding: persönliche Zielableitung pro Benutzer.
|
||||
- Motivation: Erinnerungen, Aktivitätspunkte und Hellfireclub-Aktivitäten.
|
||||
- Wearables: vorbereitete Struktur für Fitbit/Google-Fit-Importe.
|
||||
|
||||
## Architektur
|
||||
|
||||
- `apps/admin`: Next.js Admin- und App-Frontend.
|
||||
- `packages/api`: Hono, Better Auth und tRPC.
|
||||
- `packages/db`: Drizzle-Schema und Migrationen.
|
||||
- `docker`: PostgreSQL und pgAdmin für lokale/Home-Lab-Setups.
|
||||
|
||||
## Prinzipien
|
||||
|
||||
- Motivierend statt strafend.
|
||||
- Benutzerbezogene Einstellungen statt globale Health-Defaults.
|
||||
- Alltagseinheiten vor perfekter Laborpräzision.
|
||||
- Erweiterbar für Wearables, OCR, Barcode, Preisvergleich und Offline-Einkaufsmodus.
|
||||
18
docs/security.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Security - Hellth Hub
|
||||
|
||||
## Grundsätze
|
||||
|
||||
- Auth ist Pflicht für alle App- und Admin-Bereiche.
|
||||
- 2FA und Passkeys bleiben zentrale Sicherheitsfunktionen.
|
||||
- Benutzerverwaltung ist nur für Plattform-Admins zugänglich.
|
||||
- Health-Daten werden immer benutzerbezogen gefiltert.
|
||||
- Uploads werden nach Typ und Größe geprüft.
|
||||
- In Produktion muss HTTPS erzwungen werden.
|
||||
- Secrets gehören in `.env`, nicht in Markdown oder UI-Code.
|
||||
|
||||
## Prüfpunkte
|
||||
|
||||
- Neue tRPC-Endpunkte brauchen `protectedProcedure` oder bewusst öffentliche Routen.
|
||||
- Mutationen müssen Ownership prüfen, bevor bestehende Datensätze geändert werden.
|
||||
- Logs dürfen keine Tokens, Passwörter oder Wearable-Secrets enthalten.
|
||||
- Wearable-Tokens müssen verschlüsselt gespeichert werden, sobald echte OAuth-Anbindung aktiv ist.
|
||||
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "hellth-hub",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"test": "turbo test",
|
||||
"typecheck": "turbo typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.8.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild", "sharp", "unrs-resolver"],
|
||||
"overrides": {
|
||||
"esbuild": ">=0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/api/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@hellth/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/trpc/client.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch --import tsx src/index.ts",
|
||||
"test": "node --env-file ../../.env --import tsx --test src/**/*.test.ts",
|
||||
"seed:admin": "node --import tsx src/scripts/seed-admin.ts",
|
||||
"seed:health": "node --import tsx src/scripts/seed-health.ts",
|
||||
"seed:demo-recipes": "node --import tsx src/scripts/seed-demo-recipes.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.4.18",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/trpc-server": "^0.3.0",
|
||||
"@hellth/db": "workspace:*",
|
||||
"@trpc/server": "^11.1.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"hono": "^4.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"superjson": "^2.2.0",
|
||||
"tsx": "^4.19.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
177
packages/api/src/app.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { trpcServer } from "@hono/trpc-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { auth } from "./lib/auth";
|
||||
import { getImageExtension, getUploadsRoot, saveUploadedImage } from "./lib/uploads";
|
||||
import { resolveRequestProtocol } from "./lib/runtime-security";
|
||||
import { appRouter } from "./trpc";
|
||||
import { createContext } from "./trpc/context";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join, normalize, resolve } from "path";
|
||||
|
||||
const app = new Hono();
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
const allowedOrigins = new Set(
|
||||
((process.env.TRUSTED_ORIGINS ??
|
||||
(isProduction
|
||||
? ""
|
||||
: "http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3001")))
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
// Non-browser clients (no Origin header) do not need CORS.
|
||||
if (!origin) return undefined;
|
||||
|
||||
// Explicit allow-list from TRUSTED_ORIGINS
|
||||
if (allowedOrigins.has(origin)) return origin;
|
||||
|
||||
// Local development fallback for localhost/127.0.0.1 on arbitrary ports
|
||||
if (!isProduction) {
|
||||
try {
|
||||
const hostname = new URL(origin).hostname;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
|
||||
return origin;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (isProduction) {
|
||||
const protocol = resolveRequestProtocol({
|
||||
requestUrl: c.req.url,
|
||||
trustProxyForwardHeaders: process.env.TRUST_PROXY_FORWARD_HEADERS === "true",
|
||||
forwardedProtoHeader: c.req.header("x-forwarded-proto"),
|
||||
});
|
||||
if (protocol !== "https") {
|
||||
return c.json({ error: "HTTPS erforderlich" }, 426);
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
c.header("X-Content-Type-Options", "nosniff");
|
||||
c.header("X-Frame-Options", "DENY");
|
||||
c.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(self)");
|
||||
c.header("Cross-Origin-Resource-Policy", "same-site");
|
||||
c.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
c.header("Cross-Origin-Embedder-Policy", "unsafe-none");
|
||||
c.header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'self'");
|
||||
if (isProduction) {
|
||||
c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
||||
}
|
||||
});
|
||||
|
||||
// Better Auth
|
||||
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));
|
||||
|
||||
// Authenticated image upload for editor blocks
|
||||
app.post("/api/uploads/image", async (c) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return c.json({ error: "Nicht authentifiziert" }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file;
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ error: "Datei fehlt (field: file)" }, 400);
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return c.json({ error: "Nur Bilddateien sind erlaubt" }, 400);
|
||||
}
|
||||
|
||||
// Limit editor uploads to 8 MB
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
return c.json({ error: "Datei ist zu groß (max. 8 MB)" }, 400);
|
||||
}
|
||||
|
||||
const ext = getImageExtension(file.type, file.name);
|
||||
if (!ext) {
|
||||
return c.json({ error: "Dateiformat nicht unterstützt" }, 400);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const relPath = await saveUploadedImage(buffer, ext);
|
||||
const baseUrl = (process.env.PUBLIC_ASSETS_BASE_URL ?? process.env.BETTER_AUTH_BASE_URL ?? "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
const fallbackOrigin = new URL(c.req.url).origin;
|
||||
const publicUrl = `${baseUrl || fallbackOrigin}/uploads/${relPath}`;
|
||||
|
||||
return c.json({ url: publicUrl });
|
||||
});
|
||||
|
||||
// tRPC
|
||||
app.use(
|
||||
"/api/trpc/*",
|
||||
trpcServer({
|
||||
endpoint: "/api/trpc",
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
|
||||
// Health Check
|
||||
app.get("/api/health", (c) =>
|
||||
c.json({ status: "ok", timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// Public serving of uploaded files
|
||||
app.get("/uploads/*", async (c) => {
|
||||
const reqPath = c.req.path.replace(/^\/uploads\//, "");
|
||||
const safeRelPath = normalize(reqPath).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const absRoot = getUploadsRoot();
|
||||
const absPath = resolve(join(absRoot, safeRelPath));
|
||||
|
||||
if (!absPath.startsWith(absRoot)) {
|
||||
return c.text("Forbidden", 403);
|
||||
}
|
||||
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const fileBuffer = await readFile(absPath);
|
||||
data = fileBuffer.buffer.slice(
|
||||
fileBuffer.byteOffset,
|
||||
fileBuffer.byteOffset + fileBuffer.byteLength,
|
||||
);
|
||||
} catch {
|
||||
return c.text("Not Found", 404);
|
||||
}
|
||||
|
||||
const lower = absPath.toLowerCase();
|
||||
const contentType = lower.endsWith(".png")
|
||||
? "image/png"
|
||||
: lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|
||||
? "image/jpeg"
|
||||
: lower.endsWith(".webp")
|
||||
? "image/webp"
|
||||
: lower.endsWith(".gif")
|
||||
? "image/gif"
|
||||
: lower.endsWith(".avif")
|
||||
? "image/avif"
|
||||
: "application/octet-stream";
|
||||
|
||||
return c.newResponse(data, 200, {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
});
|
||||
});
|
||||
|
||||
export { app };
|
||||
export type { AppRouter } from "./trpc";
|
||||
11
packages/api/src/errors/codes.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const APP_ERROR_CODES = {
|
||||
AUTH_NOT_AUTHENTICATED: "AUTH_NOT_AUTHENTICATED",
|
||||
AUTH_NOT_ADMIN: "AUTH_NOT_ADMIN",
|
||||
AUTH_2FA_REQUIRED: "AUTH_2FA_REQUIRED",
|
||||
NEWS_NOT_FOUND: "NEWS_NOT_FOUND",
|
||||
NEWS_SLUG_CONFLICT: "NEWS_SLUG_CONFLICT",
|
||||
EVENT_NOT_FOUND: "EVENT_NOT_FOUND",
|
||||
UNKNOWN: "UNKNOWN",
|
||||
} as const;
|
||||
|
||||
export type AppErrorCode = (typeof APP_ERROR_CODES)[keyof typeof APP_ERROR_CODES];
|
||||
20
packages/api/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { config } from "dotenv";
|
||||
|
||||
config({ path: "../../.env" });
|
||||
|
||||
try {
|
||||
const { db, runMigrations } = await import("@hellth/db");
|
||||
await runMigrations(db);
|
||||
|
||||
const { serve } = await import("@hono/node-server");
|
||||
const { app } = await import("./app");
|
||||
|
||||
const port = Number(process.env.API_PORT) || 3002;
|
||||
|
||||
serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
|
||||
console.log(`API läuft auf http://localhost:${info.port}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("API konnte nicht gestartet werden:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
114
packages/api/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { db } from "@hellth/db";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { admin } from "better-auth/plugins/admin";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET ist nicht gesetzt - bitte .env prüfen");
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const defaultTrustedOrigins = isProduction
|
||||
? []
|
||||
: [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://[::1]:3000",
|
||||
"http://[::1]:3001",
|
||||
];
|
||||
|
||||
const envTrustedOrigins = (process.env.TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const trustedOrigins = Array.from(new Set([...defaultTrustedOrigins, ...envTrustedOrigins]));
|
||||
const passkeyOrigins = trustedOrigins.filter((origin) =>
|
||||
origin.startsWith("http://") || origin.startsWith("https://"),
|
||||
);
|
||||
const passkeyRpId = process.env.PASSKEY_RP_ID ?? "localhost";
|
||||
const passkeyRpName = process.env.PASSKEY_RP_NAME ?? "Hellth Hub";
|
||||
const authBaseUrl = process.env.BETTER_AUTH_BASE_URL ?? (isProduction ? "" : "http://localhost:3002");
|
||||
if (!authBaseUrl) {
|
||||
throw new Error("BETTER_AUTH_BASE_URL ist in Produktion zwingend erforderlich");
|
||||
}
|
||||
if (isProduction && !authBaseUrl.startsWith("https://")) {
|
||||
throw new Error("BETTER_AUTH_BASE_URL muss in Produktion mit https:// beginnen");
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: authBaseUrl,
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
}),
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 16,
|
||||
maxPasswordLength: 128,
|
||||
// Kein Self-Service SignUp. Für Seed-Script kann es temporär per Env erlaubt werden.
|
||||
disableSignUp: process.env.ALLOW_SEED_SIGNUP !== "true",
|
||||
},
|
||||
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 Tage
|
||||
updateAge: 60 * 60 * 24, // Session täglich erneuern
|
||||
cookieCache: {
|
||||
// User-Objekt (inkl. Avatar) nicht in Cookie cachen, sonst drohen Cookie-Overflows.
|
||||
enabled: false,
|
||||
maxAge: 5 * 60, // 5 Minuten Cache
|
||||
},
|
||||
},
|
||||
|
||||
advanced: {
|
||||
cookiePrefix: "hellthhub",
|
||||
cookies: {
|
||||
session_token: {
|
||||
attributes: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
},
|
||||
},
|
||||
},
|
||||
crossSubDomainCookies: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 20,
|
||||
customRules: {
|
||||
// Login-Route extra restriktiv
|
||||
"/api/auth/sign-in/email": {
|
||||
window: 60,
|
||||
max: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
admin({
|
||||
adminRoles: ["admin"],
|
||||
defaultRole: "user",
|
||||
}),
|
||||
twoFactor({
|
||||
issuer: "Hellth Hub",
|
||||
}),
|
||||
passkey({
|
||||
rpID: passkeyRpId,
|
||||
rpName: passkeyRpName,
|
||||
origin: passkeyOrigins,
|
||||
}),
|
||||
],
|
||||
|
||||
trustedOrigins,
|
||||
});
|
||||
56
packages/api/src/lib/health-coach.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildDailyCoachSignals } from "./health-coach";
|
||||
|
||||
test("buildDailyCoachSignals prioritizes medical safety notes", () => {
|
||||
const signals = buildDailyCoachSignals({
|
||||
date: "2026-05-25",
|
||||
calorieGoal: 2000,
|
||||
proteinGoal: 150,
|
||||
drinkGoalMl: 2500,
|
||||
calories: 1200,
|
||||
proteinG: 60,
|
||||
drinkMl: 800,
|
||||
activityMinutes: 0,
|
||||
activityCalories: 0,
|
||||
medicalNote: true,
|
||||
});
|
||||
|
||||
assert.equal(signals[0]?.id, "medical-note");
|
||||
assert.equal(signals[0]?.priority, "high");
|
||||
assert.ok(signals.some((signal) => signal.id === "protein-low"));
|
||||
});
|
||||
|
||||
test("buildDailyCoachSignals reacts to over-budget days without blame", () => {
|
||||
const signals = buildDailyCoachSignals({
|
||||
date: "2026-05-25",
|
||||
calorieGoal: 2000,
|
||||
proteinGoal: 150,
|
||||
drinkGoalMl: 2500,
|
||||
calories: 2450,
|
||||
proteinG: 150,
|
||||
drinkMl: 2500,
|
||||
activityMinutes: 45,
|
||||
activityCalories: 300,
|
||||
});
|
||||
|
||||
assert.ok(signals.some((signal) => signal.id === "calories-over"));
|
||||
assert.ok(signals.find((signal) => signal.id === "calories-over")?.body.includes("kein Scheitern"));
|
||||
assert.ok(signals.some((signal) => signal.id === "activity-bonus"));
|
||||
});
|
||||
|
||||
test("buildDailyCoachSignals returns steady feedback for balanced days", () => {
|
||||
const signals = buildDailyCoachSignals({
|
||||
date: "2026-05-25",
|
||||
calorieGoal: 2000,
|
||||
proteinGoal: 150,
|
||||
drinkGoalMl: 2500,
|
||||
calories: 1900,
|
||||
proteinG: 150,
|
||||
drinkMl: 2500,
|
||||
activityMinutes: 30,
|
||||
activityCalories: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(signals.map((signal) => signal.id), ["steady-day"]);
|
||||
});
|
||||
115
packages/api/src/lib/health-coach.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
export type CoachPriority = "high" | "medium" | "low";
|
||||
|
||||
export type CoachSignalInput = {
|
||||
date: string;
|
||||
calorieGoal: number;
|
||||
proteinGoal: number;
|
||||
drinkGoalMl: number;
|
||||
calories: number;
|
||||
proteinG: number;
|
||||
drinkMl: number;
|
||||
activityMinutes: number;
|
||||
activityCalories: number;
|
||||
medicalNote?: boolean;
|
||||
};
|
||||
|
||||
export type CoachSignal = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: CoachPriority;
|
||||
category: string;
|
||||
};
|
||||
|
||||
export function buildDailyCoachSignals(input: CoachSignalInput): CoachSignal[] {
|
||||
const signals: CoachSignal[] = [];
|
||||
const proteinRatio = input.proteinGoal > 0 ? input.proteinG / input.proteinGoal : 1;
|
||||
const drinkRatio = input.drinkGoalMl > 0 ? input.drinkMl / input.drinkGoalMl : 1;
|
||||
const calorieRatio = input.calorieGoal > 0 ? input.calories / input.calorieGoal : 0;
|
||||
|
||||
if (input.medicalNote) {
|
||||
signals.push({
|
||||
id: "medical-note",
|
||||
title: "Gesundheit zuerst",
|
||||
body: "Deine Ziele sollten bei medizinischen Themen mit einer Ärztin, einem Arzt oder einer Ernährungsfachkraft abgestimmt werden. Hellth Hub unterstützt, ersetzt aber keine medizinische Beratung.",
|
||||
priority: "high",
|
||||
category: "safety",
|
||||
});
|
||||
}
|
||||
|
||||
if (proteinRatio < 0.65) {
|
||||
signals.push({
|
||||
id: "protein-low",
|
||||
title: "Protein früher einplanen",
|
||||
body: "Heute ist dein Protein noch niedrig. Eine klare Proteinquelle in der nächsten Mahlzeit hilft Sättigung, Muskelerhalt und Planbarkeit.",
|
||||
priority: "high",
|
||||
category: "nutrition",
|
||||
});
|
||||
} else if (proteinRatio < 0.9) {
|
||||
signals.push({
|
||||
id: "protein-almost",
|
||||
title: "Protein fast im Ziel",
|
||||
body: "Du bist beim Protein auf einem guten Weg. Eine kleine Ergänzung wie Skyr, Eier, Hülsenfrüchte oder mageres Fleisch kann den Tag abrunden.",
|
||||
priority: "medium",
|
||||
category: "nutrition",
|
||||
});
|
||||
}
|
||||
|
||||
if (drinkRatio < 0.5) {
|
||||
signals.push({
|
||||
id: "water-low",
|
||||
title: "Trinken einfacher machen",
|
||||
body: "Dein Trinkziel ist noch weit weg. Stell dir ein großes Glas sichtbar hin und nimm die nächsten 500 ml als kleine Etappe.",
|
||||
priority: "medium",
|
||||
category: "hydration",
|
||||
});
|
||||
}
|
||||
|
||||
if (calorieRatio > 1.15) {
|
||||
signals.push({
|
||||
id: "calories-over",
|
||||
title: "Woche statt einzelner Tag",
|
||||
body: "Der Tag liegt über dem Ziel. Das ist kein Scheitern: nutze das Wochenbudget und plane die nächsten Tage etwas ruhiger.",
|
||||
priority: "medium",
|
||||
category: "weekly-budget",
|
||||
});
|
||||
} else if (calorieRatio < 0.65 && input.calories > 0) {
|
||||
signals.push({
|
||||
id: "calories-too-low",
|
||||
title: "Nicht zu hart sparen",
|
||||
body: "Der Tag wirkt sehr niedrig geplant. Ein zu großes Defizit kann Hunger und Abbrüche fördern. Plane lieber eine einfache, sättigende Mahlzeit.",
|
||||
priority: "medium",
|
||||
category: "weekly-budget",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.activityMinutes === 0) {
|
||||
signals.push({
|
||||
id: "movement-missing",
|
||||
title: "Kleine Bewegung zählt",
|
||||
body: "Fünf bis zehn Minuten gehen oder dehnen reichen als Start. Ziel ist kein perfektes Training, sondern ein besserer nächster Schritt.",
|
||||
priority: "low",
|
||||
category: "activity",
|
||||
});
|
||||
} else if (input.activityCalories > 0) {
|
||||
signals.push({
|
||||
id: "activity-bonus",
|
||||
title: "Aktivität ist eingezahlt",
|
||||
body: `Du hast dir heute etwa ${input.activityCalories} kcal Sportbonus erarbeitet. Nutze ihn bewusst, nicht automatisch.`,
|
||||
priority: "low",
|
||||
category: "activity",
|
||||
});
|
||||
}
|
||||
|
||||
if (signals.length === 0) {
|
||||
signals.push({
|
||||
id: "steady-day",
|
||||
title: "Stabiler Tag",
|
||||
body: "Die wichtigsten Werte sehen stimmig aus. Wiederholbare Tage sind wertvoller als perfekte Ausreißer.",
|
||||
priority: "low",
|
||||
category: "routine",
|
||||
});
|
||||
}
|
||||
|
||||
return signals.slice(0, 5);
|
||||
}
|
||||
62
packages/api/src/lib/html-sanitize.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"u",
|
||||
"s",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"hr",
|
||||
"code",
|
||||
"pre",
|
||||
"span",
|
||||
"div",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
|
||||
a: ["href", "name", "target", "rel"],
|
||||
img: ["src", "alt", "title", "width", "height", "loading"],
|
||||
"*": ["class"],
|
||||
};
|
||||
|
||||
const ALLOWED_SCHEMES = ["http", "https", "mailto", "tel", "data"];
|
||||
const ALLOWED_SCHEMES_BY_TAG: sanitizeHtml.IOptions["allowedSchemesByTag"] = {
|
||||
img: ["http", "https", "data"],
|
||||
};
|
||||
|
||||
export function sanitizeRichHtml(input: string | null | undefined) {
|
||||
if (!input) return "";
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
allowedSchemes: ALLOWED_SCHEMES,
|
||||
allowedSchemesByTag: ALLOWED_SCHEMES_BY_TAG,
|
||||
allowProtocolRelative: false,
|
||||
disallowedTagsMode: "discard",
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", {
|
||||
rel: "noopener noreferrer nofollow",
|
||||
target: "_blank",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
31
packages/api/src/lib/request-host.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
|
||||
function normalizeHostHeader(raw: string) {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value) return "";
|
||||
|
||||
const first = value.split(",")[0]?.trim() ?? "";
|
||||
if (!first) return "";
|
||||
if (first.startsWith("[")) {
|
||||
const idx = first.indexOf("]");
|
||||
if (idx > 0) return first.slice(1, idx);
|
||||
return "";
|
||||
}
|
||||
|
||||
return first.replace(/:\d+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function trustForwardedHost() {
|
||||
return process.env.TRUST_PROXY_FORWARD_HEADERS === "true";
|
||||
}
|
||||
|
||||
export function getRequestHostname(headers: Headers) {
|
||||
const forwardedHost = headers.get("x-forwarded-host");
|
||||
const host = headers.get("host");
|
||||
const preferred = trustForwardedHost() ? forwardedHost ?? host : host ?? forwardedHost;
|
||||
return preferred ? normalizeHostHeader(preferred) : "";
|
||||
}
|
||||
|
||||
export function getRequestHostnameFromContext(opts: FetchCreateContextFnOptions) {
|
||||
return getRequestHostname(opts.req.headers);
|
||||
}
|
||||
47
packages/api/src/lib/runtime-security.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
isPlatformAdminUser,
|
||||
parseConfiguredAdminEmails,
|
||||
resolveRequestProtocol,
|
||||
} from "./runtime-security";
|
||||
|
||||
test("parseConfiguredAdminEmails returns empty list for undefined input", () => {
|
||||
assert.deepEqual(parseConfiguredAdminEmails(undefined), []);
|
||||
});
|
||||
|
||||
test("isPlatformAdminUser is false without explicit config and without admin role", () => {
|
||||
const result = isPlatformAdminUser({
|
||||
userRole: "user",
|
||||
userEmail: "admin@onl1.eu",
|
||||
configuredAdminEmails: [],
|
||||
});
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isPlatformAdminUser accepts configured email allow-list", () => {
|
||||
const result = isPlatformAdminUser({
|
||||
userRole: "user",
|
||||
userEmail: "ADMIN@example.org",
|
||||
configuredAdminEmails: ["admin@example.org"],
|
||||
});
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("resolveRequestProtocol honors forwarded proto when proxy headers are trusted", () => {
|
||||
const protocol = resolveRequestProtocol({
|
||||
requestUrl: "http://internal-service/api/trpc",
|
||||
trustProxyForwardHeaders: true,
|
||||
forwardedProtoHeader: "https",
|
||||
});
|
||||
assert.equal(protocol, "https");
|
||||
});
|
||||
|
||||
test("resolveRequestProtocol rejects missing https in production-like direct requests", () => {
|
||||
const protocol = resolveRequestProtocol({
|
||||
requestUrl: "http://localhost:3002/api/trpc",
|
||||
trustProxyForwardHeaders: false,
|
||||
forwardedProtoHeader: null,
|
||||
});
|
||||
assert.equal(protocol, "http");
|
||||
});
|
||||
43
packages/api/src/lib/runtime-security.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export function parseConfiguredAdminEmails(raw: string | undefined) {
|
||||
return (raw ?? "")
|
||||
.split(",")
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isPlatformAdminUser(input: {
|
||||
userRole: string | null | undefined;
|
||||
userEmail: string | null | undefined;
|
||||
configuredAdminEmails: string[];
|
||||
}) {
|
||||
const normalizedRole = (input.userRole ?? "").toLowerCase();
|
||||
if (normalizedRole === "admin" || normalizedRole === "platform_admin") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedEmail = (input.userEmail ?? "").trim().toLowerCase();
|
||||
if (!normalizedEmail) return false;
|
||||
return input.configuredAdminEmails.includes(normalizedEmail);
|
||||
}
|
||||
|
||||
export function resolveRequestProtocol(input: {
|
||||
requestUrl: string;
|
||||
trustProxyForwardHeaders: boolean;
|
||||
forwardedProtoHeader: string | null | undefined;
|
||||
}) {
|
||||
if (input.trustProxyForwardHeaders) {
|
||||
const forwarded = input.forwardedProtoHeader?.split(",")[0]?.trim().toLowerCase();
|
||||
if (forwarded === "http" || forwarded === "https") {
|
||||
return forwarded;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const protocol = new URL(input.requestUrl).protocol.toLowerCase();
|
||||
if (protocol === "https:") return "https";
|
||||
} catch {
|
||||
// Fall through to fail-safe default.
|
||||
}
|
||||
|
||||
return "http";
|
||||
}
|
||||
26
packages/api/src/lib/security-audit.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
type SecurityAuditMetadata = Record<string, unknown>;
|
||||
|
||||
export function auditSecurityEvent(action: string, metadata: SecurityAuditMetadata = {}) {
|
||||
console.warn("[security]", {
|
||||
action,
|
||||
at: new Date().toISOString(),
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
export function twoFactorLossReasons(input: {
|
||||
isPrivileged: boolean;
|
||||
userTwoFactorEnabled?: boolean | null;
|
||||
twoFactorRowExists: boolean;
|
||||
twoFactorRowEnabled?: boolean | null;
|
||||
recoveryRequired?: boolean | null;
|
||||
}) {
|
||||
if (!input.isPrivileged) return [];
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (!input.userTwoFactorEnabled) reasons.push("user.twoFactorEnabled=false");
|
||||
if (!input.twoFactorRowExists) reasons.push("twoFactor row missing");
|
||||
if (input.twoFactorRowExists && !input.twoFactorRowEnabled) reasons.push("twoFactor.enabled=false");
|
||||
if (input.recoveryRequired) reasons.push("twoFactorRecoveryRequired=true");
|
||||
return reasons;
|
||||
}
|
||||