Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.
Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,66 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { fetchHello, type HelloResponse } from "@/lib/api"
|
||||
import { HomeScreen } from "@/components/home/HomeScreen"
|
||||
import { AppShell } from "@/components/layout/AppShell"
|
||||
import { LanguageProvider } from "@/lib/language"
|
||||
import { BuilderScreen } from "@/screens/BuilderScreen"
|
||||
import { PantryScreen } from "@/screens/PantryScreen"
|
||||
import { PlateScreen } from "@/screens/PlateScreen"
|
||||
import { SettingsScreen } from "@/screens/SettingsScreen"
|
||||
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
|
||||
|
||||
export default function App() {
|
||||
const [data, setData] = useState<HelloResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setData(await fetchHello())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<main className="dark:bg-background dark:text-foreground flex min-h-svh flex-col items-center justify-center bg-background gap-6 p-6 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-6">
|
||||
<span className="bg-primary/10 text-primary rounded-full px-3 py-1 text-xs font-medium">
|
||||
Pocket Pascal
|
||||
</span>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
{data?.message ?? "Hello, World!"}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A minimal installable PWA. React + shadcn/ui on the front, Express +
|
||||
SQLite on the back.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted text-muted-foreground flex w-full flex-col gap-1 rounded-lg border p-4 text-sm">
|
||||
{loading ? (
|
||||
<span>Asking the server…</span>
|
||||
) : error ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-foreground text-2xl font-semibold">
|
||||
{data?.visits.toLocaleString()}
|
||||
</span>
|
||||
<span>times this hello has been said</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw className={loading ? "animate-spin" : undefined} />
|
||||
Say it again
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
<LanguageProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<HomeScreen />} />
|
||||
<Route path="pantry" element={<PantryScreen />} />
|
||||
<Route path="knowledge" element={<PlateScreen />} />
|
||||
<Route path="weekplan" element={<WeekPlanScreen />} />
|
||||
<Route path="builder" element={<BuilderScreen />} />
|
||||
<Route path="settings" element={<SettingsScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
43
client/src/components/home/BuilderSlot.tsx
Normal file
43
client/src/components/home/BuilderSlot.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ChefHat } from "lucide-react"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
/** Navigation tile — whole card is the hit target (same pattern as KnowledgeHub). */
|
||||
export function BuilderSlot() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
variant="interactive"
|
||||
className="p-4"
|
||||
onClick={() => navigate("/builder")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
navigate("/builder")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
|
||||
style={{ backgroundImage: "var(--gradient-primary)" }}
|
||||
>
|
||||
<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="type-title-sm text-[var(--ink)]">
|
||||
{t(ui.builderSlot.title, lang)}
|
||||
</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">
|
||||
{t(ui.builderSlot.body, lang)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
133
client/src/components/home/HomeScreen.tsx
Normal file
133
client/src/components/home/HomeScreen.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { BuilderSlot } from "@/components/home/BuilderSlot"
|
||||
import { KnowledgeHub } from "@/components/home/KnowledgeHub"
|
||||
import { SuggestionCard } from "@/components/home/SuggestionCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import {
|
||||
DEFAULT_WIDGET_ORDER,
|
||||
moveWidget,
|
||||
readWidgetOrder,
|
||||
writeWidgetOrder,
|
||||
} from "@/lib/widgetOrder"
|
||||
import type { HomeWidgetId, WidgetOrder } from "@/types/domain"
|
||||
|
||||
function WidgetShell({
|
||||
id,
|
||||
editing,
|
||||
order,
|
||||
onMove,
|
||||
children,
|
||||
}: {
|
||||
id: HomeWidgetId
|
||||
editing: boolean
|
||||
order: WidgetOrder
|
||||
onMove: (id: HomeWidgetId, direction: "up" | "down") => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const index = order.indexOf(id)
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
{editing ? (
|
||||
<div className="mb-2 flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(ui.home.moveUp, lang)}
|
||||
disabled={index <= 0}
|
||||
onClick={() => onMove(id, "up")}
|
||||
className={cx(
|
||||
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
|
||||
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
"disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(ui.home.moveDown, lang)}
|
||||
disabled={index >= order.length - 1}
|
||||
onClick={() => onMove(id, "down")}
|
||||
className={cx(
|
||||
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
|
||||
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
"disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [order, setOrder] = useState<WidgetOrder>(DEFAULT_WIDGET_ORDER)
|
||||
|
||||
useEffect(() => {
|
||||
setOrder(readWidgetOrder())
|
||||
}, [])
|
||||
|
||||
function handleMove(id: HomeWidgetId, direction: "up" | "down") {
|
||||
setOrder((current) => {
|
||||
const next = moveWidget(current, id, direction)
|
||||
writeWidgetOrder(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const widgets: Record<HomeWidgetId, ReactNode> = {
|
||||
suggestion: <SuggestionCard />,
|
||||
knowledge: <KnowledgeHub />,
|
||||
builder: <BuilderSlot />,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
|
||||
<h1 className="type-display mt-1 text-[var(--ink)]">
|
||||
{t(ui.home.title, lang)}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing((value) => !value)}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[48px] items-center rounded-[var(--radius-pill)] px-4",
|
||||
"type-button text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{editing ? t(ui.home.doneEdit, lang) : t(ui.home.editLayout, lang)}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{order.map((id) => (
|
||||
<WidgetShell
|
||||
key={id}
|
||||
id={id}
|
||||
editing={editing}
|
||||
order={order}
|
||||
onMove={handleMove}
|
||||
>
|
||||
{widgets[id]}
|
||||
</WidgetShell>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
client/src/components/home/KnowledgeHub.tsx
Normal file
79
client/src/components/home/KnowledgeHub.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { BookOpen, CalendarDays, Refrigerator } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
function HubTile({
|
||||
title,
|
||||
body,
|
||||
gradient,
|
||||
icon,
|
||||
onNavigate,
|
||||
}: {
|
||||
title: string
|
||||
body: string
|
||||
gradient: string
|
||||
icon: ReactNode
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
return (
|
||||
<GlassCard
|
||||
variant="interactive"
|
||||
className="p-4"
|
||||
onClick={onNavigate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onNavigate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
|
||||
style={{ backgroundImage: gradient }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="type-title-sm text-[var(--ink)]">{title}</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeHub() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.plateTitle, lang)}
|
||||
body={t(ui.knowledgeHub.plateBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge")}
|
||||
/>
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.pantryTitle, lang)}
|
||||
body={t(ui.knowledgeHub.pantryBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
icon={<Refrigerator className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/pantry")}
|
||||
/>
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.weekplanTitle, lang)}
|
||||
body={t(ui.knowledgeHub.weekplanBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/weekplan")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
client/src/components/home/SuggestionCard.tsx
Normal file
128
client/src/components/home/SuggestionCard.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Pin, RefreshCw } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui-pp/Button"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion"
|
||||
import { formatClock, getTimeContext } from "@/lib/timeContext"
|
||||
import type { FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
||||
|
||||
const CAT_DOT: Record<string, string> = {
|
||||
protein: "var(--color-protein)",
|
||||
obst: "var(--color-obstgem)",
|
||||
gemuese: "var(--color-obstgem)",
|
||||
fett: "var(--color-fett)",
|
||||
kh: "var(--color-kh)",
|
||||
snack: "var(--color-protein)",
|
||||
}
|
||||
|
||||
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string {
|
||||
return t(ui.suggestion[slot], lang)
|
||||
}
|
||||
|
||||
export function SuggestionCard() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const context = useMemo(() => getTimeContext(), [])
|
||||
|
||||
const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot))
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const recipe: Recipe | null = suggestion.recipe
|
||||
const items: FoodItem[] = suggestion.foods
|
||||
|
||||
const clock = formatClock(context.hour, context.minute, lang)
|
||||
const header =
|
||||
lang === "de"
|
||||
? `${clock} ${t(ui.suggestion.timeSuffix, lang)} · ${slotLabel(context.slot, lang)}`
|
||||
: `${clock} · ${slotLabel(context.slot, lang)}`
|
||||
|
||||
const title = recipe ? tr(recipe.name, lang) : t(ui.suggestion.fallbackName, lang)
|
||||
|
||||
function togglePin(id: string) {
|
||||
setPinnedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function reroll() {
|
||||
if (pinnedIds.size === 0) {
|
||||
setSuggestion(buildSuggestion(context.slot, recipe?.id))
|
||||
return
|
||||
}
|
||||
setSuggestion({
|
||||
recipe: null,
|
||||
foods: reshuffleSuggestion(items, pinnedIds, context.slot),
|
||||
})
|
||||
}
|
||||
|
||||
function sendToBuilder() {
|
||||
navigate("/builder", {
|
||||
state: {
|
||||
ingredientIds: items.map((item) => item.id),
|
||||
source: "suggestion" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="type-label text-[var(--ink-faint)]">{header}</p>
|
||||
<h2 className="type-title text-[var(--ink)]">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => {
|
||||
const pinned = pinnedIds.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => togglePin(item.id)}
|
||||
aria-pressed={pinned}
|
||||
aria-label={`${tr(item.name, lang)} — ${pinned ? t(ui.suggestion.unpin, lang) : t(ui.suggestion.pin, lang)}`}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[48px] items-center gap-2 rounded-[var(--radius-pill)] px-3",
|
||||
"type-body-emphasis text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
pinned && "ring-2 ring-[var(--color-pick)]/50",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: CAT_DOT[item.cat] ?? "var(--ink-faint)" }}
|
||||
/>
|
||||
<span>{tr(item.name, lang)}</span>
|
||||
{pinned ? (
|
||||
<Pin className="h-3.5 w-3.5 text-[var(--color-pick)]" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="secondary" className="flex-1" onClick={reroll}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
||||
{t(ui.suggestion.reroll, lang)}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={sendToBuilder}>
|
||||
{t(ui.suggestion.toBuilder, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
53
client/src/components/layout/AppShell.tsx
Normal file
53
client/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom"
|
||||
import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react"
|
||||
|
||||
import { BottomNav } from "@/components/ui-pp/BottomNav"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
const ROUTES = [
|
||||
{ key: "home", path: "/", icon: Home, labelKey: "home" as const },
|
||||
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const },
|
||||
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const },
|
||||
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const },
|
||||
]
|
||||
|
||||
export function AppShell() {
|
||||
const { lang } = useLanguage()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const items = ROUTES.map((route) => {
|
||||
const Icon = route.icon
|
||||
const active =
|
||||
route.path === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(route.path)
|
||||
|
||||
return {
|
||||
key: route.key,
|
||||
label: t(ui.nav[route.labelKey], lang),
|
||||
active,
|
||||
icon: <Icon className="h-5 w-5" strokeWidth={active ? 2.4 : 2} />,
|
||||
onClick: () => navigate(route.path),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-svh text-[var(--ink)]"
|
||||
style={{ backgroundImage: "var(--page-gradient)" }}
|
||||
>
|
||||
<main
|
||||
className="mx-auto w-full max-w-lg px-4"
|
||||
style={{
|
||||
paddingTop: "calc(env(safe-area-inset-top) + 16px)",
|
||||
paddingBottom: "calc(env(safe-area-inset-bottom) + 108px)",
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<BottomNav items={items} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
client/src/components/plate/DiningPlate.tsx
Normal file
182
client/src/components/plate/DiningPlate.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useId } from "react"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface PlateSegment {
|
||||
id: string
|
||||
/** Share of the plate (relative weights; need not sum to 1). */
|
||||
weight: number
|
||||
color: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DiningPlateProps {
|
||||
segments: PlateSegment[]
|
||||
size?: number
|
||||
onSelect?: (id: string) => void
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
function polar(centerX: number, centerY: number, r: number, angleDeg: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||
return { x: centerX + r * Math.cos(rad), y: centerY + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
/** Filled pie wedge from center (food on the plate — not a thin outer ring). */
|
||||
function wedgePath(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
r: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): string {
|
||||
if (endAngle - startAngle >= 359.9) {
|
||||
return [
|
||||
`M ${centerX} ${centerY - r}`,
|
||||
`A ${r} ${r} 0 1 1 ${centerX} ${centerY + r}`,
|
||||
`A ${r} ${r} 0 1 1 ${centerX} ${centerY - r}`,
|
||||
"Z",
|
||||
].join(" ")
|
||||
}
|
||||
const start = polar(centerX, centerY, r, startAngle)
|
||||
const end = polar(centerX, centerY, r, endAngle)
|
||||
const large = endAngle - startAngle > 180 ? 1 : 0
|
||||
return [
|
||||
`M ${centerX} ${centerY}`,
|
||||
`L ${start.x} ${start.y}`,
|
||||
`A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`,
|
||||
"Z",
|
||||
].join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceramic dining plate with food-like category wedges inside the rim.
|
||||
* Rim depth via CSS inset shadows; wedges are SVG (clickable).
|
||||
*/
|
||||
export function DiningPlate({
|
||||
segments,
|
||||
size = 280,
|
||||
onSelect,
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: DiningPlateProps) {
|
||||
const uid = useId().replace(/:/g, "")
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const rimPad = size * 0.09
|
||||
const foodR = size / 2 - rimPad
|
||||
const gapDeg = 2.5
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
|
||||
let angle = 0
|
||||
const wedges = segments.map((segment) => {
|
||||
const sweep = (segment.weight / total) * 360
|
||||
const start = angle + gapDeg / 2
|
||||
const end = angle + sweep - gapDeg / 2
|
||||
angle += sweep
|
||||
return {
|
||||
...segment,
|
||||
start,
|
||||
end,
|
||||
d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("relative select-none", className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* Ceramic body + raised rim */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 45%, var(--plate-well-edge) 100%)",
|
||||
boxShadow: `
|
||||
0 8px 24px rgba(25, 24, 0, 0.12),
|
||||
inset 4px 5px 10px rgba(255, 255, 255, 0.85),
|
||||
inset -5px -6px 12px rgba(25, 24, 0, 0.12),
|
||||
inset 0 0 0 1px rgba(25, 24, 0, 0.06)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft recessed well under the food */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
inset: rimPad * 0.55,
|
||||
background:
|
||||
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
|
||||
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="relative z-10"
|
||||
>
|
||||
<defs>
|
||||
<filter id={`${uid}-food-soft`} x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="blur" />
|
||||
<feOffset dy="1" result="off" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.18" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter={`url(#${uid}-food-soft)`}>
|
||||
{wedges.map(
|
||||
(wedge) =>
|
||||
wedge.d && (
|
||||
<path
|
||||
key={wedge.id}
|
||||
d={wedge.d}
|
||||
fill={wedge.color}
|
||||
opacity={0.92}
|
||||
className={cx(
|
||||
"outline-none transition-[opacity,filter] duration-150",
|
||||
onSelect &&
|
||||
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
role={onSelect ? "button" : undefined}
|
||||
aria-label={wedge.label}
|
||||
onClick={() => onSelect?.(wedge.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (!onSelect) return
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onSelect(wedge.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Subtle inner rim line separating food from ceramic lip */}
|
||||
<circle
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
r={foodR + 1}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.55)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
client/src/components/ui-pp/Accordion.tsx
Normal file
168
client/src/components/ui-pp/Accordion.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useId, useState, type ReactNode } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface AccordionProps {
|
||||
title: ReactNode
|
||||
meta?: ReactNode
|
||||
icon?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level elevated accordion (type groups, pantry categories).
|
||||
* Bevel/fill use rounded-[inherit] so radius tokens stay consistent.
|
||||
*/
|
||||
export function Accordion({
|
||||
title,
|
||||
meta,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
children,
|
||||
}: AccordionProps) {
|
||||
const reactId = useId()
|
||||
const panelId = `acc-panel-${reactId}`
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
function toggle() {
|
||||
const next = !open
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"overflow-hidden rounded-[var(--radius-card)] shadow-[var(--shadow-elevated)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: "var(--surface-gradient)" }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={toggle}
|
||||
className={cx(
|
||||
"relative z-10 flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left",
|
||||
"font-body no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"active:brightness-[0.98]",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="shrink-0 text-xl leading-none">{icon}</span>
|
||||
) : null}
|
||||
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
||||
{title}
|
||||
</span>
|
||||
{meta ? (
|
||||
<span className="type-caption shrink-0 font-bold text-[var(--ink-faint)]">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"h-5 w-5 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className="relative z-10 border-t border-[var(--border)] px-3 py-3"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface NestedDisclosureProps {
|
||||
title: ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact nested row for lists inside an Accordion.
|
||||
* Radius --radius-nested with parent p-3 → outer card 24 = 12 + 12.
|
||||
*/
|
||||
export function NestedDisclosure({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
children,
|
||||
}: NestedDisclosureProps) {
|
||||
const reactId = useId()
|
||||
const panelId = `nested-panel-${reactId}`
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"overflow-hidden rounded-[var(--radius-nested)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
className,
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={cx(
|
||||
"flex min-h-[48px] w-full items-center gap-2 px-3 py-2 text-left",
|
||||
"font-body no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"active:brightness-[0.98]",
|
||||
)}
|
||||
>
|
||||
<span className="type-body-emphasis min-w-0 flex-1 text-[var(--ink)]">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"h-4 w-4 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className="border-t border-[var(--border)] px-3 py-3"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
client/src/components/ui-pp/BottomNav.tsx
Normal file
98
client/src/components/ui-pp/BottomNav.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface NavItem {
|
||||
key: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface BottomNavProps {
|
||||
items: NavItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating pill bottom navigation — float elevation above content cards.
|
||||
*/
|
||||
export function BottomNav({ items, className }: BottomNavProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cx(
|
||||
"pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center",
|
||||
className,
|
||||
)}
|
||||
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) + 12px)" }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative flex items-center gap-1 rounded-[var(--radius-pill)] px-2 py-2"
|
||||
style={{ boxShadow: "var(--shadow-float)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
|
||||
|
||||
{items.map((item) => {
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
|
||||
item.active
|
||||
? "text-[var(--color-obstgem-dk)]"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cx(
|
||||
"type-label normal-case tracking-wide",
|
||||
item.active
|
||||
? "bg-clip-text text-transparent"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
style={
|
||||
item.active
|
||||
? { backgroundImage: "var(--gradient-accent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const itemClass = cx(
|
||||
"relative z-10 flex min-h-[48px] min-w-[48px] flex-col items-center justify-center gap-1",
|
||||
"elevation-transition no-select rounded-[var(--radius-pill)] px-3",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
)
|
||||
return item.href ? (
|
||||
<a key={item.key} href={item.href} className={itemClass}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className={itemClass}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
115
client/src/components/ui-pp/Button.tsx
Normal file
115
client/src/components/ui-pp/Button.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "fab"
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
loading?: boolean
|
||||
/** Required for `variant="fab"` — icon-only, no visible label. */
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
function Spinner({ dark = false }: { dark?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"inline-block h-4 w-4 animate-spin rounded-full border-2",
|
||||
dark
|
||||
? "border-[var(--ink)]/25 border-t-[var(--ink)]"
|
||||
: "border-[var(--ink)]/25 border-t-[var(--ink)]",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const pressable = cx(
|
||||
"elevation-transition active:translate-y-px active:brightness-95",
|
||||
"shadow-[var(--shadow-elevated)] active:shadow-[var(--shadow-pressed)]",
|
||||
)
|
||||
|
||||
const base = cx(
|
||||
"relative inline-flex items-center justify-center gap-2",
|
||||
"type-button no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"disabled:opacity-40 disabled:pointer-events-none disabled:active:translate-y-0 disabled:active:shadow-[var(--shadow-elevated)]",
|
||||
)
|
||||
|
||||
/**
|
||||
* Touch-optimized button set. All variants meet the 48px minimum touch target.
|
||||
* Text on gradients uses --ink for WCAG AA on light brand hues.
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ variant = "primary", loading = false, disabled, icon, className, children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
if (variant === "fab") {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(
|
||||
base,
|
||||
pressable,
|
||||
"h-14 w-14 rounded-[var(--radius-pill)] text-[var(--ink)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: "var(--gradient-accent)" }}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner dark /> : (icon ?? children)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === "secondary") {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(
|
||||
base,
|
||||
pressable,
|
||||
"type-button-secondary min-h-[48px] overflow-hidden rounded-[var(--radius-control)] px-6 text-[var(--ink)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<span className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
{loading ? <Spinner dark /> : children}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={cx(
|
||||
base,
|
||||
pressable,
|
||||
"min-h-[48px] rounded-[var(--radius-control)] px-6 text-[var(--ink)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: "var(--gradient-primary)" }}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Spinner dark /> : children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
48
client/src/components/ui-pp/GlassCard.tsx
Normal file
48
client/src/components/ui-pp/GlassCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { forwardRef, type HTMLAttributes } from "react"
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* `default` — static, sits at rest elevation.
|
||||
* `interactive` — settles down 1px + softens shadow on press.
|
||||
*/
|
||||
variant?: "default" | "interactive"
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevated Surface Card — primary container.
|
||||
* Radius via --radius-card; bevel inherits so nested overrides stay consistent.
|
||||
*/
|
||||
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
||||
({ variant = "default", className, children, ...props }, ref) => {
|
||||
const interactive = variant === "interactive"
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
className={cx(
|
||||
"elevation-transition relative no-select rounded-[var(--radius-card)]",
|
||||
"shadow-[var(--shadow-elevated)]",
|
||||
interactive &&
|
||||
"cursor-pointer active:translate-y-px active:shadow-[var(--shadow-resting)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: "var(--surface-gradient)" }}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
GlassCard.displayName = "GlassCard"
|
||||
62
client/src/components/ui-pp/SegControl.tsx
Normal file
62
client/src/components/ui-pp/SegControl.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface SegOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SegControlProps<T extends string> {
|
||||
value: T
|
||||
options: SegOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
/** Accessible name for the control group. */
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control — same interaction pattern as the prototype `.segctl`.
|
||||
* Soft track + elevated active pill. Touch targets ≥48px.
|
||||
*/
|
||||
export function SegControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: SegControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
"flex rounded-[var(--radius-pill)] p-1",
|
||||
className,
|
||||
)}
|
||||
style={{ background: "var(--green-soft)" }}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const active = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cx(
|
||||
"type-button-secondary flex-1 min-h-[48px] rounded-[var(--radius-pill)] px-3",
|
||||
"no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"elevation-transition",
|
||||
active
|
||||
? "bg-[var(--glass-fill)] text-[var(--ink)] shadow-[var(--shadow-resting)]"
|
||||
: "text-[var(--ink-soft)] active:brightness-95",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
client/src/data/extras.json
Normal file
14
client/src/data/extras.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "id": "soy_sauce", "name": { "de": "Sojasauce", "en": "Soy sauce" } },
|
||||
{ "id": "lime", "name": { "de": "Limette", "en": "Lime" } },
|
||||
{ "id": "garlic", "name": { "de": "Knoblauch", "en": "Garlic" } },
|
||||
{ "id": "cocoa_powder", "name": { "de": "Kakaopulver", "en": "Cocoa powder" } },
|
||||
{ "id": "honey", "name": { "de": "Honig", "en": "Honey" } },
|
||||
{ "id": "dates", "name": { "de": "Datteln", "en": "Dates" } },
|
||||
{ "id": "ginger", "name": { "de": "Ingwer", "en": "Ginger" } },
|
||||
{ "id": "sesame_oil", "name": { "de": "Sesamöl", "en": "Sesame oil" } },
|
||||
{ "id": "lemon", "name": { "de": "Zitrone", "en": "Lemon" } },
|
||||
{ "id": "oregano", "name": { "de": "Oregano", "en": "Oregano" } },
|
||||
{ "id": "cumin", "name": { "de": "Kreuzkümmel", "en": "Cumin" } },
|
||||
{ "id": "paprika_powder", "name": { "de": "Paprikapulver", "en": "Paprika powder" } }
|
||||
]
|
||||
979
client/src/data/foods.json
Normal file
979
client/src/data/foods.json
Normal file
@@ -0,0 +1,979 @@
|
||||
[
|
||||
{
|
||||
"id": "protein",
|
||||
"cls": "protein",
|
||||
"ic": "🥚",
|
||||
"title": {
|
||||
"de": "Protein",
|
||||
"en": "Protein"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Mindestens 15–20 g Protein pro 100 g, wenig Zucker & wenig Fett, kurze Zutatenliste.",
|
||||
"en": "At least 15–20 g protein per 100 g, low sugar & low fat, short ingredient list."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "edamame",
|
||||
"name": {
|
||||
"de": "Edamame",
|
||||
"en": "Edamame"
|
||||
},
|
||||
"v": {
|
||||
"de": "125 kcal · 14 g",
|
||||
"en": "125 kcal · 14 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "egg",
|
||||
"name": {
|
||||
"de": "Ei (Größe M)",
|
||||
"en": "Egg (size M)"
|
||||
},
|
||||
"v": {
|
||||
"de": "137 kcal · 13 g",
|
||||
"en": "137 kcal · 13 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pea_protein_strips",
|
||||
"name": {
|
||||
"de": "Erbsenproteinschnetzel",
|
||||
"en": "Pea-protein strips"
|
||||
},
|
||||
"v": {
|
||||
"de": "367 kcal · 61 g",
|
||||
"en": "367 kcal · 61 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "feta",
|
||||
"name": {
|
||||
"de": "Feta (leicht)",
|
||||
"en": "Feta (light)"
|
||||
},
|
||||
"v": {
|
||||
"de": "161 kcal · 19 g",
|
||||
"en": "161 kcal · 19 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "chicken_breast",
|
||||
"name": {
|
||||
"de": "Hühnerbrust",
|
||||
"en": "Chicken breast"
|
||||
},
|
||||
"v": {
|
||||
"de": "111 kcal · 24 g",
|
||||
"en": "111 kcal · 24 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "cottage_cheese",
|
||||
"name": {
|
||||
"de": "Hüttenkäse",
|
||||
"en": "Cottage cheese"
|
||||
},
|
||||
"v": {
|
||||
"de": "87 kcal · 12,3 g",
|
||||
"en": "87 kcal · 12.3 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "like_chicken",
|
||||
"name": {
|
||||
"de": "„Like Chicken\" (pflanzlich)",
|
||||
"en": "\"Like Chicken\" (plant-based)"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈140 kcal · 18 g",
|
||||
"en": "≈140 kcal · 18 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "low_fat_quark",
|
||||
"name": {
|
||||
"de": "Magerquark",
|
||||
"en": "Low-fat quark"
|
||||
},
|
||||
"v": {
|
||||
"de": "67 kcal · 12 g",
|
||||
"en": "67 kcal · 12 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "beef_fillet",
|
||||
"name": {
|
||||
"de": "Rinderfilet / Tatar",
|
||||
"en": "Beef fillet / tartare"
|
||||
},
|
||||
"v": {
|
||||
"de": "112 kcal · 22 g",
|
||||
"en": "112 kcal · 22 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "silken_tofu",
|
||||
"name": {
|
||||
"de": "Seidentofu",
|
||||
"en": "Silken tofu"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈55 kcal · 5 g",
|
||||
"en": "≈55 kcal · 5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "skyr",
|
||||
"name": {
|
||||
"de": "Skyr",
|
||||
"en": "Skyr"
|
||||
},
|
||||
"v": {
|
||||
"de": "54 kcal · 10,3 g",
|
||||
"en": "54 kcal · 10.3 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "tempeh",
|
||||
"name": {
|
||||
"de": "Tempeh",
|
||||
"en": "Tempeh"
|
||||
},
|
||||
"v": {
|
||||
"de": "184 kcal · 19 g",
|
||||
"en": "184 kcal · 19 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tofu",
|
||||
"name": {
|
||||
"de": "Tofu",
|
||||
"en": "Tofu"
|
||||
},
|
||||
"v": {
|
||||
"de": "129 kcal · 13 g",
|
||||
"en": "129 kcal · 13 g"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sub": {
|
||||
"title": {
|
||||
"de": "Milch & Joghurt",
|
||||
"en": "Milk & yoghurt"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "greek_yogurt_10",
|
||||
"name": {
|
||||
"de": "Griech. Joghurt 10 %",
|
||||
"en": "Greek yoghurt 10%"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈115 kcal · 5 g",
|
||||
"en": "≈115 kcal · 5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "natural_yogurt",
|
||||
"name": {
|
||||
"de": "Naturjoghurt",
|
||||
"en": "Natural yoghurt"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈61 kcal · 3,5 g",
|
||||
"en": "≈61 kcal · 3.5 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "oatly_barista",
|
||||
"name": {
|
||||
"de": "Oatly Barista",
|
||||
"en": "Oatly Barista"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈68 kcal · 1 g (kaum Protein)",
|
||||
"en": "≈68 kcal · 1 g (barely protein)"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "whole_milk",
|
||||
"name": {
|
||||
"de": "Vollmilch 3,5 %",
|
||||
"en": "Whole milk 3.5%"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈64 kcal · 3,4 g",
|
||||
"en": "≈64 kcal · 3.4 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "snack",
|
||||
"cls": "snack",
|
||||
"ic": "🥤",
|
||||
"title": {
|
||||
"de": "Proteinsnacks für Steffi",
|
||||
"en": "Protein snacks for Steffi"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Mindestens 15 g Protein pro Portion, unter 5 g Zucker, Fett unter 8 g — Zutatenliste beginnt nicht mit Zucker/Sirup/Schokolade.",
|
||||
"en": "At least 15 g protein per serving, under 5 g sugar, fat under 8 g — ingredient list shouldn’t open with sugar/syrup/chocolate."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "three_eggs",
|
||||
"name": {
|
||||
"de": "3 Eier",
|
||||
"en": "3 eggs"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈205 kcal · 19 g",
|
||||
"en": "≈205 kcal · 19 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "cottage_berries",
|
||||
"name": {
|
||||
"de": "Hüttenkäse + Beeren",
|
||||
"en": "Cottage cheese + berries"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈210 kcal · 25 g",
|
||||
"en": "≈210 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "chickpea_waffles",
|
||||
"name": {
|
||||
"de": "Kichererbsenwaffeln 90g",
|
||||
"en": "Chickpea waffles 90g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈300 kcal · 17 g",
|
||||
"en": "≈300 kcal · 17 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "pumpkin_seeds_30",
|
||||
"name": {
|
||||
"de": "Kürbiskerne 30g",
|
||||
"en": "Pumpkin seeds 30g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈171 kcal · 9 g",
|
||||
"en": "≈171 kcal · 9 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lentil_waffles",
|
||||
"name": {
|
||||
"de": "Linsenwaffeln 90g",
|
||||
"en": "Lentil waffles 90g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈300 kcal · 18 g",
|
||||
"en": "≈300 kcal · 18 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "protein_bar",
|
||||
"name": {
|
||||
"de": "Magerer Proteinriegel",
|
||||
"en": "Lean protein bar"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈180 kcal · 20 g",
|
||||
"en": "≈180 kcal · 20 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "protein_rice_pudding",
|
||||
"name": {
|
||||
"de": "Proteinmilchreis (2x)",
|
||||
"en": "Protein rice pudding (2x)"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈340 kcal · 40 g",
|
||||
"en": "≈340 kcal · 40 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "protein_pudding",
|
||||
"name": {
|
||||
"de": "Proteinpudding",
|
||||
"en": "Protein pudding"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈90 kcal · 20 g",
|
||||
"en": "≈90 kcal · 20 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "protein_shake",
|
||||
"name": {
|
||||
"de": "Proteinshake 40g",
|
||||
"en": "Protein shake 40g"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈150 kcal · 30 g",
|
||||
"en": "≈150 kcal · 30 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "turkey_breast_pack",
|
||||
"name": {
|
||||
"de": "Putenbrust, 1 Packung",
|
||||
"en": "Turkey breast, 1 pack"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈130 kcal · 28 g",
|
||||
"en": "≈130 kcal · 28 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "smoked_tofu_cubes",
|
||||
"name": {
|
||||
"de": "Räuchertofu-Würfel",
|
||||
"en": "Smoked tofu cubes"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈225 kcal · 25 g",
|
||||
"en": "≈225 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "skyr_honey",
|
||||
"name": {
|
||||
"de": "Skyr/Griech. Joghurt 10% + Honig, 250g+",
|
||||
"en": "Skyr/Greek yoghurt 10% + honey, 250g+"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈180 kcal · 25 g",
|
||||
"en": "≈180 kcal · 25 g"
|
||||
},
|
||||
"approx": true
|
||||
}
|
||||
],
|
||||
"recipe": {
|
||||
"title": {
|
||||
"de": "Rezept: Whey-Waffeln",
|
||||
"en": "Recipe: Whey waffles"
|
||||
},
|
||||
"text": {
|
||||
"de": "40 g Whey-Protein + 1 Ei + 40 g Haferflocken (fein gemahlen) + 60 ml Milch/Wasser verquirlen. ≈25 g Protein.",
|
||||
"en": "Whisk 40 g whey protein + 1 egg + 40 g oats (finely ground) + 60 ml milk/water. ≈25 g protein."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "obst",
|
||||
"cls": "obst",
|
||||
"ic": "🍎",
|
||||
"title": {
|
||||
"de": "Obst",
|
||||
"en": "Fruit"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Frisch oder tiefgekühlt ohne Zuckerzusatz, Saison bevorzugen.",
|
||||
"en": "Fresh or frozen without added sugar, prefer what’s in season."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "apple",
|
||||
"name": {
|
||||
"de": "Apfel",
|
||||
"en": "Apple"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Kalium, Kupfer",
|
||||
"en": "Vit. C, potassium, copper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "banana",
|
||||
"name": {
|
||||
"de": "Banane",
|
||||
"en": "Banana"
|
||||
},
|
||||
"v": {
|
||||
"de": "Kalium, B6, C, Magnesium",
|
||||
"en": "Potassium, B6, C, magnesium"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "blueberry",
|
||||
"name": {
|
||||
"de": "Blaubeere",
|
||||
"en": "Blueberry"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, C, Mangan",
|
||||
"en": "Vit. K, C, manganese"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "strawberry",
|
||||
"name": {
|
||||
"de": "Erdbeere",
|
||||
"en": "Strawberry"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Mangan, Folsäure, Kalium",
|
||||
"en": "Vit. C, manganese, folate, potassium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "granadilla",
|
||||
"name": {
|
||||
"de": "Granadilla (≈ Passionsfrucht)",
|
||||
"en": "Granadilla (≈ passion fruit)"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, B2",
|
||||
"en": "Vit. C, A, potassium, B2"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "pomegranate",
|
||||
"name": {
|
||||
"de": "Granatapfel",
|
||||
"en": "Pomegranate"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Folsäure, Kalium, B6",
|
||||
"en": "Vit. C, K, folate, potassium, B6"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "mango",
|
||||
"name": {
|
||||
"de": "Mango",
|
||||
"en": "Mango"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, B6, Folsäure, Kupfer",
|
||||
"en": "Vit. C, A, B6, folate, copper"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "nectarine",
|
||||
"name": {
|
||||
"de": "Nektarine",
|
||||
"en": "Nectarine"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, E, Niacin",
|
||||
"en": "Vit. C, A, potassium, E, niacin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "orange",
|
||||
"name": {
|
||||
"de": "Orange",
|
||||
"en": "Orange"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Folsäure, Kalium, Calcium",
|
||||
"en": "Vit. C, folate, potassium, calcium"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "passion_fruit",
|
||||
"name": {
|
||||
"de": "Passionsfrucht",
|
||||
"en": "Passion fruit"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, Kalium, Eisen",
|
||||
"en": "Vit. C, A, potassium, iron"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "plum",
|
||||
"name": {
|
||||
"de": "Pflaume",
|
||||
"en": "Plum"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Kalium, A, Kupfer",
|
||||
"en": "Vit. C, K, potassium, A, copper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grapes",
|
||||
"name": {
|
||||
"de": "Trauben",
|
||||
"en": "Grapes"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, C, Kalium, Kupfer, Mangan",
|
||||
"en": "Vit. K, C, potassium, copper, manganese"
|
||||
},
|
||||
"f": "caution"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gemuese",
|
||||
"cls": "gemuese",
|
||||
"ic": "🥦",
|
||||
"title": {
|
||||
"de": "Gemüse",
|
||||
"en": "Vegetables"
|
||||
},
|
||||
"thumbLabel": {
|
||||
"de": "Tipp",
|
||||
"en": "Tip"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "TK-Gemüse ist völlig okay, wenn’s schnell gehen muss — genauso nährstoffreich wie frisch, einfach ohne Salz-/Fettzusatz wählen.",
|
||||
"en": "Frozen veg is perfectly fine when time is short — just as nutrient-rich as fresh, just pick it without added salt/fat."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "broccoli",
|
||||
"name": {
|
||||
"de": "Brokkoli",
|
||||
"en": "Broccoli"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Folsäure, Eisen",
|
||||
"en": "Vit. C, K, folate, iron"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mushrooms",
|
||||
"name": {
|
||||
"de": "Champignons",
|
||||
"en": "Mushrooms"
|
||||
},
|
||||
"v": {
|
||||
"de": "Kalium, Selen, Kupfer, B2",
|
||||
"en": "Potassium, selenium, copper, B2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "iceberg_lettuce",
|
||||
"name": {
|
||||
"de": "Eisbergsalat",
|
||||
"en": "Iceberg lettuce"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, A, Folsäure, Kalium — nährstoffarm",
|
||||
"en": "Vit. K, A, folate, potassium — low nutrient density"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lambs_lettuce",
|
||||
"name": {
|
||||
"de": "Feldsalat",
|
||||
"en": "Lamb’s lettuce"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. A, C, Folsäure, Eisen",
|
||||
"en": "Vit. A, C, folate, iron"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "cucumber",
|
||||
"name": {
|
||||
"de": "Gurke",
|
||||
"en": "Cucumber"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, Kalium, Mangan",
|
||||
"en": "Vit. K, potassium, manganese"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "carrot",
|
||||
"name": {
|
||||
"de": "Karotte",
|
||||
"en": "Carrot"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. A, K, Kalium, B6",
|
||||
"en": "Vit. A, K, potassium, B6"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "bell_pepper",
|
||||
"name": {
|
||||
"de": "Paprika",
|
||||
"en": "Bell pepper"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, A, B6, E",
|
||||
"en": "Vit. C, A, B6, E"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "spinach",
|
||||
"name": {
|
||||
"de": "Spinat",
|
||||
"en": "Spinach"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. K, A, Folsäure, Eisen",
|
||||
"en": "Vit. K, A, folate, iron"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "tomato",
|
||||
"name": {
|
||||
"de": "Tomate",
|
||||
"en": "Tomato"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, K, Kalium, Folsäure",
|
||||
"en": "Vit. C, K, potassium, folate"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "zucchini",
|
||||
"name": {
|
||||
"de": "Zucchini",
|
||||
"en": "Zucchini"
|
||||
},
|
||||
"v": {
|
||||
"de": "Vit. C, Kalium, B6, Mangan",
|
||||
"en": "Vit. C, potassium, B6, manganese"
|
||||
}
|
||||
}
|
||||
],
|
||||
"note": {
|
||||
"de": "Hinweis: Süßkartoffel, Kochbanane und Erbsen zählen hier als Kohlenhydratquelle, Avocado als Fettquelle — siehe dort.",
|
||||
"en": "Note: sweet potato, plantain and peas count here as a carb source, avocado as a fat source — see those cards."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fett",
|
||||
"cls": "fett",
|
||||
"ic": "🥑",
|
||||
"title": {
|
||||
"de": "Fett",
|
||||
"en": "Fat"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Native/kaltgepresste Öle, ungesalzene & ungeröstete Nüsse/Kerne, Zutatenliste = nur die Zutat selbst.",
|
||||
"en": "Native/cold-pressed oils, unsalted & unroasted nuts/seeds, ingredient list = just the ingredient itself."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "avocado",
|
||||
"name": {
|
||||
"de": "Avocado",
|
||||
"en": "Avocado"
|
||||
},
|
||||
"v": {
|
||||
"de": "217 kcal · 12,5 g",
|
||||
"en": "217 kcal · 12.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "chia_seeds",
|
||||
"name": {
|
||||
"de": "Chiasamen",
|
||||
"en": "Chia seeds"
|
||||
},
|
||||
"v": {
|
||||
"de": "486 kcal · 31 g",
|
||||
"en": "486 kcal · 31 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "pumpkin_seeds",
|
||||
"name": {
|
||||
"de": "Kürbiskerne",
|
||||
"en": "Pumpkin seeds"
|
||||
},
|
||||
"v": {
|
||||
"de": "570 kcal · 44 g",
|
||||
"en": "570 kcal · 44 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "linseed_oil",
|
||||
"name": {
|
||||
"de": "Leinöl",
|
||||
"en": "Linseed oil"
|
||||
},
|
||||
"v": {
|
||||
"de": "837 kcal · 93 g",
|
||||
"en": "837 kcal · 93 g"
|
||||
},
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "almonds",
|
||||
"name": {
|
||||
"de": "Mandeln",
|
||||
"en": "Almonds"
|
||||
},
|
||||
"v": {
|
||||
"de": "612 kcal · 55 g",
|
||||
"en": "612 kcal · 55 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "olive_oil",
|
||||
"name": {
|
||||
"de": "Olivenöl",
|
||||
"en": "Olive oil"
|
||||
},
|
||||
"v": {
|
||||
"de": "857 kcal · 91,5 g",
|
||||
"en": "857 kcal · 91.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "walnuts",
|
||||
"name": {
|
||||
"de": "Walnüsse",
|
||||
"en": "Walnuts"
|
||||
},
|
||||
"v": {
|
||||
"de": "674 kcal · 62,5 g",
|
||||
"en": "674 kcal · 62.5 g"
|
||||
},
|
||||
"f": "pick"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kh",
|
||||
"cls": "kh",
|
||||
"ic": "🌾",
|
||||
"title": {
|
||||
"de": "Kohlenhydrate",
|
||||
"en": "Carbohydrates"
|
||||
},
|
||||
"thumb": {
|
||||
"de": "Vollkorn als erste Zutat, wenig zugesetzter Zucker. Faustregel 10:1 — pro 10 g Kohlenhydrate mind. 1 g Ballaststoffe (z. B. 30 g KH → mind. 3 g Ballaststoffe).",
|
||||
"en": "Whole grain as the first ingredient, low added sugar. 10:1 rule of thumb — at least 1 g fibre per 10 g carbs (e.g. 30 g carbs → at least 3 g fibre)."
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "brown_lentils",
|
||||
"name": {
|
||||
"de": "Berglinsen",
|
||||
"en": "Brown lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "319 kcal · ≈46 g",
|
||||
"en": "319 kcal · ≈46 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "buckwheat",
|
||||
"name": {
|
||||
"de": "Buchweizen",
|
||||
"en": "Buckwheat"
|
||||
},
|
||||
"v": {
|
||||
"de": "343 kcal · 71,5 g",
|
||||
"en": "343 kcal · 71.5 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "puffed_spelt",
|
||||
"name": {
|
||||
"de": "Dinkel gepufft",
|
||||
"en": "Puffed spelt"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈338 kcal · 70 g",
|
||||
"en": "≈338 kcal · 70 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "spelt_pasta",
|
||||
"name": {
|
||||
"de": "Dinkel-Vollkorn-Nudeln",
|
||||
"en": "Whole-grain spelt pasta"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈338 kcal · 70 g",
|
||||
"en": "≈338 kcal · 70 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "peas",
|
||||
"name": {
|
||||
"de": "Erbsen",
|
||||
"en": "Peas"
|
||||
},
|
||||
"v": {
|
||||
"de": "93 kcal · 10,4 g",
|
||||
"en": "93 kcal · 10.4 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "oats",
|
||||
"name": {
|
||||
"de": "Haferflocken",
|
||||
"en": "Oats"
|
||||
},
|
||||
"v": {
|
||||
"de": "372 kcal · 66,3 g",
|
||||
"en": "372 kcal · 66.3 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "millet",
|
||||
"name": {
|
||||
"de": "Hirse",
|
||||
"en": "Millet"
|
||||
},
|
||||
"v": {
|
||||
"de": "363 kcal · 69 g",
|
||||
"en": "363 kcal · 69 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "chickpeas",
|
||||
"name": {
|
||||
"de": "Kichererbsen",
|
||||
"en": "Chickpeas"
|
||||
},
|
||||
"v": {
|
||||
"de": "108 kcal · 14,2 g",
|
||||
"en": "108 kcal · 14.2 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "chickpea_pasta",
|
||||
"name": {
|
||||
"de": "Kichererbsennudeln / -couscous",
|
||||
"en": "Chickpea pasta / couscous"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈335 kcal · 50 g",
|
||||
"en": "≈335 kcal · 50 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "plantain",
|
||||
"name": {
|
||||
"de": "Kochbanane",
|
||||
"en": "Plantain"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈122 kcal · 32 g",
|
||||
"en": "≈122 kcal · 32 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "lentil_pasta",
|
||||
"name": {
|
||||
"de": "Linsennudeln",
|
||||
"en": "Lentil pasta"
|
||||
},
|
||||
"v": {
|
||||
"de": "≈335 kcal · 52 g",
|
||||
"en": "≈335 kcal · 52 g"
|
||||
},
|
||||
"approx": true
|
||||
},
|
||||
{
|
||||
"id": "quinoa",
|
||||
"name": {
|
||||
"de": "Quinoa",
|
||||
"en": "Quinoa"
|
||||
},
|
||||
"v": {
|
||||
"de": "368 kcal · 64,2 g",
|
||||
"en": "368 kcal · 64.2 g"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "red_lentils",
|
||||
"name": {
|
||||
"de": "Rote Linsen",
|
||||
"en": "Red lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "326 kcal · ≈52 g",
|
||||
"en": "326 kcal · ≈52 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
},
|
||||
{
|
||||
"id": "sweet_potato",
|
||||
"name": {
|
||||
"de": "Süßkartoffel",
|
||||
"en": "Sweet potato"
|
||||
},
|
||||
"v": {
|
||||
"de": "86 kcal · 20,1 g",
|
||||
"en": "86 kcal · 20.1 g"
|
||||
},
|
||||
"f": "caution"
|
||||
},
|
||||
{
|
||||
"id": "french_lentils",
|
||||
"name": {
|
||||
"de": "Tellerlinsen",
|
||||
"en": "French lentils"
|
||||
},
|
||||
"v": {
|
||||
"de": "234 kcal · ≈34 g",
|
||||
"en": "234 kcal · ≈34 g"
|
||||
},
|
||||
"approx": true,
|
||||
"f": "pick"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
160
client/src/data/recipes.json
Normal file
160
client/src/data/recipes.json
Normal file
@@ -0,0 +1,160 @@
|
||||
[
|
||||
{
|
||||
"id": "bf_skyr_oats_blueberry",
|
||||
"type": "breakfast",
|
||||
"name": {
|
||||
"de": "Skyr + Haferflocken + Blaubeeren",
|
||||
"en": "Skyr + oats + blueberries"
|
||||
},
|
||||
"uses": ["skyr", "oats", "blueberry"]
|
||||
},
|
||||
{
|
||||
"id": "bf_eggs_tomato",
|
||||
"type": "breakfast",
|
||||
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
|
||||
"uses": ["egg", "tomato"]
|
||||
},
|
||||
{
|
||||
"id": "bf_quark_pomegranate",
|
||||
"type": "breakfast",
|
||||
"name": {
|
||||
"de": "Magerquark + Granatapfel",
|
||||
"en": "Low-fat quark + pomegranate"
|
||||
},
|
||||
"uses": ["low_fat_quark", "pomegranate"]
|
||||
},
|
||||
{
|
||||
"id": "sn_skyr_blueberry",
|
||||
"type": "snack",
|
||||
"name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
|
||||
"uses": ["skyr", "blueberry"]
|
||||
},
|
||||
{
|
||||
"id": "sn_cottage_cucumber",
|
||||
"type": "snack",
|
||||
"name": {
|
||||
"de": "Hüttenkäse + Gurke",
|
||||
"en": "Cottage cheese + cucumber"
|
||||
},
|
||||
"uses": ["cottage_cheese", "cucumber"]
|
||||
},
|
||||
{
|
||||
"id": "sn_quark_pomegranate",
|
||||
"type": "snack",
|
||||
"name": {
|
||||
"de": "Magerquark + Granatapfel",
|
||||
"en": "Low-fat quark + pomegranate"
|
||||
},
|
||||
"uses": ["low_fat_quark", "pomegranate"]
|
||||
},
|
||||
{
|
||||
"id": "sn_protein_pudding",
|
||||
"type": "snack",
|
||||
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
|
||||
"uses": ["protein_pudding"]
|
||||
},
|
||||
{
|
||||
"id": "full_chicken_cooked",
|
||||
"type": "full",
|
||||
"cooked": true,
|
||||
"name": {
|
||||
"de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen",
|
||||
"en": "Warm chicken breast + veg + linseed oil + red lentils"
|
||||
},
|
||||
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
|
||||
},
|
||||
{
|
||||
"id": "full_cold_platter",
|
||||
"type": "full",
|
||||
"cooked": false,
|
||||
"name": {
|
||||
"de": "Kalte Brotzeit: Feta + Feldsalat + Walnüsse + Nudelsalat",
|
||||
"en": "Cold platter: feta + lamb's lettuce + walnuts + pasta salad"
|
||||
},
|
||||
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
|
||||
},
|
||||
{
|
||||
"id": "full_bowl_nocook",
|
||||
"type": "full",
|
||||
"cooked": false,
|
||||
"name": {
|
||||
"de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)",
|
||||
"en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils"
|
||||
},
|
||||
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
|
||||
},
|
||||
{
|
||||
"id": "full_leftovers_cold",
|
||||
"type": "full",
|
||||
"cooked": false,
|
||||
"name": {
|
||||
"de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)",
|
||||
"en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)"
|
||||
},
|
||||
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
|
||||
},
|
||||
{
|
||||
"id": "rec_protein_shake",
|
||||
"type": "recipe",
|
||||
"name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" },
|
||||
"uses": ["protein_shake"],
|
||||
"text": {
|
||||
"de": "40 g Proteinpulver + 250–300 ml Wasser oder Milch shaken. ≈30 g Protein.",
|
||||
"en": "Shake 40 g protein powder with 250–300 ml water or milk. ≈30 g protein."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rec_tempeh_pan",
|
||||
"type": "recipe",
|
||||
"name": { "de": "Tempeh-Pfanne", "en": "Pan-fried tempeh" },
|
||||
"uses": ["tempeh", "soy_sauce", "lime", "garlic"],
|
||||
"text": {
|
||||
"de": "Tempeh würfeln, 10 Min. in Sojasauce + Limette + Knoblauch marinieren, knusprig braten (5–7 Min.).",
|
||||
"en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (5–7 min)."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rec_silken_tofu_pudding",
|
||||
"type": "recipe",
|
||||
"name": {
|
||||
"de": "Schoko-Seidentofu-Pudding",
|
||||
"en": "Chocolate silken-tofu pudding"
|
||||
},
|
||||
"uses": ["silken_tofu", "cocoa_powder", "honey"],
|
||||
"text": {
|
||||
"de": "150 g Seidentofu + 1 EL Kakaopulver + 1 TL Honig pürieren, 30 Min. kalt stellen.",
|
||||
"en": "Blend 150 g silken tofu + 1 tbsp cocoa powder + 1 tsp honey, chill 30 min."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rec_veggie_stirfry_base",
|
||||
"type": "recipe",
|
||||
"name": {
|
||||
"de": "Grundrezept: Gemüsepfanne",
|
||||
"en": "Base recipe: vegetable stir-fry"
|
||||
},
|
||||
"uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"],
|
||||
"text": {
|
||||
"de": "Gemüse in mundgerechte Stücke schneiden. In heißem Öl 5–8 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.",
|
||||
"en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 5–8 min. Add protein, fry briefly, season, done."
|
||||
},
|
||||
"variations": [
|
||||
{
|
||||
"de": "Asiatisch: Ingwer + Sojasauce + Sesamöl",
|
||||
"en": "Asian: ginger + soy sauce + sesame oil"
|
||||
},
|
||||
{
|
||||
"de": "Mediterran: Olivenöl + Zitrone + Oregano",
|
||||
"en": "Mediterranean: olive oil + lemon + oregano"
|
||||
},
|
||||
{
|
||||
"de": "Mexikanisch: Kreuzkümmel + Paprikapulver + Limette",
|
||||
"en": "Mexican: cumin + paprika powder + lime"
|
||||
},
|
||||
{
|
||||
"de": "Protein nach Wahl: Hühnerbrust, Tempeh, Räuchertofu oder Kichererbsen",
|
||||
"en": "Protein of choice: chicken breast, tempeh, smoked tofu, or chickpeas"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
66
client/src/data/steps.json
Normal file
66
client/src/data/steps.json
Normal file
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"n": 1,
|
||||
"cls": "g",
|
||||
"title": {
|
||||
"de": "Protein",
|
||||
"en": "Protein"
|
||||
},
|
||||
"qty": {
|
||||
"de": "30 g pro Mahlzeit",
|
||||
"en": "30 g per meal"
|
||||
},
|
||||
"text": {
|
||||
"de": "Bspw. 125 g Rinderfilet, 300 g Skyr, 40 g Proteinpulver, 50 g Erbsenproteingeschnetzeltes oder 5 Eier. 150 g am Tag insgesamt.",
|
||||
"en": "E.g. 125 g beef fillet, 300 g Skyr, 40 g protein powder, 50 g pea-protein strips, or 5 eggs. 150 g total per day."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"cls": "t",
|
||||
"title": {
|
||||
"de": "Obst ODER Gemüse",
|
||||
"en": "Fruit OR Vegetables"
|
||||
},
|
||||
"qty": {
|
||||
"de": "eine Portion",
|
||||
"en": "one portion"
|
||||
},
|
||||
"text": {
|
||||
"de": "Obst = 100 g oder eine geballte Faust. Gemüse = 200 g oder 2 Handvoll.",
|
||||
"en": "Fruit = 100 g or one clenched fist. Vegetables = 200 g or 2 handfuls."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 3,
|
||||
"cls": "o",
|
||||
"title": {
|
||||
"de": "Fett",
|
||||
"en": "Fat"
|
||||
},
|
||||
"qty": {
|
||||
"de": "",
|
||||
"en": ""
|
||||
},
|
||||
"text": {
|
||||
"de": "15 g Samen/Kerne/Nüsse (≈20 Stück, 1 EL) · ¼ Avocado · 1 EL Öl (15 g) — Nussöl außer Erdnuss, Olivenöl, Leinsamenöl.",
|
||||
"en": "15 g seeds/nuts (≈20 pieces, 1 tbsp) · ¼ avocado · 1 tbsp oil (15 g) — nut oil except peanut, olive oil, linseed oil."
|
||||
}
|
||||
},
|
||||
{
|
||||
"n": 4,
|
||||
"cls": "s",
|
||||
"title": {
|
||||
"de": "Kohlenhydrate",
|
||||
"en": "Carbohydrates"
|
||||
},
|
||||
"qty": {
|
||||
"de": "",
|
||||
"en": ""
|
||||
},
|
||||
"text": {
|
||||
"de": "75 g Reis/Nudeln/Brot (trocken) · ca. 250 g rohe Kartoffeln (≈3 mittelgroße) · 50 g Haferflocken (≈5 gehäufte EL).",
|
||||
"en": "75 g rice/pasta/bread (dry) · approx. 250 g raw potatoes (≈3 medium) · 50 g oats (≈5 heaped tbsp)."
|
||||
}
|
||||
}
|
||||
]
|
||||
148
client/src/i18n/ui.ts
Normal file
148
client/src/i18n/ui.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Language, LocalizedString } from "@/types/domain"
|
||||
|
||||
/**
|
||||
* Resolve a UI chrome key object (or any LocalizedString) for the active language.
|
||||
* Mirrors the prototype `t(key)` helper when called as `t(ui.nav.home, lang)`.
|
||||
*/
|
||||
export function t(value: LocalizedString, lang: Language): string {
|
||||
return value[lang]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a data LocalizedString (`name`, `text`, …) — prototype `tr(obj)`.
|
||||
*/
|
||||
export function tr(value: LocalizedString, lang: Language): string {
|
||||
return value[lang]
|
||||
}
|
||||
|
||||
export const ui = {
|
||||
appName: { de: "Pocket Pascal", en: "Pocket Pascal" },
|
||||
nav: {
|
||||
home: { de: "Start", en: "Home" },
|
||||
pantry: { de: "Vorrat", en: "Pantry" },
|
||||
knowledge: { de: "Teller", en: "Plate" },
|
||||
settings: { de: "Einstellungen", en: "Settings" },
|
||||
},
|
||||
home: {
|
||||
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
|
||||
editLayout: { de: "Layout", en: "Layout" },
|
||||
doneEdit: { de: "Fertig", en: "Done" },
|
||||
moveUp: { de: "Nach oben", en: "Move up" },
|
||||
moveDown: { de: "Nach unten", en: "Move down" },
|
||||
},
|
||||
suggestion: {
|
||||
morning: { de: "Morgen · Protein + Obst", en: "Morning · Protein + fruit" },
|
||||
midday: { de: "Mittag · Ganzer Teller", en: "Midday · Full plate" },
|
||||
afternoon: { de: "Nachmittag · Snack-Zeit", en: "Afternoon · Snack time" },
|
||||
evening: { de: "Abend · Ganzer Teller", en: "Evening · Full plate" },
|
||||
timeSuffix: { de: "Uhr", en: "" },
|
||||
pinned: { de: "Fixiert", en: "Pinned" },
|
||||
pin: { de: "Fixieren", en: "Pin" },
|
||||
unpin: { de: "Lösen", en: "Unpin" },
|
||||
reroll: { de: "Was anderes", en: "Something else" },
|
||||
toBuilder: { de: "In den Builder übernehmen", en: "Send to builder" },
|
||||
fallbackName: { de: "Dein Teller jetzt", en: "Your plate now" },
|
||||
},
|
||||
knowledgeHub: {
|
||||
plateTitle: { de: "Mein Teller", en: "My Plate" },
|
||||
plateBody: {
|
||||
de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate",
|
||||
en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs",
|
||||
},
|
||||
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
|
||||
pantryBody: {
|
||||
de: "Kategorien, Suche, Pick- & Caution-Markierungen",
|
||||
en: "Categories, search, pick & caution markers",
|
||||
},
|
||||
weekplanTitle: { de: "Wochenplan", en: "Weekly plan" },
|
||||
weekplanBody: {
|
||||
de: "Frühstück, Snacks, volle Teller & Rezepte",
|
||||
en: "Breakfast, snacks, full plates & recipes",
|
||||
},
|
||||
},
|
||||
weekplan: {
|
||||
title: { de: "Wochenplan", en: "Weekly plan" },
|
||||
intro: {
|
||||
de: "Kuratierte Kombinationen aus dem Baukasten — gruppiert nach Mahlzeitentyp.",
|
||||
en: "Curated building-block combos — grouped by meal type.",
|
||||
},
|
||||
breakfast: { de: "Frühstück", en: "Breakfast" },
|
||||
snack: { de: "Snack", en: "Snack" },
|
||||
full: { de: "Ganzer Teller", en: "Full plate" },
|
||||
recipe: { de: "Rezepte", en: "Recipes" },
|
||||
cooked: { de: "Warm / gekocht", en: "Warm / cooked" },
|
||||
cold: { de: "Kalt / ohne Kochen", en: "Cold / no cook" },
|
||||
uses: { de: "Zutaten", en: "Ingredients" },
|
||||
variations: { de: "Variationen", en: "Variations" },
|
||||
},
|
||||
pantry: {
|
||||
title: { de: "Vorratskammer", en: "Pantry" },
|
||||
search: { de: "Lebensmittel suchen…", en: "Search foods…" },
|
||||
pick: { de: "empfohlen", en: "recommended" },
|
||||
caution: { de: "bewusst genießen", en: "enjoy mindfully" },
|
||||
noResults: { de: "Keine Treffer.", en: "No results." },
|
||||
tip: { de: "Tipp", en: "Tip" },
|
||||
},
|
||||
plate: {
|
||||
title: { de: "Mein Teller", en: "My Plate" },
|
||||
subtitle: {
|
||||
de: "Baukasten-Reihenfolge: zuerst Protein, dann Obst oder Gemüse, danach Fett und Kohlenhydrate.",
|
||||
en: "Building-block order: protein first, then fruit or vegetables, then fat and carbohydrates.",
|
||||
},
|
||||
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
|
||||
segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
|
||||
segHalf: { de: "Halbe Mahlzeit", en: "Half meal" },
|
||||
plateAria: {
|
||||
de: "Tellerverteilung nach Baukasten",
|
||||
en: "Plate breakdown by building blocks",
|
||||
},
|
||||
rulesTitle: { de: "Grundregeln", en: "Ground rules" },
|
||||
rule1: {
|
||||
de: "Jede Mahlzeit beruht auf Protein — egal ob süß, herzhaft, Snack oder Hauptmahlzeit.",
|
||||
en: "Every meal is built on protein — sweet, savoury, snack, or main.",
|
||||
},
|
||||
rule2: {
|
||||
de: "Jede Mahlzeit enthält eine Portion Gemüse oder Obst.",
|
||||
en: "Every meal includes a portion of vegetables or fruit.",
|
||||
},
|
||||
rule3: {
|
||||
de: "4 Mahlzeiten am Tag, ca. alle 3–4 Std.",
|
||||
en: "4 meals a day, roughly every 3–4 hours.",
|
||||
},
|
||||
rule4: {
|
||||
de: "2 L Wasser täglich.",
|
||||
en: "2 L of water daily.",
|
||||
},
|
||||
},
|
||||
builderSlot: {
|
||||
title: { de: "Teller-Builder", en: "Plate builder" },
|
||||
body: {
|
||||
de: "Chips antippen, Kombination sofort gegen die Baukasten-Regeln prüfen.",
|
||||
en: "Tap chips and check the combo against the building-block rules instantly.",
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
|
||||
pantryBody: {
|
||||
de: "Phase-1-Platzhalter — Suche & Kategorien folgen.",
|
||||
en: "Phase 1 stub — search & categories coming next.",
|
||||
},
|
||||
knowledgeTitle: { de: "Knowledge Base", en: "Knowledge Base" },
|
||||
knowledgeBody: {
|
||||
de: "Phase-1-Platzhalter — Teller-Regel & Daumenregeln folgen.",
|
||||
en: "Phase 1 stub — plate rule & thumb rules coming next.",
|
||||
},
|
||||
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
|
||||
builderBody: {
|
||||
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen.",
|
||||
en: "Phase 1 stub — chip selection & scoring coming next.",
|
||||
},
|
||||
builderHandoff: {
|
||||
de: "Übernommen aus dem Vorschlag:",
|
||||
en: "Handed off from suggestion:",
|
||||
},
|
||||
settingsTitle: { de: "Einstellungen", en: "Settings" },
|
||||
language: { de: "Sprache", en: "Language" },
|
||||
comingSoon: { de: "Demnächst", en: "Coming soon" },
|
||||
},
|
||||
} as const
|
||||
@@ -1,13 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/glass.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-heading: "Unbounded", ui-sans-serif, sans-serif;
|
||||
--font-sans: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -121,10 +122,15 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
color: var(--ink);
|
||||
background: var(--page-gradient);
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
139
client/src/lib/catalog.ts
Normal file
139
client/src/lib/catalog.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
CategoryId,
|
||||
CatalogItem,
|
||||
ExtraItem,
|
||||
FoodCategory,
|
||||
FoodItem,
|
||||
LocalizedString,
|
||||
PlanRecipeType,
|
||||
Recipe,
|
||||
TagType,
|
||||
TimeSlot,
|
||||
} from "@/types/domain"
|
||||
import extrasRaw from "@/data/extras.json"
|
||||
import foodsRaw from "@/data/foods.json"
|
||||
import recipesRaw from "@/data/recipes.json"
|
||||
import stepsRaw from "@/data/steps.json"
|
||||
|
||||
interface RawFoodItem {
|
||||
id: string
|
||||
name: LocalizedString
|
||||
v: LocalizedString
|
||||
f?: TagType
|
||||
approx?: boolean
|
||||
}
|
||||
|
||||
interface RawFoodCategory {
|
||||
id: CategoryId
|
||||
cls: string
|
||||
title: LocalizedString
|
||||
thumb: LocalizedString
|
||||
items: RawFoodItem[]
|
||||
}
|
||||
|
||||
export interface PlateStep {
|
||||
n: number
|
||||
cls: string
|
||||
title: LocalizedString
|
||||
qty: LocalizedString
|
||||
text: LocalizedString
|
||||
}
|
||||
|
||||
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
|
||||
return {
|
||||
id: item.id,
|
||||
cat,
|
||||
name: item.name,
|
||||
valueInfo: item.v,
|
||||
tag: item.f,
|
||||
isApprox: item.approx,
|
||||
}
|
||||
}
|
||||
|
||||
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
|
||||
(category) => ({
|
||||
id: category.id,
|
||||
cls: category.cls,
|
||||
title: category.title,
|
||||
thumb: category.thumb,
|
||||
items: category.items.map((item) => mapItem(category.id, item)),
|
||||
}),
|
||||
)
|
||||
|
||||
export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items)
|
||||
|
||||
export const foodById: Record<string, FoodItem> = Object.fromEntries(
|
||||
foods.map((item) => [item.id, item]),
|
||||
)
|
||||
|
||||
export const extras: ExtraItem[] = extrasRaw as ExtraItem[]
|
||||
|
||||
export const extraById: Record<string, ExtraItem> = Object.fromEntries(
|
||||
extras.map((item) => [item.id, item]),
|
||||
)
|
||||
|
||||
export const recipes: Recipe[] = recipesRaw as Recipe[]
|
||||
|
||||
export const plateSteps: PlateStep[] = stepsRaw as PlateStep[]
|
||||
|
||||
export const PLAN_TYPE_ORDER: PlanRecipeType[] = [
|
||||
"breakfast",
|
||||
"snack",
|
||||
"full",
|
||||
"recipe",
|
||||
]
|
||||
|
||||
export function foodsInCategory(cat: CategoryId): FoodItem[] {
|
||||
return foodCategories.find((category) => category.id === cat)?.items ?? []
|
||||
}
|
||||
|
||||
export function resolveCatalogId(id: string): CatalogItem | null {
|
||||
const food = foodById[id]
|
||||
if (food) {
|
||||
return {
|
||||
id: food.id,
|
||||
name: food.name,
|
||||
source: "cat",
|
||||
cat: food.cat,
|
||||
valueInfo: food.valueInfo,
|
||||
tag: food.tag,
|
||||
}
|
||||
}
|
||||
const extra = extraById[id]
|
||||
if (extra) {
|
||||
return {
|
||||
id: extra.id,
|
||||
name: extra.name,
|
||||
source: "extra",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] {
|
||||
return recipe.uses
|
||||
.map((id) => resolveCatalogId(id))
|
||||
.filter((item): item is CatalogItem => Boolean(item))
|
||||
}
|
||||
|
||||
export function recipesByType(type: PlanRecipeType): Recipe[] {
|
||||
return recipes.filter((recipe) => recipe.type === type)
|
||||
}
|
||||
|
||||
/** Map plan recipe types onto suggestion time slots. */
|
||||
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] {
|
||||
switch (recipe.type) {
|
||||
case "breakfast":
|
||||
return ["morning"]
|
||||
case "snack":
|
||||
return ["afternoon"]
|
||||
case "full":
|
||||
return ["midday", "evening"]
|
||||
case "recipe":
|
||||
return ["morning", "afternoon", "midday", "evening"]
|
||||
}
|
||||
}
|
||||
|
||||
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" {
|
||||
return recipe.type === "full" ? "full" : "half"
|
||||
}
|
||||
4
client/src/lib/cx.ts
Normal file
4
client/src/lib/cx.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Tiny className joiner — avoids pulling in clsx/tailwind-merge as a dependency. */
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
65
client/src/lib/language.tsx
Normal file
65
client/src/lib/language.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
import type { Language } from "@/types/domain"
|
||||
|
||||
const STORAGE_KEY = "pp.lang"
|
||||
|
||||
interface LanguageContextValue {
|
||||
lang: Language
|
||||
setLang: (lang: Language) => void
|
||||
toggleLang: () => void
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | null>(null)
|
||||
|
||||
function readStoredLanguage(): Language {
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
if (value === "de" || value === "en") return value
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "de"
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Language>(() =>
|
||||
typeof window === "undefined" ? "de" : readStoredLanguage(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
document.documentElement.lang = lang
|
||||
}, [lang])
|
||||
|
||||
const value = useMemo<LanguageContextValue>(
|
||||
() => ({
|
||||
lang,
|
||||
setLang: setLangState,
|
||||
toggleLang: () => setLangState((current) => (current === "de" ? "en" : "de")),
|
||||
}),
|
||||
[lang],
|
||||
)
|
||||
|
||||
return createElement(LanguageContext.Provider, { value }, children)
|
||||
}
|
||||
|
||||
export function useLanguage(): LanguageContextValue {
|
||||
const ctx = useContext(LanguageContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useLanguage must be used within LanguageProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
103
client/src/lib/suggestion.ts
Normal file
103
client/src/lib/suggestion.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { foodById, foods, recipes, timeSlotsForRecipe } from "@/lib/catalog"
|
||||
import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
||||
|
||||
const SLOT_CATEGORIES: Record<TimeSlot, CategoryId[]> = {
|
||||
morning: ["protein", "obst", "fett"],
|
||||
midday: ["protein", "gemuese", "fett", "kh"],
|
||||
afternoon: ["protein", "gemuese"],
|
||||
evening: ["protein", "gemuese", "fett", "kh"],
|
||||
}
|
||||
|
||||
function pickRandom<T>(items: T[], excludeIds: Set<string>, getId: (item: T) => string): T | null {
|
||||
const pool = items.filter((item) => !excludeIds.has(getId(item)))
|
||||
if (pool.length === 0) return null
|
||||
return pool[Math.floor(Math.random() * pool.length)] ?? null
|
||||
}
|
||||
|
||||
export function recipesForSlot(slot: TimeSlot): Recipe[] {
|
||||
return recipes.filter(
|
||||
(recipe) =>
|
||||
recipe.type !== "recipe" && timeSlotsForRecipe(recipe).includes(slot),
|
||||
)
|
||||
}
|
||||
|
||||
/** Plate foods only (skip EXTRAS) for suggestion chips / builder handoff. */
|
||||
export function resolveRecipeFoods(recipe: Recipe): FoodItem[] {
|
||||
return recipe.uses
|
||||
.map((id) => foodById[id])
|
||||
.filter((item): item is FoodItem => Boolean(item))
|
||||
}
|
||||
|
||||
/** Prefer curated recipes; fall back to one random food per expected category. */
|
||||
export function buildSuggestion(slot: TimeSlot, avoidRecipeId?: string): {
|
||||
recipe: Recipe | null
|
||||
foods: FoodItem[]
|
||||
} {
|
||||
const candidates = recipesForSlot(slot).filter((recipe) => recipe.id !== avoidRecipeId)
|
||||
const recipe =
|
||||
candidates.length > 0
|
||||
? (pickRandom(candidates, new Set(), (item) => item.id) as Recipe)
|
||||
: null
|
||||
|
||||
if (recipe) {
|
||||
return { recipe, foods: resolveRecipeFoods(recipe) }
|
||||
}
|
||||
|
||||
const generated = SLOT_CATEGORIES[slot]
|
||||
.map((cat) =>
|
||||
pickRandom(
|
||||
foods.filter((item) => item.cat === cat),
|
||||
new Set(),
|
||||
(item) => item.id,
|
||||
),
|
||||
)
|
||||
.filter((item): item is FoodItem => Boolean(item))
|
||||
|
||||
return { recipe: null, foods: generated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace unpinned ingredients with alternatives from the same category.
|
||||
* Pinned IDs stay in place; missing categories are filled when possible.
|
||||
*/
|
||||
export function reshuffleSuggestion(
|
||||
current: FoodItem[],
|
||||
pinnedIds: Set<string>,
|
||||
slot: TimeSlot,
|
||||
): FoodItem[] {
|
||||
const pinned = current.filter((item) => pinnedIds.has(item.id))
|
||||
const pinnedCats = new Set(pinned.map((item) => item.cat))
|
||||
const exclude = new Set(current.map((item) => item.id))
|
||||
|
||||
const next: FoodItem[] = [...pinned]
|
||||
|
||||
for (const item of current) {
|
||||
if (pinnedIds.has(item.id)) continue
|
||||
const replacement = pickRandom(
|
||||
foods.filter((candidate) => candidate.cat === item.cat),
|
||||
exclude,
|
||||
(candidate) => candidate.id,
|
||||
)
|
||||
if (replacement) {
|
||||
exclude.add(replacement.id)
|
||||
next.push(replacement)
|
||||
} else {
|
||||
next.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
for (const cat of SLOT_CATEGORIES[slot]) {
|
||||
if (pinnedCats.has(cat) || next.some((item) => item.cat === cat)) continue
|
||||
const fill = pickRandom(
|
||||
foods.filter((candidate) => candidate.cat === cat),
|
||||
exclude,
|
||||
(candidate) => candidate.id,
|
||||
)
|
||||
if (fill) {
|
||||
exclude.add(fill.id)
|
||||
next.push(fill)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
36
client/src/lib/timeContext.ts
Normal file
36
client/src/lib/timeContext.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { MealType, TimeSlot } from "@/types/domain"
|
||||
|
||||
export interface TimeContext {
|
||||
slot: TimeSlot
|
||||
mealType: MealType
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
|
||||
/** Local-time meal context for the suggestion engine. */
|
||||
export function getTimeContext(date = new Date()): TimeContext {
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
|
||||
if (hour >= 6 && hour < 11) {
|
||||
return { slot: "morning", mealType: "half", hour, minute }
|
||||
}
|
||||
if (hour >= 11 && hour < 15) {
|
||||
return { slot: "midday", mealType: "full", hour, minute }
|
||||
}
|
||||
if (hour >= 15 && hour < 18) {
|
||||
return { slot: "afternoon", mealType: "half", hour, minute }
|
||||
}
|
||||
if (hour >= 18 && hour < 22) {
|
||||
return { slot: "evening", mealType: "full", hour, minute }
|
||||
}
|
||||
|
||||
// Late night / early morning: lean snack context
|
||||
return { slot: "afternoon", mealType: "half", hour, minute }
|
||||
}
|
||||
|
||||
export function formatClock(hour: number, minute: number, lang: "de" | "en"): string {
|
||||
const hh = String(hour).padStart(2, "0")
|
||||
const mm = String(minute).padStart(2, "0")
|
||||
return lang === "de" ? `${hh}:${mm}` : `${hh}:${mm}`
|
||||
}
|
||||
55
client/src/lib/widgetOrder.ts
Normal file
55
client/src/lib/widgetOrder.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { HomeWidgetId, WidgetOrder } from "@/types/domain"
|
||||
|
||||
const STORAGE_KEY = "pp.home.widgetOrder"
|
||||
|
||||
export const DEFAULT_WIDGET_ORDER: WidgetOrder = [
|
||||
"suggestion",
|
||||
"knowledge",
|
||||
"builder",
|
||||
]
|
||||
|
||||
function isWidgetId(value: unknown): value is HomeWidgetId {
|
||||
return value === "suggestion" || value === "knowledge" || value === "builder"
|
||||
}
|
||||
|
||||
export function readWidgetOrder(): WidgetOrder {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_WIDGET_ORDER
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every(isWidgetId)) {
|
||||
return DEFAULT_WIDGET_ORDER
|
||||
}
|
||||
const unique = new Set(parsed)
|
||||
if (unique.size !== 3) return DEFAULT_WIDGET_ORDER
|
||||
return parsed
|
||||
} catch {
|
||||
return DEFAULT_WIDGET_ORDER
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWidgetOrder(order: WidgetOrder): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function moveWidget(
|
||||
order: WidgetOrder,
|
||||
id: HomeWidgetId,
|
||||
direction: "up" | "down",
|
||||
): WidgetOrder {
|
||||
const index = order.indexOf(id)
|
||||
if (index < 0) return order
|
||||
const target = direction === "up" ? index - 1 : index + 1
|
||||
if (target < 0 || target >= order.length) return order
|
||||
const next = [...order]
|
||||
const current = next[index]
|
||||
const swap = next[target]
|
||||
if (current === undefined || swap === undefined) return order
|
||||
next[index] = swap
|
||||
next[target] = current
|
||||
return next
|
||||
}
|
||||
53
client/src/screens/BuilderScreen.tsx
Normal file
53
client/src/screens/BuilderScreen.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useLocation } from "react-router-dom"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodById } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import type { BuilderHandoffState } from "@/types/domain"
|
||||
|
||||
export function BuilderScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const location = useLocation()
|
||||
const handoff = location.state as BuilderHandoffState | null
|
||||
const ingredientIds = handoff?.ingredientIds ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<GlassCard className="p-4">
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
</p>
|
||||
<h1 className="type-display mt-2 text-[var(--ink)]">
|
||||
{t(ui.stubs.builderTitle, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.stubs.builderBody, lang)}
|
||||
</p>
|
||||
</GlassCard>
|
||||
|
||||
{ingredientIds.length > 0 ? (
|
||||
<GlassCard className="p-4">
|
||||
<p className="type-body-emphasis text-[var(--ink)]">
|
||||
{t(ui.stubs.builderHandoff, lang)}
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-wrap gap-2">
|
||||
{ingredientIds.map((id) => {
|
||||
const item = foodById[id]
|
||||
if (!item) return null
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)] ring-1 ring-[var(--border)]"
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{tr(item.name, lang)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
client/src/screens/PantryScreen.tsx
Normal file
124
client/src/screens/PantryScreen.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { Accordion } from "@/components/ui-pp/Accordion"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodCategories } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { FoodItem } from "@/types/domain"
|
||||
|
||||
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
|
||||
if (!query) return true
|
||||
return tr(item.name, lang).toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
export function PantryScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [query, setQuery] = useState("")
|
||||
const normalized = query.trim().toLowerCase()
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
foodCategories
|
||||
.map((category) => {
|
||||
const items = category.items.filter((item) => matchesQuery(item, normalized, lang))
|
||||
return { category, items }
|
||||
})
|
||||
.filter((section) => section.items.length > 0),
|
||||
[lang, normalized],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.pantry.title, lang)}
|
||||
</h1>
|
||||
<p className="type-caption mt-2 text-[var(--ink-soft)]">
|
||||
<span className="text-[var(--color-pick)]">●</span> {t(ui.pantry.pick, lang)}
|
||||
{" · "}
|
||||
<span className="text-[var(--color-caution)]">●</span> {t(ui.pantry.caution, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<label className="sr-only" htmlFor="pantry-search">
|
||||
{t(ui.pantry.search, lang)}
|
||||
</label>
|
||||
<input
|
||||
id="pantry-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t(ui.pantry.search, lang)}
|
||||
className={cx(
|
||||
"type-body min-h-[48px] w-full rounded-[var(--radius-control)] px-4 text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)] outline-none placeholder:text-[var(--ink-faint)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-kh)]/40",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
/>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<p className="type-body text-[var(--ink-soft)]">{t(ui.pantry.noResults, lang)}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sections.map(({ category, items }) => (
|
||||
<Accordion
|
||||
key={category.id}
|
||||
defaultOpen={Boolean(normalized)}
|
||||
title={tr(category.title, lang)}
|
||||
meta={items.length}
|
||||
icon={
|
||||
<span aria-hidden>
|
||||
{category.id === "protein"
|
||||
? "🥚"
|
||||
: category.id === "snack"
|
||||
? "🥤"
|
||||
: category.id === "obst"
|
||||
? "🍎"
|
||||
: category.id === "gemuese"
|
||||
? "🥦"
|
||||
: category.id === "fett"
|
||||
? "🥑"
|
||||
: "🌾"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-baseline justify-between gap-3 border-b border-[var(--border)] pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="type-body-emphasis text-[var(--ink)]">
|
||||
{tr(item.name, lang)}
|
||||
</span>
|
||||
<span
|
||||
className={cx(
|
||||
"type-caption shrink-0 text-right text-[var(--ink-soft)]",
|
||||
item.isApprox && "italic",
|
||||
)}
|
||||
>
|
||||
{item.tag === "pick" ? (
|
||||
<span className="mr-1 text-[var(--color-pick)]">●</span>
|
||||
) : null}
|
||||
{item.tag === "caution" ? (
|
||||
<span className="mr-1 text-[var(--color-caution)]">●</span>
|
||||
) : null}
|
||||
{tr(item.valueInfo, lang)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="type-caption mt-3 text-[var(--ink-soft)]">
|
||||
<b className="font-bold text-[var(--ink)]">{t(ui.pantry.tip, lang)}</b>
|
||||
<br />
|
||||
{tr(category.thumb, lang)}
|
||||
</p>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
client/src/screens/PlateScreen.tsx
Normal file
283
client/src/screens/PlateScreen.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import { DiningPlate } from "@/components/plate/DiningPlate"
|
||||
import { SegControl } from "@/components/ui-pp/SegControl"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { plateSteps, type PlateStep } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
type MealMode = "full" | "half"
|
||||
|
||||
const CLS_COLOR: Record<string, string> = {
|
||||
g: "var(--green)",
|
||||
t: "var(--terracotta)",
|
||||
o: "var(--gold)",
|
||||
s: "var(--slate)",
|
||||
}
|
||||
|
||||
const FULL_WEIGHT: Record<string, number> = {
|
||||
g: 2,
|
||||
t: 2,
|
||||
o: 1,
|
||||
s: 1,
|
||||
}
|
||||
|
||||
function stepColor(cls: string): string {
|
||||
return CLS_COLOR[cls] ?? "var(--ink-faint)"
|
||||
}
|
||||
|
||||
function StepDetailCard({
|
||||
step,
|
||||
open,
|
||||
highlighted,
|
||||
onToggle,
|
||||
lang,
|
||||
}: {
|
||||
step: PlateStep
|
||||
open: boolean
|
||||
highlighted: boolean
|
||||
onToggle: () => void
|
||||
lang: "de" | "en"
|
||||
}) {
|
||||
const color = stepColor(step.cls)
|
||||
const qty = tr(step.qty, lang)
|
||||
const panelId = `plate-step-panel-${step.n}`
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`plate-step-${step.n}`}
|
||||
className={cx(
|
||||
"overflow-hidden rounded-[var(--radius-card)] shadow-[var(--shadow-elevated)]",
|
||||
"transition-[outline,outline-offset] duration-300",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "var(--surface-gradient)",
|
||||
outline: highlighted ? `2px solid ${color}` : "2px solid transparent",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit]"
|
||||
style={{
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
/>
|
||||
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={onToggle}
|
||||
className={cx(
|
||||
"relative z-10 flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left",
|
||||
"no-select [-webkit-tap-highlight-color:transparent]",
|
||||
"active:brightness-[0.98]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full type-button text-white"
|
||||
style={{
|
||||
background: color,
|
||||
color: step.cls === "o" ? "var(--ink)" : "#fff",
|
||||
}}
|
||||
>
|
||||
{step.n}
|
||||
</span>
|
||||
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
||||
{tr(step.title, lang)}
|
||||
</span>
|
||||
{qty ? (
|
||||
<span className="type-caption shrink-0 max-w-[40%] text-right font-bold text-[var(--ink-soft)]">
|
||||
{qty}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"h-5 w-5 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className="relative z-10 border-t border-[var(--border)] px-4 py-3"
|
||||
>
|
||||
<p className="type-body text-[var(--ink-soft)]">{tr(step.text, lang)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlateScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [mode, setMode] = useState<MealMode>("full")
|
||||
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
|
||||
const [highlightId, setHighlightId] = useState<number | null>(null)
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const visibleSteps = useMemo(
|
||||
() =>
|
||||
mode === "full"
|
||||
? plateSteps
|
||||
: plateSteps.filter((step) => step.cls === "g" || step.cls === "t"),
|
||||
[mode],
|
||||
)
|
||||
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
visibleSteps.map((step) => ({
|
||||
id: String(step.n),
|
||||
weight: mode === "full" ? (FULL_WEIGHT[step.cls] ?? 1) : 1,
|
||||
color: stepColor(step.cls),
|
||||
label: tr(step.title, lang),
|
||||
})),
|
||||
[visibleSteps, mode, lang],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Drop open state for cards that are hidden in half-meal mode
|
||||
setOpenIds((current) => {
|
||||
const next = new Set<number>()
|
||||
for (const id of current) {
|
||||
if (visibleSteps.some((step) => step.n === id)) next.add(id)
|
||||
}
|
||||
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
|
||||
return next
|
||||
})
|
||||
}, [visibleSteps])
|
||||
|
||||
function focusStep(n: number) {
|
||||
setOpenIds((current) => new Set(current).add(n))
|
||||
setHighlightId(n)
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current)
|
||||
highlightTimer.current = setTimeout(() => setHighlightId(null), 300)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.getElementById(`plate-step-${n}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
})
|
||||
}
|
||||
|
||||
function toggleStep(n: number) {
|
||||
setOpenIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(n)) next.delete(n)
|
||||
else next.add(n)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const rules = [ui.plate.rule1, ui.plate.rule2, ui.plate.rule3, ui.plate.rule4]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.plate.subtitle, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SegControl
|
||||
aria-label={t(ui.plate.mealToggle, lang)}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "full", label: t(ui.plate.segFull, lang) },
|
||||
{ value: "half", label: t(ui.plate.segHalf, lang) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
<DiningPlate
|
||||
key={mode}
|
||||
size={280}
|
||||
segments={segments}
|
||||
aria-label={t(ui.plate.plateAria, lang)}
|
||||
onSelect={(id) => focusStep(Number(id))}
|
||||
/>
|
||||
|
||||
<ul className="flex w-full flex-col gap-2">
|
||||
{visibleSteps.map((step) => (
|
||||
<li key={step.n}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => focusStep(step.n)}
|
||||
className={cx(
|
||||
"type-caption flex min-h-[44px] w-full items-center justify-center gap-2 font-bold text-[var(--ink-soft)]",
|
||||
"rounded-[var(--radius-control)] px-2 active:brightness-95",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ background: stepColor(step.cls) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{tr(step.title, lang)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{visibleSteps.map((step) => (
|
||||
<div
|
||||
key={step.n}
|
||||
className={cx(
|
||||
"transition-[opacity,transform] duration-300 ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
<StepDetailCard
|
||||
step={step}
|
||||
open={openIds.has(step.n)}
|
||||
highlighted={highlightId === step.n}
|
||||
onToggle={() => toggleStep(step.n)}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-2 rounded-[var(--radius-card)] p-4 ring-1 ring-[var(--border)]"
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
<p className="type-label mb-3 text-[var(--ink-faint)]">
|
||||
{t(ui.plate.rulesTitle, lang)}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{rules.map((rule, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="type-body flex gap-2 text-[var(--ink-soft)]"
|
||||
>
|
||||
<span className="text-[var(--ink-faint)]" aria-hidden>
|
||||
—
|
||||
</span>
|
||||
<span>{t(rule, lang)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
client/src/screens/SettingsScreen.tsx
Normal file
58
client/src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button } from "@/components/ui-pp/Button"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { Language } from "@/types/domain"
|
||||
|
||||
const OPTIONS: { id: Language; label: string }[] = [
|
||||
{ id: "de", label: "Deutsch" },
|
||||
{ id: "en", label: "English" },
|
||||
]
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { lang, setLang } = useLanguage()
|
||||
|
||||
return (
|
||||
<GlassCard className="p-4">
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.stubs.settingsTitle, lang)}
|
||||
</h1>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.stubs.language, lang)}
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setLang(option.id)}
|
||||
className={cx(
|
||||
"elevation-transition min-h-[48px] rounded-[var(--radius-control)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
lang === option.id
|
||||
? "type-button text-[var(--ink)] shadow-[var(--shadow-elevated)]"
|
||||
: "type-button-secondary text-[var(--ink-soft)] ring-1 ring-[var(--border)]",
|
||||
)}
|
||||
style={
|
||||
lang === option.id
|
||||
? { backgroundImage: "var(--gradient-primary)" }
|
||||
: { background: "var(--glass-fill)" }
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" className="w-full" disabled>
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
136
client/src/screens/WeekPlanScreen.tsx
Normal file
136
client/src/screens/WeekPlanScreen.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { Accordion, NestedDisclosure } from "@/components/ui-pp/Accordion"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { PLAN_TYPE_ORDER, recipesByType, resolveRecipeUses } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { PlanRecipeType, Recipe } from "@/types/domain"
|
||||
|
||||
const TYPE_ICON: Record<PlanRecipeType, string> = {
|
||||
breakfast: "🌅",
|
||||
snack: "🥤",
|
||||
full: "🍽️",
|
||||
recipe: "📖",
|
||||
}
|
||||
|
||||
function typeLabel(type: PlanRecipeType, lang: "de" | "en"): string {
|
||||
return t(ui.weekplan[type], lang)
|
||||
}
|
||||
|
||||
function RecipeBody({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
|
||||
const ingredients = resolveRecipeUses(recipe)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{recipe.cooked !== undefined ? (
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{recipe.cooked ? t(ui.weekplan.cooked, lang) : t(ui.weekplan.cold, lang)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{recipe.text ? (
|
||||
<p className="type-body text-[var(--ink-soft)]">{tr(recipe.text, lang)}</p>
|
||||
) : null}
|
||||
|
||||
{recipe.variations && recipe.variations.length > 0 ? (
|
||||
<div>
|
||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
||||
{t(ui.weekplan.variations, lang)}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{recipe.variations.map((variation, index) => (
|
||||
<li
|
||||
key={`${recipe.id}-var-${index}`}
|
||||
className="type-body flex gap-2 text-[var(--ink-soft)]"
|
||||
>
|
||||
<span className="text-[var(--ink-faint)]" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>{tr(variation, lang)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
||||
{t(ui.weekplan.uses, lang)}
|
||||
</p>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{ingredients.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cx(
|
||||
"type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{tr(item.name, lang)}
|
||||
{item.source === "extra" ? (
|
||||
<span className="ml-1 text-[var(--ink-faint)]">+</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekPlanScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [openTypes, setOpenTypes] = useState<Record<PlanRecipeType, boolean>>({
|
||||
breakfast: true,
|
||||
snack: false,
|
||||
full: false,
|
||||
recipe: false,
|
||||
})
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
PLAN_TYPE_ORDER.map((type) => ({
|
||||
type,
|
||||
items: recipesByType(type),
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.weekplan.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.weekplan.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map(({ type, items }) => (
|
||||
<Accordion
|
||||
key={type}
|
||||
open={openTypes[type]}
|
||||
onOpenChange={(open) =>
|
||||
setOpenTypes((current) => ({ ...current, [type]: open }))
|
||||
}
|
||||
title={typeLabel(type, lang)}
|
||||
meta={items.length}
|
||||
icon={<span aria-hidden>{TYPE_ICON[type]}</span>}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((recipe) => (
|
||||
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
|
||||
<RecipeBody recipe={recipe} lang={lang} />
|
||||
</NestedDisclosure>
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
client/src/styles/glass.css
Normal file
29
client/src/styles/glass.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Modal-only glass utilities.
|
||||
*
|
||||
* Home/surfaces use the elevated system in theme.css.
|
||||
* True low-opacity glass is reserved for overlays/modals so content
|
||||
* behind them stays readable without fighting the page gradient.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--modal-glass-fill: rgba(255, 255, 255, 0.12);
|
||||
--modal-glass-blur: 16px;
|
||||
--modal-glass-border: rgba(255, 255, 255, 0.35);
|
||||
--modal-glass-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--modal-scrim: rgba(25, 24, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-glass {
|
||||
background: var(--modal-glass-fill);
|
||||
backdrop-filter: blur(var(--modal-glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--modal-glass-blur));
|
||||
border: 1px solid var(--modal-glass-border);
|
||||
box-shadow: var(--modal-glass-shadow);
|
||||
}
|
||||
|
||||
.modal-scrim {
|
||||
background: var(--modal-scrim);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
270
client/src/styles/theme.css
Normal file
270
client/src/styles/theme.css
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Pocket Pascal — Elevated Surface Design Tokens
|
||||
*
|
||||
* Pivoted from heavy glassmorphism to a low-transparency, tactile system:
|
||||
* cards read as solid raised surfaces (elevation shadow + faint top/bottom
|
||||
* bevel), buttons visibly depress on press (inset shadow + 1px downward
|
||||
* shift) instead of just scaling. `--glass-*` names kept for minimal diff
|
||||
* across components — they now mean "surface fill / surface blur", not
|
||||
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
|
||||
* whatever sits behind a card without reading as transparent.
|
||||
*
|
||||
* Toggle dark mode by adding/removing the `dark` class on <html>
|
||||
* (matches Tailwind `darkMode: 'class'` — see README).
|
||||
*
|
||||
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited
|
||||
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
|
||||
* section 05/08 for the full rationale and contrast numbers.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---------- Elevated surface (formerly "glass") ---------- */
|
||||
--glass-fill: rgba(255, 255, 255, 0.9);
|
||||
--glass-blur: 8px;
|
||||
/* top-light / bottom-shadow bevel instead of an even glass edge — reads as raised, not see-through */
|
||||
--glass-border-from: rgba(255, 255, 255, 0.9);
|
||||
--glass-border-to: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* ---------- Radius scale (nested: outer ≈ inner + padding) ---------- */
|
||||
--radius-card: 24px;
|
||||
--radius-nested: 12px;
|
||||
--radius-control: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* ---------- Shadows (resting → elevated → float; warm ink-tinted ambient) ---------- */
|
||||
--shadow-resting: 0 1px 2px rgba(25, 24, 0, 0.04), 0 1px 3px rgba(25, 24, 0, 0.06);
|
||||
--shadow-elevated: 0 2px 4px rgba(25, 24, 0, 0.04), 0 8px 20px rgba(25, 24, 0, 0.08);
|
||||
--shadow-float: 0 4px 8px rgba(25, 24, 0, 0.05), 0 16px 32px rgba(25, 24, 0, 0.1);
|
||||
--shadow-pressed: inset 0 2px 5px rgba(25, 24, 0, 0.22), inset 0 1px 1px rgba(25, 24, 0, 0.12);
|
||||
/* BottomNav / overlays sit one step above content cards */
|
||||
--shadow-ambient: var(--shadow-float);
|
||||
|
||||
/* ---------- Brand ink / neutrals ---------- */
|
||||
--ink: #191800;
|
||||
--ink-soft: #5c5b48;
|
||||
--ink-faint: #6b6a56;
|
||||
--border: #e4e2d8;
|
||||
|
||||
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */
|
||||
--color-protein: #d9f227;
|
||||
--color-protein-dk: #2e3200;
|
||||
--color-obstgem: #ff6a3d;
|
||||
--color-obstgem-dk: #3d1508;
|
||||
--color-fett: #ffb300;
|
||||
--color-fett-dk: #3d2900;
|
||||
--color-kh: #1fd9c4;
|
||||
--color-kh-dk: #04302b;
|
||||
--color-pick: #0f7a37;
|
||||
--color-caution: #c81e1e;
|
||||
|
||||
/* ---------- Plate / Baukasten category hues (from prototype) ---------- */
|
||||
--green: #2f5233;
|
||||
--green-soft: #e3ece4;
|
||||
--terracotta: #b33d22;
|
||||
--terracotta-soft: #f7e0d8;
|
||||
--gold: #e8b93a;
|
||||
--gold-soft: #fdf1d9;
|
||||
--slate: #6b4c9a;
|
||||
--slate-soft: #efe9f5;
|
||||
|
||||
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */
|
||||
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
|
||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
|
||||
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
|
||||
|
||||
/* ---------- Action gradients (vibrant, multi-stop) ---------- */
|
||||
/* NOTE: brand hues are light/high-luminance by design (see contrast audit),
|
||||
so action gradients pair with --ink text, not white — this keeps every
|
||||
button at AA contrast without text-shadow hacks. Do not swap to white
|
||||
text on these without re-checking contrast. */
|
||||
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
||||
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
|
||||
|
||||
/* ---------- Dining Plate chart tokens ---------- */
|
||||
--plate-rim-light: #ffffff;
|
||||
--plate-rim-shadow: #d8d8d0;
|
||||
--plate-well-center: #f7f7f2;
|
||||
--plate-well-edge: #e6e6dd;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--glass-fill: rgba(28, 28, 21, 0.88);
|
||||
--glass-blur: 8px;
|
||||
--glass-border-from: rgba(255, 255, 255, 0.1);
|
||||
--glass-border-to: rgba(0, 0, 0, 0.35);
|
||||
|
||||
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
--shadow-elevated: 0 2px 4px rgba(0, 0, 0, 0.35), 0 10px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-float: 0 4px 8px rgba(0, 0, 0, 0.4), 0 18px 36px rgba(0, 0, 0, 0.45);
|
||||
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.55), inset 0 1px 1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-ambient: var(--shadow-float);
|
||||
|
||||
--ink: #f3f2e6;
|
||||
--ink-soft: #c2c1ac;
|
||||
--ink-faint: #8f8e7a;
|
||||
--border: #33342a;
|
||||
|
||||
/* category primaries stay identical (they're the brand, not a theme) */
|
||||
--color-protein: #d9f227;
|
||||
--color-protein-dk: #2e3200;
|
||||
--color-obstgem: #ff6a3d;
|
||||
--color-obstgem-dk: #3d1508;
|
||||
--color-fett: #ffb300;
|
||||
--color-fett-dk: #3d2900;
|
||||
--color-kh: #1fd9c4;
|
||||
--color-kh-dk: #04302b;
|
||||
--color-pick: #4ade80;
|
||||
--color-caution: #ff6b60;
|
||||
|
||||
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
|
||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
|
||||
radial-gradient(120% 100% at 100% 100%, #0f2a26 0%, #14140f 45%);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
||||
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #ff6b60 100%);
|
||||
|
||||
--plate-rim-light: #2a2a22;
|
||||
--plate-rim-shadow: #0c0c09;
|
||||
--plate-well-center: #1c1c15;
|
||||
--plate-well-edge: #111109;
|
||||
}
|
||||
|
||||
/* ---------- Fonts ---------- */
|
||||
/* Download the two static woff2 files listed in README into /public/fonts,
|
||||
then this block just works. Using local @font-face (not next/font) keeps
|
||||
this file framework-agnostic for Cursor. */
|
||||
@font-face {
|
||||
font-family: "Unbounded";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/unbounded-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Unbounded";
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/unbounded-900.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-400.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Albert Sans";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/fonts/albert-sans-700.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.font-body {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- Type ramp (single hierarchy) ---------- */
|
||||
.type-display {
|
||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.5rem; /* 24px */
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.type-title {
|
||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem; /* 20px */
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.type-title-sm {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem; /* 16px */
|
||||
line-height: 1.3;
|
||||
}
|
||||
.type-body {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
line-height: 1.5;
|
||||
}
|
||||
.type-body-emphasis {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.type-label {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.type-caption {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem; /* 12px */
|
||||
line-height: 1.45;
|
||||
}
|
||||
.type-button {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.type-button-secondary {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 500; /* closest to semibold until 600 is loaded */
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ---------- Glass border (gradient 1px border that respects border-radius) ---------- */
|
||||
/* border-image ignores border-radius, so we use a masked pseudo-element instead.
|
||||
Apply `.glass-border` to a `relative` container with `overflow-hidden` off
|
||||
(the pseudo-element is inset:0 and radius:inherit). */
|
||||
.glass-border {
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
/* top-light, bottom-shadow — reads as a raised bevel, not a glass sheen */
|
||||
background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
|
||||
.elevation-transition {
|
||||
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
|
||||
}
|
||||
|
||||
/* ---------- PWA polish ---------- */
|
||||
html,
|
||||
body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
79
client/src/types/domain.ts
Normal file
79
client/src/types/domain.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type Language = "de" | "en"
|
||||
|
||||
export interface LocalizedString {
|
||||
de: string
|
||||
en: string
|
||||
}
|
||||
|
||||
export type CategoryId =
|
||||
| "protein"
|
||||
| "snack"
|
||||
| "obst"
|
||||
| "gemuese"
|
||||
| "fett"
|
||||
| "kh"
|
||||
|
||||
export type TagType = "pick" | "caution"
|
||||
|
||||
export type MealType = "full" | "half"
|
||||
|
||||
export type TimeSlot = "morning" | "midday" | "afternoon" | "evening"
|
||||
|
||||
export type PlanRecipeType = "breakfast" | "snack" | "full" | "recipe"
|
||||
|
||||
export type HomeWidgetId = "suggestion" | "knowledge" | "builder"
|
||||
|
||||
export type WidgetOrder = HomeWidgetId[]
|
||||
|
||||
export interface FoodItem {
|
||||
id: string
|
||||
cat: CategoryId
|
||||
name: LocalizedString
|
||||
valueInfo: LocalizedString
|
||||
tag?: TagType
|
||||
isApprox?: boolean
|
||||
}
|
||||
|
||||
export interface FoodCategory {
|
||||
id: CategoryId
|
||||
cls: string
|
||||
title: LocalizedString
|
||||
thumb: LocalizedString
|
||||
items: FoodItem[]
|
||||
}
|
||||
|
||||
/** Condiment / prep ingredient outside the plate categories (CATS). */
|
||||
export interface ExtraItem {
|
||||
id: string
|
||||
name: LocalizedString
|
||||
}
|
||||
|
||||
/** Resolved CATS or EXTRAS entry for recipe `uses` ids. */
|
||||
export interface CatalogItem {
|
||||
id: string
|
||||
name: LocalizedString
|
||||
source: "cat" | "extra"
|
||||
cat?: CategoryId
|
||||
valueInfo?: LocalizedString
|
||||
tag?: TagType
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated meal / snack / how-to recipes.
|
||||
* `uses` references ids from CATS (foods) or EXTRAS.
|
||||
*/
|
||||
export interface Recipe {
|
||||
id: string
|
||||
type: PlanRecipeType
|
||||
name: LocalizedString
|
||||
uses: string[]
|
||||
cooked?: boolean
|
||||
text?: LocalizedString
|
||||
/** Optional flavor / protein twists shown under the base recipe text. */
|
||||
variations?: LocalizedString[]
|
||||
}
|
||||
|
||||
export interface BuilderHandoffState {
|
||||
ingredientIds: string[]
|
||||
source?: "suggestion" | "home"
|
||||
}
|
||||
Reference in New Issue
Block a user