Rebuild app IA and polish pantry, chips, and food data.
Introduce the four-tab hubs (Jetzt/Küche/Planen/Wissen), frosted ingredient flyouts with caution reasons, structured nutrition fields with protein sorting, and disabled coming-soon nav tiles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"shadcn": "^4.16.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -4,10 +4,17 @@ import { HomeScreen } from "@/components/home/HomeScreen"
|
||||
import { AppShell } from "@/components/layout/AppShell"
|
||||
import { LanguageProvider } from "@/lib/language"
|
||||
import { BuilderScreen } from "@/screens/BuilderScreen"
|
||||
import { HowToLibraryScreen } from "@/screens/HowToLibraryScreen"
|
||||
import { KitchenHubScreen } from "@/screens/KitchenHubScreen"
|
||||
import { KnowledgeHubScreen } from "@/screens/KnowledgeHubScreen"
|
||||
import { PantryScreen } from "@/screens/PantryScreen"
|
||||
import { PlaceholderScreen } from "@/screens/PlaceholderScreen"
|
||||
import { PlanHubScreen } from "@/screens/PlanHubScreen"
|
||||
import { PlateScreen } from "@/screens/PlateScreen"
|
||||
import { RulesScreen } from "@/screens/RulesScreen"
|
||||
import { SettingsScreen } from "@/screens/SettingsScreen"
|
||||
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
|
||||
import { ui } from "@/i18n/ui"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -16,11 +23,70 @@ export default function App() {
|
||||
<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="kitchen" element={<KitchenHubScreen />} />
|
||||
<Route path="kitchen/pantry" element={<PantryScreen />} />
|
||||
<Route path="kitchen/builder" element={<BuilderScreen />} />
|
||||
<Route
|
||||
path="kitchen/stock"
|
||||
element={
|
||||
<PlaceholderScreen
|
||||
backTo="/kitchen"
|
||||
backLabel={ui.kitchen.back}
|
||||
title={ui.placeholders.stockTitle}
|
||||
body={ui.placeholders.stockBody}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="plan" element={<PlanHubScreen />} />
|
||||
<Route path="plan/week" element={<WeekPlanScreen />} />
|
||||
<Route
|
||||
path="plan/meal-planner"
|
||||
element={
|
||||
<PlaceholderScreen
|
||||
backTo="/plan"
|
||||
backLabel={ui.planHub.back}
|
||||
title={ui.placeholders.mealPlannerTitle}
|
||||
body={ui.placeholders.mealPlannerBody}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan/shopping"
|
||||
element={
|
||||
<PlaceholderScreen
|
||||
backTo="/plan"
|
||||
backLabel={ui.planHub.back}
|
||||
title={ui.placeholders.shoppingTitle}
|
||||
body={ui.placeholders.shoppingBody}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan/recipes"
|
||||
element={
|
||||
<PlaceholderScreen
|
||||
backTo="/plan"
|
||||
backLabel={ui.planHub.back}
|
||||
title={ui.placeholders.recipesTitle}
|
||||
body={ui.placeholders.recipesBody}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="knowledge" element={<KnowledgeHubScreen />} />
|
||||
<Route path="knowledge/plate" element={<PlateScreen />} />
|
||||
<Route path="knowledge/recipes" element={<HowToLibraryScreen />} />
|
||||
<Route path="knowledge/rules" element={<RulesScreen />} />
|
||||
<Route path="knowledge/settings" element={<SettingsScreen />} />
|
||||
|
||||
{/* Legacy redirects */}
|
||||
<Route path="pantry" element={<Navigate to="/kitchen/pantry" replace />} />
|
||||
<Route path="builder" element={<Navigate to="/kitchen/builder" replace />} />
|
||||
<Route path="weekplan" element={<Navigate to="/plan/week" replace />} />
|
||||
<Route path="settings" element={<Navigate to="/knowledge/settings" replace />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
42
client/src/components/food/CategorySoftChip.tsx
Normal file
42
client/src/components/food/CategorySoftChip.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { categoryDotColor } from "@/lib/catalog"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CategoryId } from "@/types/domain"
|
||||
|
||||
const CAT_ICON: Record<CategoryId, string> = {
|
||||
protein: "🥚",
|
||||
snack: "🥤",
|
||||
obst: "🍎",
|
||||
gemuese: "🥦",
|
||||
fett: "🥑",
|
||||
kh: "🌾",
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-Chip category marker (Vorrat accordion headers).
|
||||
* Category hue washed with white — readable emoji, no side stripe on cards.
|
||||
*/
|
||||
export function CategorySoftChip({
|
||||
categoryId,
|
||||
className,
|
||||
}: {
|
||||
categoryId: CategoryId
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"flex h-9 w-9 items-center justify-center rounded-[var(--radius-control)]",
|
||||
"text-lg leading-none ring-1 ring-[var(--border)]",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: categoryDotColor(categoryId),
|
||||
boxShadow:
|
||||
"inset 0 0 0 999px color-mix(in srgb, white 72%, transparent)",
|
||||
}}
|
||||
>
|
||||
{CAT_ICON[categoryId]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
159
client/src/components/food/IngredientChip.tsx
Normal file
159
client/src/components/food/IngredientChip.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Popover } from "@base-ui/react/popover"
|
||||
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodCategoryById } from "@/lib/catalog"
|
||||
import { categoryChipSurface, chipAriaLabel, chipTagModifiers } from "@/lib/chipStyle"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CatalogItem, Language } from "@/types/domain"
|
||||
|
||||
function detailAria(name: string, lang: Language): string {
|
||||
return t(ui.ingredients.detailAria, lang).replace("{name}", name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip + flyout (Base UI Popover). Tap opens; tap same chip / outside closes.
|
||||
* Content: valueInfo · caution (if tagged) · category Daumenregel.
|
||||
* Name is not repeated — the chip already shows it.
|
||||
*/
|
||||
export function IngredientChip({
|
||||
item,
|
||||
size,
|
||||
}: {
|
||||
item: CatalogItem
|
||||
size: "regular" | "large"
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const large = size === "large"
|
||||
const surface = categoryChipSurface(item.cat)
|
||||
const tagMod = chipTagModifiers(item.tag)
|
||||
const name = tr(item.name, lang)
|
||||
const label = chipAriaLabel(
|
||||
name,
|
||||
item.tag,
|
||||
t(ui.pantry.pick, lang),
|
||||
t(ui.pantry.caution, lang),
|
||||
)
|
||||
const category = item.cat ? foodCategoryById(item.cat) : undefined
|
||||
const valueInfo = item.valueInfo ? tr(item.valueInfo, lang) : null
|
||||
/** Obst: valueInfo is enough — no category tip in the flyout. */
|
||||
const showThumb = Boolean(category && item.cat !== "obst")
|
||||
const thumb = showThumb && category ? tr(category.thumb, lang) : null
|
||||
const categoryTitle = showThumb && category ? tr(category.title, lang) : null
|
||||
const showCaution = item.tag === "caution"
|
||||
const cautionWhy =
|
||||
showCaution && item.cautionWhy ? tr(item.cautionWhy, lang) : null
|
||||
|
||||
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
|
||||
const chipType = large
|
||||
? "type-body-emphasis text-[var(--ink)]"
|
||||
: "type-caption font-bold text-[var(--ink)]"
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
aria-label={detailAria(label, lang)}
|
||||
title={item.tag ? label : undefined}
|
||||
className={cx(
|
||||
"inline-flex items-center rounded-[var(--radius-pill)]",
|
||||
"cursor-pointer transition-[filter] active:brightness-[0.98]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
|
||||
"data-[popup-open]:ring-2 data-[popup-open]:ring-[var(--ink)]/25",
|
||||
chipType,
|
||||
chipBox,
|
||||
tagMod.className,
|
||||
)}
|
||||
style={{ ...surface, ...tagMod.style }}
|
||||
>
|
||||
<span className={cx("truncate", large ? "max-w-[10rem]" : "max-w-[8rem]")}>
|
||||
{name}
|
||||
</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
{/*
|
||||
Prefer bottom-end (bottom-right of chip). Collision avoidance flips
|
||||
when the panel would leave the viewport. No scale/transform — keeps
|
||||
backdrop-filter working.
|
||||
*/}
|
||||
<Popover.Positioner
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="z-50 outline-none"
|
||||
>
|
||||
<Popover.Popup
|
||||
className={cx(
|
||||
"relative w-[min(18rem,calc(100vw-2rem))] rounded-[var(--radius-card)] p-3",
|
||||
"shadow-[var(--shadow-float)]",
|
||||
"data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
|
||||
"transition-opacity duration-150 ease-out",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--glass-fill) 12%, transparent)",
|
||||
backdropFilter: "blur(28px) saturate(1.6)",
|
||||
WebkitBackdropFilter: "blur(28px) saturate(1.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-40"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(145deg, color-mix(in srgb, white 45%, transparent) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
/>
|
||||
|
||||
{/* Solid ink on glass — ink-soft fails AA over busy blurred chips */}
|
||||
<div className="relative z-10 [text-shadow:0_0.5px_0_color-mix(in_srgb,white_55%,transparent)]">
|
||||
<Popover.Title className="sr-only">{name}</Popover.Title>
|
||||
|
||||
{valueInfo ? (
|
||||
<Popover.Description className="type-body-emphasis text-[var(--ink)]">
|
||||
{valueInfo}
|
||||
</Popover.Description>
|
||||
) : null}
|
||||
|
||||
{showCaution ? (
|
||||
<div className={valueInfo ? "mt-2" : undefined}>
|
||||
<p className="type-caption font-bold text-[var(--signal-warning)]">
|
||||
{t(ui.pantry.caution, lang)}
|
||||
</p>
|
||||
{cautionWhy ? (
|
||||
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
|
||||
{cautionWhy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{thumb ? (
|
||||
<div
|
||||
className={cx(
|
||||
"border-t border-[var(--border)] pt-2.5",
|
||||
valueInfo || showCaution ? "mt-3" : undefined,
|
||||
)}
|
||||
>
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.pantry.tip, lang)}
|
||||
{categoryTitle ? ` · ${categoryTitle}` : null}
|
||||
</p>
|
||||
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
|
||||
{thumb}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
73
client/src/components/food/IngredientChips.tsx
Normal file
73
client/src/components/food/IngredientChips.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { IngredientChip } from "@/components/food/IngredientChip"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CatalogItem } from "@/types/domain"
|
||||
|
||||
/**
|
||||
* Ingredient chips — two sizes only:
|
||||
* - `regular` (default): browse lists (Wochenplan, How-to, Builder, …)
|
||||
* - `large`: Jetzt suggestion only
|
||||
*
|
||||
* Each chip opens a flyout (tap again / outside to close).
|
||||
*/
|
||||
export function IngredientChips({
|
||||
items,
|
||||
maxVisible,
|
||||
className,
|
||||
size = "regular",
|
||||
}: {
|
||||
items: CatalogItem[]
|
||||
maxVisible?: number
|
||||
className?: string
|
||||
size?: "regular" | "large"
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const large = size === "large"
|
||||
const overflow =
|
||||
maxVisible !== undefined ? Math.max(0, items.length - maxVisible) : 0
|
||||
const visible =
|
||||
maxVisible !== undefined && !expanded
|
||||
? items.slice(0, maxVisible)
|
||||
: items
|
||||
|
||||
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
|
||||
|
||||
return (
|
||||
<ul className={cx("flex flex-wrap", large ? "gap-2" : "gap-1.5", className)}>
|
||||
{visible.map((item) => (
|
||||
<li key={item.id} className="inline-flex">
|
||||
<IngredientChip item={item} size={size} />
|
||||
</li>
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? t(ui.ingredients.showLess, lang)
|
||||
: t(ui.ingredients.showMore, lang)
|
||||
}
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
className={cx(
|
||||
"inline-flex items-center rounded-[var(--radius-pill)] ring-1 ring-[var(--border)]",
|
||||
"text-[var(--ink-faint)] cursor-pointer transition-colors",
|
||||
"hover:text-[var(--ink)] focus-visible:outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
|
||||
large ? "type-body-emphasis" : "type-caption font-bold",
|
||||
chipBox,
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{expanded ? `−${overflow}` : `+${overflow}`}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +1,23 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus-First Start: only the hunger suggestion — tools live in Küche / Planen. */
|
||||
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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{order.map((id) => (
|
||||
<WidgetShell
|
||||
key={id}
|
||||
id={id}
|
||||
editing={editing}
|
||||
order={order}
|
||||
onMove={handleMove}
|
||||
>
|
||||
{widgets[id]}
|
||||
</WidgetShell>
|
||||
))}
|
||||
<section aria-label={t(ui.home.title, lang)}>
|
||||
<SuggestionCard />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
131
client/src/components/home/NavCard.tsx
Normal file
131
client/src/components/home/NavCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
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 { LocalizedString } from "@/types/domain"
|
||||
|
||||
export interface NavCardProps {
|
||||
title: string
|
||||
body: string
|
||||
icon: ReactNode
|
||||
gradient?: string
|
||||
/** Required when interactive; ignored when `disabled`. */
|
||||
onNavigate?: () => void
|
||||
className?: string
|
||||
size?: "default" | "featured"
|
||||
/** Status chip, e.g. Demnächst — typically paired with `disabled`. */
|
||||
badge?: string
|
||||
/** Coming-soon / unavailable: not clickable, no navigation. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Navigation card — noun-phrase body, ≥44px touch target when interactive. */
|
||||
export function NavCard({
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
gradient = "var(--gradient-primary)",
|
||||
onNavigate,
|
||||
className,
|
||||
size = "default",
|
||||
badge,
|
||||
disabled = false,
|
||||
}: NavCardProps) {
|
||||
const featured = size === "featured"
|
||||
const interactive = !disabled && Boolean(onNavigate)
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
variant={interactive ? "interactive" : "default"}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cx(
|
||||
featured ? "p-5" : "p-4",
|
||||
"min-h-[44px]",
|
||||
disabled && "opacity-55",
|
||||
className,
|
||||
)}
|
||||
onClick={interactive ? onNavigate : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onNavigate?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cx(
|
||||
"flex items-center justify-center rounded-[var(--radius-control)] text-[var(--ink)]",
|
||||
"[&_svg]:text-[var(--ink)]",
|
||||
featured ? "h-12 w-12" : "h-10 w-10",
|
||||
)}
|
||||
style={{ backgroundImage: gradient }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{badge ? (
|
||||
<span className="type-caption rounded-[var(--radius-pill)] px-2 py-1 font-bold text-[var(--ink-faint)] ring-1 ring-[var(--border)]">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className={cx(
|
||||
"text-[var(--ink)]",
|
||||
featured ? "type-title" : "type-title-sm",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubBackLink({
|
||||
to,
|
||||
label,
|
||||
}: {
|
||||
to: string
|
||||
label: LocalizedString
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(to)}
|
||||
className={cx(
|
||||
"type-caption mb-1 inline-flex min-h-[44px] items-center gap-1 font-bold text-[var(--ink-soft)]",
|
||||
"active:brightness-95",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>‹</span>
|
||||
{t(label, lang)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeBackLink() {
|
||||
return <HubBackLink to="/knowledge" label={ui.knowledge.back} />
|
||||
}
|
||||
|
||||
export function KitchenBackLink() {
|
||||
return <HubBackLink to="/kitchen" label={ui.kitchen.back} />
|
||||
}
|
||||
|
||||
export function PlanBackLink() {
|
||||
return <HubBackLink to="/plan" label={ui.planHub.back} />
|
||||
}
|
||||
@@ -1,127 +1,135 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Pin, RefreshCw } from "lucide-react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||
import { Button } from "@/components/ui-pp/Button"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { resolveRecipeUses } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion"
|
||||
import { pickSuggestion } from "@/lib/suggestion"
|
||||
import { formatClock, getTimeContext } from "@/lib/timeContext"
|
||||
import type { FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
||||
import type { Effort, MealTime, MealType, Recipe } 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)",
|
||||
const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"]
|
||||
|
||||
function effortLabel(effort: Effort, lang: "de" | "en"): string {
|
||||
if (effort === "none") return t(ui.suggestion.effortNone, lang)
|
||||
if (effort === "quick") return t(ui.suggestion.effortQuick, lang)
|
||||
return t(ui.suggestion.effortCook, lang)
|
||||
}
|
||||
|
||||
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string {
|
||||
return t(ui.suggestion[slot], lang)
|
||||
function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string {
|
||||
return t(ui.suggestion[mealTime], lang)
|
||||
}
|
||||
|
||||
function plateLabel(mealType: MealType, lang: "de" | "en"): string {
|
||||
return mealType === "full"
|
||||
? t(ui.suggestion.plateFull, lang)
|
||||
: t(ui.suggestion.plateHalf, lang)
|
||||
}
|
||||
|
||||
export function SuggestionCard() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const context = useMemo(() => getTimeContext(), [])
|
||||
const [effort, setEffort] = useState<Effort | null>(null)
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(() =>
|
||||
pickSuggestion(context.mealTime, null),
|
||||
)
|
||||
|
||||
const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot))
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set())
|
||||
const ingredients = recipe ? resolveRecipeUses(recipe) : []
|
||||
const clock = formatClock(context.hour, context.minute)
|
||||
const plateType = recipe?.type ?? context.mealType
|
||||
const eyebrow = `${mealTimeLabel(context.mealTime, lang)} · ${plateLabel(plateType, lang)}`
|
||||
|
||||
const recipe: Recipe | null = suggestion.recipe
|
||||
const items: FoodItem[] = suggestion.foods
|
||||
function applyEffort(next: Effort | null) {
|
||||
setEffort(next)
|
||||
setRecipe(pickSuggestion(context.mealTime, next, recipe?.id))
|
||||
}
|
||||
|
||||
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 toggleEffort(value: Effort) {
|
||||
applyEffort(effort === value ? null : value)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined))
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard className="p-4">
|
||||
<GlassCard className="p-5">
|
||||
<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 className="flex gap-3">
|
||||
<TimeIconBadge
|
||||
hour={context.hour}
|
||||
mealTime={context.mealTime}
|
||||
aria-label={eyebrow}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="type-label uppercase tracking-wide text-[var(--ink-soft)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<p className="type-caption text-[var(--ink-faint)]" aria-hidden>
|
||||
{clock}
|
||||
{lang === "de" ? " Uhr" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{recipe ? (
|
||||
<h2 className="type-title text-[var(--ink)]">
|
||||
{tr(recipe.name, lang)}
|
||||
</h2>
|
||||
) : (
|
||||
<p className="type-body text-[var(--ink-soft)]">
|
||||
{t(ui.suggestion.emptyFilter, lang)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
{ingredients.length > 0 ? (
|
||||
<IngredientChips items={ingredients} size="large" />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
||||
{t(ui.suggestion.effortLabel, lang)}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t(ui.suggestion.effortLabel, lang)}
|
||||
>
|
||||
{EFFORT_OPTIONS.map((option) => {
|
||||
const active = effort === option
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleEffort(option)}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[44px] items-center rounded-[var(--radius-pill)] px-3",
|
||||
"type-caption font-bold text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
active && "ring-2 ring-[var(--ui-accent)]/40",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{effortLabel(option, lang)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
<Button variant="secondary" onClick={reroll}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
||||
{t(ui.suggestion.reroll, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom"
|
||||
import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react"
|
||||
import { BookOpen, CalendarDays, CookingPot, Sparkles } 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 },
|
||||
{ key: "now", path: "/", icon: Sparkles, labelKey: "now" as const },
|
||||
{ key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const },
|
||||
{ key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const },
|
||||
{
|
||||
key: "knowledge",
|
||||
path: "/knowledge",
|
||||
icon: BookOpen,
|
||||
labelKey: "knowledge" as const,
|
||||
},
|
||||
]
|
||||
|
||||
export function AppShell() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
@@ -12,7 +13,9 @@ export interface PlateSegment {
|
||||
|
||||
export interface DiningPlateProps {
|
||||
segments: PlateSegment[]
|
||||
/** Mobile-first default. */
|
||||
size?: number
|
||||
selectedId?: string | null
|
||||
onSelect?: (id: string) => void
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
@@ -23,160 +26,189 @@ function polar(centerX: number, centerY: number, r: number, angleDeg: number) {
|
||||
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).
|
||||
* Ceramic dining plate with detached, rounded pie wedges (Recharts).
|
||||
* padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile.
|
||||
*/
|
||||
export function DiningPlate({
|
||||
segments,
|
||||
size = 280,
|
||||
size = 240,
|
||||
selectedId = null,
|
||||
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 rimPad = size * 0.14
|
||||
const foodR = size / 2 - rimPad
|
||||
const gapDeg = 2.5
|
||||
const center = size / 2
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
segments.map((segment) => ({
|
||||
id: segment.id,
|
||||
value: segment.weight,
|
||||
color: segment.color,
|
||||
label: segment.label,
|
||||
})),
|
||||
[segments],
|
||||
)
|
||||
|
||||
/** Mid-angles for rim dots / floating pill (Recharts starts at +90° / 12 o'clock). */
|
||||
const markers = useMemo(() => {
|
||||
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
|
||||
const padAngle = 5
|
||||
const padTotal = padAngle * segments.length
|
||||
const usable = 360 - padTotal
|
||||
let angle = 0
|
||||
return segments.map((segment) => {
|
||||
const sweep = (segment.weight / total) * usable
|
||||
const start = angle + padAngle / 2
|
||||
const end = angle + padAngle / 2 + sweep
|
||||
const mid = (start + end) / 2
|
||||
angle += sweep + padAngle
|
||||
return {
|
||||
id: segment.id,
|
||||
label: segment.label,
|
||||
color: segment.color,
|
||||
marker: polar(center, center, foodR + rimPad * 0.48, mid),
|
||||
pill: polar(center, center, foodR + rimPad * 1.45, mid),
|
||||
}
|
||||
})
|
||||
}, [segments, center, foodR, rimPad])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("relative select-none", className)}
|
||||
style={{ width: size, height: size }}
|
||||
className={cx("relative mx-auto select-none overflow-visible", className)}
|
||||
style={{ width: size, height: size, maxWidth: "100%" }}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* 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%)",
|
||||
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 48%, 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)
|
||||
0 6px 18px rgba(25, 24, 0, 0.1),
|
||||
inset 5px 6px 12px rgba(255, 255, 255, 0.9),
|
||||
inset -6px -7px 14px rgba(25, 24, 0, 0.11),
|
||||
inset 0 0 0 1px rgba(25, 24, 0, 0.05)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft recessed well under the food */}
|
||||
{/* Recessed well — ceramic shows through wedge gaps */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
className="absolute overflow-hidden rounded-full"
|
||||
style={{
|
||||
inset: rimPad * 0.55,
|
||||
inset: rimPad,
|
||||
background:
|
||||
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
|
||||
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
|
||||
"radial-gradient(circle at 50% 42%, #fbfbf7 0%, #ecece4 100%)",
|
||||
boxShadow: "inset 0 2px 10px rgba(25, 24, 0, 0.07)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={0}
|
||||
outerRadius="98%"
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
paddingAngle={5}
|
||||
cornerRadius={10}
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
onClick={(_, index) => {
|
||||
const segment = chartData[index]
|
||||
if (segment) onSelect?.(segment.id)
|
||||
}}
|
||||
>
|
||||
{chartData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.id}
|
||||
fill={entry.color}
|
||||
fillOpacity={
|
||||
selectedId && selectedId !== entry.id ? 0.72 : 0.96
|
||||
}
|
||||
className={cx(
|
||||
"outline-none transition-[opacity,filter] duration-150",
|
||||
onSelect &&
|
||||
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
|
||||
"outline-none",
|
||||
onSelect && "cursor-pointer focus:outline-none",
|
||||
)}
|
||||
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)
|
||||
}
|
||||
}}
|
||||
style={{ outline: "none" }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Rim markers */}
|
||||
{markers.map((marker) => {
|
||||
const selected = selectedId === marker.id
|
||||
return (
|
||||
<button
|
||||
key={`marker-${marker.id}`}
|
||||
type="button"
|
||||
aria-label={marker.label}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect?.(marker.id)}
|
||||
className={cx(
|
||||
"absolute z-20 -translate-x-1/2 -translate-y-1/2",
|
||||
"flex min-h-[44px] min-w-[44px] items-center justify-center",
|
||||
"no-select [-webkit-tap-highlight-color:transparent]",
|
||||
)}
|
||||
style={{ left: marker.marker.x, top: marker.marker.y }}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"elevation-transition block rounded-full",
|
||||
selected ? "h-3.5 w-3.5" : "h-2.5 w-2.5",
|
||||
)}
|
||||
style={{
|
||||
background: marker.color,
|
||||
boxShadow: selected
|
||||
? "0 0 0 3px rgba(255,255,255,0.95), 0 0 0 5px rgba(25,24,0,0.2), 0 2px 6px rgba(25,24,0,0.18)"
|
||||
: "0 0 0 2px rgba(255,255,255,0.95), 0 0 0 3px rgba(25,24,0,0.18)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Floating label pill for selection */}
|
||||
{markers.map((marker) => {
|
||||
if (selectedId !== marker.id) return null
|
||||
return (
|
||||
<div
|
||||
key={`pill-${marker.id}`}
|
||||
className={cx(
|
||||
"pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2",
|
||||
"type-caption whitespace-nowrap rounded-[var(--radius-pill)] px-3 py-1.5 font-bold text-[var(--ink)]",
|
||||
"shadow-[var(--shadow-float)] ring-1 ring-[var(--border)]",
|
||||
)}
|
||||
style={{
|
||||
left: marker.pill.x,
|
||||
top: marker.pill.y,
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
|
||||
style={{ background: marker.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
{marker.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export function Accordion({
|
||||
"active:brightness-[0.98]",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="shrink-0 text-xl leading-none">{icon}</span>
|
||||
) : null}
|
||||
{icon ? <span className="shrink-0 leading-none">{icon}</span> : null}
|
||||
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface BottomNavProps {
|
||||
|
||||
/**
|
||||
* Floating pill bottom navigation — float elevation above content cards.
|
||||
* Active state uses --ui-accent (non-semantic chrome), not category colors.
|
||||
*/
|
||||
export function BottomNav({ items, className }: BottomNavProps) {
|
||||
return (
|
||||
@@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
||||
className={cx(
|
||||
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
|
||||
item.active
|
||||
? "text-[var(--color-obstgem-dk)]"
|
||||
? "text-[var(--ui-accent)]"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
>
|
||||
@@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
||||
className={cx(
|
||||
"type-label normal-case tracking-wide",
|
||||
item.active
|
||||
? "bg-clip-text text-transparent"
|
||||
? "text-[var(--ui-accent)]"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
style={
|
||||
item.active
|
||||
? { backgroundImage: "var(--gradient-accent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
84
client/src/components/ui-pp/TimeIconBadge.tsx
Normal file
84
client/src/components/ui-pp/TimeIconBadge.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
import {
|
||||
DAYLIGHT_GRADIENT,
|
||||
daylightIconPlate,
|
||||
daylightPeriodFromHour,
|
||||
daylightPeriodFromMealTime,
|
||||
mealTimeIcon,
|
||||
periodFallbackIcon,
|
||||
type DaylightPeriod,
|
||||
} from "@/lib/daylight"
|
||||
import type { MealTime } from "@/types/domain"
|
||||
|
||||
export type { DaylightPeriod }
|
||||
|
||||
export interface TimeIconBadgeProps {
|
||||
icon?: LucideIcon
|
||||
/** Explicit period wins over hour / mealTime. */
|
||||
period?: DaylightPeriod
|
||||
/**
|
||||
* Wall-clock hour for live “now” gradient.
|
||||
* When set (without `period`), drives the sky gradient — even if `mealTime` is set for the icon.
|
||||
*/
|
||||
hour?: number
|
||||
/** Section meal slot — drives icon; drives gradient only when `hour` is omitted. */
|
||||
mealTime?: MealTime
|
||||
className?: string
|
||||
"aria-hidden"?: boolean
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded gradient badge for time-of-day / meal-section headers.
|
||||
* - Suggestion (now): pass `hour` + `mealTime` (sky from hour, icon from meal).
|
||||
* - Wochenplan sections: pass `mealTime` only (stable per section).
|
||||
*/
|
||||
export function TimeIconBadge({
|
||||
icon,
|
||||
period: periodProp,
|
||||
hour,
|
||||
mealTime,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-label": ariaLabel,
|
||||
}: TimeIconBadgeProps) {
|
||||
const period: DaylightPeriod =
|
||||
periodProp ??
|
||||
(hour !== undefined
|
||||
? daylightPeriodFromHour(hour)
|
||||
: mealTime
|
||||
? daylightPeriodFromMealTime(mealTime)
|
||||
: daylightPeriodFromHour(new Date().getHours()))
|
||||
|
||||
const Icon =
|
||||
icon ?? (mealTime ? mealTimeIcon(mealTime) : periodFallbackIcon(period))
|
||||
|
||||
const { plate, icon: iconColor } = daylightIconPlate(period)
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-control)]",
|
||||
"shadow-[var(--shadow-resting)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: DAYLIGHT_GRADIENT[period] }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px]"
|
||||
style={{ background: plate }}
|
||||
>
|
||||
<Icon
|
||||
className="h-4 w-4"
|
||||
strokeWidth={2.4}
|
||||
color={iconColor}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
62
client/src/data/howtos.json
Normal file
62
client/src/data/howtos.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": "rec_protein_shake",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "bf_skyr_oats_blueberry",
|
||||
"type": "breakfast",
|
||||
"id": "skyr_oats_blueberry",
|
||||
"type": "half",
|
||||
"times": ["breakfast"],
|
||||
"effort": "none",
|
||||
"name": {
|
||||
"de": "Skyr + Haferflocken + Blaubeeren",
|
||||
"en": "Skyr + oats + blueberries"
|
||||
@@ -9,14 +11,18 @@
|
||||
"uses": ["skyr", "oats", "blueberry"]
|
||||
},
|
||||
{
|
||||
"id": "bf_eggs_tomato",
|
||||
"type": "breakfast",
|
||||
"id": "eggs_tomato",
|
||||
"type": "half",
|
||||
"times": ["breakfast"],
|
||||
"effort": "quick",
|
||||
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
|
||||
"uses": ["egg", "tomato"]
|
||||
},
|
||||
{
|
||||
"id": "bf_quark_pomegranate",
|
||||
"type": "breakfast",
|
||||
"id": "quark_pomegranate",
|
||||
"type": "half",
|
||||
"times": ["breakfast", "snack"],
|
||||
"effort": "none",
|
||||
"name": {
|
||||
"de": "Magerquark + Granatapfel",
|
||||
"en": "Low-fat quark + pomegranate"
|
||||
@@ -24,14 +30,29 @@
|
||||
"uses": ["low_fat_quark", "pomegranate"]
|
||||
},
|
||||
{
|
||||
"id": "sn_skyr_blueberry",
|
||||
"type": "snack",
|
||||
"id": "shake_banana",
|
||||
"type": "half",
|
||||
"times": ["breakfast", "snack"],
|
||||
"effort": "none",
|
||||
"name": {
|
||||
"de": "Proteinshake + Banane",
|
||||
"en": "Protein shake + banana"
|
||||
},
|
||||
"uses": ["protein_shake", "banana"]
|
||||
},
|
||||
{
|
||||
"id": "skyr_blueberry",
|
||||
"type": "half",
|
||||
"times": ["snack"],
|
||||
"effort": "none",
|
||||
"name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
|
||||
"uses": ["skyr", "blueberry"]
|
||||
},
|
||||
{
|
||||
"id": "sn_cottage_cucumber",
|
||||
"type": "snack",
|
||||
"id": "cottage_cucumber",
|
||||
"type": "half",
|
||||
"times": ["snack"],
|
||||
"effort": "none",
|
||||
"name": {
|
||||
"de": "Hüttenkäse + Gurke",
|
||||
"en": "Cottage cheese + cucumber"
|
||||
@@ -39,122 +60,68 @@
|
||||
"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",
|
||||
"id": "protein_pudding_snack",
|
||||
"type": "half",
|
||||
"times": ["snack"],
|
||||
"effort": "none",
|
||||
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
|
||||
"uses": ["protein_pudding"]
|
||||
},
|
||||
{
|
||||
"id": "full_chicken_cooked",
|
||||
"id": "chicken_warm_plate",
|
||||
"type": "full",
|
||||
"cooked": true,
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "cook",
|
||||
"name": {
|
||||
"de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen",
|
||||
"en": "Warm chicken breast + veg + linseed oil + red lentils"
|
||||
"de": "Hühnerbrust warm mit Gemüse",
|
||||
"en": "Warm chicken breast with veg"
|
||||
},
|
||||
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
|
||||
},
|
||||
{
|
||||
"id": "full_cold_platter",
|
||||
"id": "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"
|
||||
},
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "quick",
|
||||
"name": { "de": "Kalte Brotzeit", "en": "Cold platter" },
|
||||
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
|
||||
},
|
||||
{
|
||||
"id": "full_bowl_nocook",
|
||||
"id": "tofu_bowl",
|
||||
"type": "full",
|
||||
"cooked": false,
|
||||
"name": {
|
||||
"de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)",
|
||||
"en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils"
|
||||
},
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "none",
|
||||
"name": { "de": "Räuchertofu-Bowl", "en": "Smoked tofu bowl" },
|
||||
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
|
||||
},
|
||||
{
|
||||
"id": "full_leftovers_cold",
|
||||
"id": "leftovers_plate",
|
||||
"type": "full",
|
||||
"cooked": false,
|
||||
"name": {
|
||||
"de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)",
|
||||
"en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)"
|
||||
},
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "none",
|
||||
"name": { "de": "Resteteller kalt", "en": "Cold leftovers plate" },
|
||||
"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",
|
||||
"id": "tempeh_stirfry_plate",
|
||||
"type": "full",
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "cook",
|
||||
"name": {
|
||||
"de": "Schoko-Seidentofu-Pudding",
|
||||
"en": "Chocolate silken-tofu pudding"
|
||||
"de": "Tempeh-Gemüsepfanne",
|
||||
"en": "Tempeh vegetable stir-fry"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
"uses": ["tempeh", "bell_pepper", "zucchini", "sesame_oil", "quinoa"]
|
||||
},
|
||||
{
|
||||
"id": "rec_veggie_stirfry_base",
|
||||
"type": "recipe",
|
||||
"id": "chicken_salad_plate",
|
||||
"type": "full",
|
||||
"times": ["lunch", "dinner"],
|
||||
"effort": "quick",
|
||||
"name": {
|
||||
"de": "Grundrezept: Gemüsepfanne",
|
||||
"en": "Base recipe: vegetable stir-fry"
|
||||
"de": "Hühnersalat mit Kichererbsen",
|
||||
"en": "Chicken salad with chickpeas"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"uses": ["chicken_breast", "lambs_lettuce", "avocado", "chickpeas"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"n": 2,
|
||||
"cls": "t",
|
||||
"title": {
|
||||
"de": "Obst ODER Gemüse",
|
||||
"en": "Fruit OR Vegetables"
|
||||
"de": "Obst & Gemüse",
|
||||
"en": "Fruit & Veggie"
|
||||
},
|
||||
"qty": {
|
||||
"de": "eine Portion",
|
||||
|
||||
@@ -18,76 +18,192 @@ export function tr(value: LocalizedString, lang: Language): string {
|
||||
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" },
|
||||
now: { de: "Jetzt", en: "Now" },
|
||||
kitchen: { de: "Küche", en: "Kitchen" },
|
||||
plan: { de: "Planen", en: "Plan" },
|
||||
knowledge: { de: "Wissen", en: "Knowledge" },
|
||||
},
|
||||
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" },
|
||||
},
|
||||
kitchen: {
|
||||
title: { de: "Küche", en: "Kitchen" },
|
||||
intro: {
|
||||
de: "Vorrat, Bestand und Teller bauen",
|
||||
en: "Pantry, stock and plate building",
|
||||
},
|
||||
back: { de: "Küche", en: "Kitchen" },
|
||||
pantryTitle: { de: "Vorrat", en: "Pantry" },
|
||||
pantryBody: {
|
||||
de: "Lebensmittel und Markierungen",
|
||||
en: "Foods and markers",
|
||||
},
|
||||
stockTitle: { de: "Bestand", en: "In stock" },
|
||||
stockBody: {
|
||||
de: "Was zu Hause ist",
|
||||
en: "What's at home",
|
||||
},
|
||||
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
|
||||
builderBody: {
|
||||
de: "Kombination und Regel-Check",
|
||||
en: "Combination and rule check",
|
||||
},
|
||||
},
|
||||
planHub: {
|
||||
title: { de: "Planen", en: "Plan" },
|
||||
intro: {
|
||||
de: "Woche, Einkauf und eigene Rezepte",
|
||||
en: "Week, shopping and your recipes",
|
||||
},
|
||||
back: { de: "Planen", en: "Plan" },
|
||||
weekTitle: { de: "Wochenplan", en: "Weekly plan" },
|
||||
weekBody: {
|
||||
de: "Frühstück, Mittag, Snack, Abend",
|
||||
en: "Breakfast, lunch, snack, dinner",
|
||||
},
|
||||
mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" },
|
||||
mealPlannerBody: {
|
||||
de: "Tage und Mahlzeiten zuordnen",
|
||||
en: "Assign meals to days",
|
||||
},
|
||||
shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" },
|
||||
shoppingBody: {
|
||||
de: "Abhakbar im Laden",
|
||||
en: "Checkable in the shop",
|
||||
},
|
||||
recipesTitle: { de: "Rezepte", en: "Recipes" },
|
||||
recipesBody: {
|
||||
de: "Eigene Rezepte und Import",
|
||||
en: "Your recipes and import",
|
||||
},
|
||||
},
|
||||
placeholders: {
|
||||
badge: { de: "Demnächst", en: "Coming soon" },
|
||||
stockTitle: { de: "Bestand", en: "In stock" },
|
||||
stockBody: {
|
||||
de: "Erfasst, was zu Hause ist — Grundlage für Vorschläge aus dem Vorrat.",
|
||||
en: "Tracks what's at home — basis for stock-aware suggestions.",
|
||||
},
|
||||
shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" },
|
||||
shoppingBody: {
|
||||
de: "Ausgewogene Liste nach Kategorien, abhakbar im Laden.",
|
||||
en: "Balanced list by category, checkable while shopping.",
|
||||
},
|
||||
mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" },
|
||||
mealPlannerBody: {
|
||||
de: "Mahlzeiten auf Wochentage legen — bewusst später als der Wochenplan.",
|
||||
en: "Place meals on weekdays — intentionally later than the weekly browse plan.",
|
||||
},
|
||||
recipesTitle: { de: "Rezepte", en: "Recipes" },
|
||||
recipesBody: {
|
||||
de: "Eigene Rezepte speichern. Später: Link aus Social Media teilen — Zutaten, Schritte und Bild extrahieren.",
|
||||
en: "Save your recipes. Later: share a social link — extract ingredients, steps and thumbnail.",
|
||||
},
|
||||
},
|
||||
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" },
|
||||
breakfast: { de: "Frühstück", en: "Breakfast" },
|
||||
lunch: { de: "Mittag", en: "Lunch" },
|
||||
snack: { de: "Snack", en: "Snack" },
|
||||
dinner: { de: "Abend", en: "Dinner" },
|
||||
plateFull: { de: "Ganzer Teller", en: "Full plate" },
|
||||
plateHalf: { de: "Halber Teller", en: "Half plate" },
|
||||
effortLabel: { de: "Wie viel Zeit?", en: "How much time?" },
|
||||
effortNone: { de: "Keine Zeit", en: "No time" },
|
||||
effortQuick: { de: "15 Min", en: "15 min" },
|
||||
effortCook: { de: "Kann kochen", en: "Can cook" },
|
||||
reroll: { de: "Neuer Vorschlag", en: "New suggestion" },
|
||||
emptyFilter: {
|
||||
de: "Mit dieser Zeit passt gerade nichts — Filter lockern?",
|
||||
en: "Nothing fits this time filter — loosen it?",
|
||||
},
|
||||
},
|
||||
knowledgeHub: {
|
||||
knowledge: {
|
||||
title: { de: "Wissen", en: "Knowledge" },
|
||||
intro: {
|
||||
de: "Regeln, Rezepte und Etikett-Checks",
|
||||
en: "Rules, recipes and label checks",
|
||||
},
|
||||
plateTitle: { de: "Mein Teller", en: "My Plate" },
|
||||
plateBody: {
|
||||
de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate",
|
||||
en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs",
|
||||
de: "Ganze und halbe Mahlzeit",
|
||||
en: "Full and half meals",
|
||||
},
|
||||
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
|
||||
pantryBody: {
|
||||
de: "Kategorien, Suche, Pick- & Caution-Markierungen",
|
||||
en: "Categories, search, pick & caution markers",
|
||||
recipesTitle: { de: "Rezept-Bibliothek", en: "Recipe library" },
|
||||
recipesBody: {
|
||||
de: "Zubereitung und Grundrezepte",
|
||||
en: "Prep steps and base recipes",
|
||||
},
|
||||
weekplanTitle: { de: "Wochenplan", en: "Weekly plan" },
|
||||
weekplanBody: {
|
||||
de: "Frühstück, Snacks, volle Teller & Rezepte",
|
||||
en: "Breakfast, snacks, full plates & recipes",
|
||||
rulesTitle: { de: "Daumenregeln", en: "Rules of thumb" },
|
||||
rulesBody: {
|
||||
de: "Etikett-Check pro Kategorie",
|
||||
en: "Label check per category",
|
||||
},
|
||||
settingsTitle: { de: "Einstellungen", en: "Settings" },
|
||||
settingsBody: {
|
||||
de: "Sprache und App-Optionen",
|
||||
en: "Language and app options",
|
||||
},
|
||||
back: { de: "Wissen", en: "Knowledge" },
|
||||
},
|
||||
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.",
|
||||
de: "Browsebare Inspiration für die Woche",
|
||||
en: "Browsable inspiration for the week",
|
||||
},
|
||||
breakfast: { de: "Frühstück", en: "Breakfast" },
|
||||
lunch: { de: "Mittag", en: "Lunch" },
|
||||
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" },
|
||||
dinner: { de: "Abend", en: "Dinner" },
|
||||
effortNone: { de: "ohne Kochen", en: "no cook" },
|
||||
effortQuick: { de: "15 Min", en: "15 min" },
|
||||
effortCook: { de: "kochen", en: "cook" },
|
||||
},
|
||||
ingredients: {
|
||||
showMore: { de: "Weitere Zutaten anzeigen", en: "Show more ingredients" },
|
||||
showLess: { de: "Weniger anzeigen", en: "Show less" },
|
||||
detailAria: {
|
||||
de: "Details zu {name}",
|
||||
en: "Details for {name}",
|
||||
},
|
||||
},
|
||||
howto: {
|
||||
title: { de: "Rezept-Bibliothek", en: "Recipe library" },
|
||||
intro: {
|
||||
de: "Zubereitungsanleitungen und Grundrezepte",
|
||||
en: "Prep instructions and base recipes",
|
||||
},
|
||||
uses: { de: "Zutaten", en: "Ingredients" },
|
||||
variations: { de: "Variationen", en: "Variations" },
|
||||
},
|
||||
pantry: {
|
||||
title: { de: "Vorratskammer", en: "Pantry" },
|
||||
title: { de: "Küche", en: "Kitchen" },
|
||||
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." },
|
||||
noResults: { de: "Keine Treffer", en: "No results" },
|
||||
tip: { de: "Tipp", en: "Tip" },
|
||||
per100gHint: {
|
||||
de: "Angaben pro 100 g",
|
||||
en: "Values per 100 g",
|
||||
},
|
||||
legendProtein: { de: "Protein", en: "Protein" },
|
||||
legendProduce: { de: "Obst & Gemüse", en: "Fruit & veg" },
|
||||
legendFat: { de: "Fett", en: "Fat" },
|
||||
legendBerry: { de: "Kohlenhydrate", en: "Carbs" },
|
||||
},
|
||||
rules: {
|
||||
title: { de: "Daumenregeln", en: "Rules of thumb" },
|
||||
intro: {
|
||||
de: "Etikett-Check pro Baustein-Kategorie",
|
||||
en: "Label check per building-block category",
|
||||
},
|
||||
},
|
||||
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.",
|
||||
de: "Protein, Obst & Gemüse, Fett, Kohlenhydrate",
|
||||
en: "Protein, fruit & veggie, fat, carbohydrates",
|
||||
},
|
||||
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
|
||||
segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
|
||||
@@ -114,33 +230,17 @@ export const ui = {
|
||||
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.",
|
||||
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen",
|
||||
en: "Phase 1 stub — chip selection and scoring next",
|
||||
},
|
||||
builderHandoff: {
|
||||
de: "Übernommen aus dem Vorschlag:",
|
||||
en: "Handed off from suggestion:",
|
||||
de: "Übernommen aus dem Vorschlag",
|
||||
en: "Handed off from suggestion",
|
||||
},
|
||||
builderMissing: { de: "Fehlt noch", en: "Still missing" },
|
||||
settingsTitle: { de: "Einstellungen", en: "Settings" },
|
||||
language: { de: "Sprache", en: "Language" },
|
||||
comingSoon: { de: "Demnächst", en: "Coming soon" },
|
||||
|
||||
@@ -4,14 +4,16 @@ import type {
|
||||
ExtraItem,
|
||||
FoodCategory,
|
||||
FoodItem,
|
||||
HowToRecipe,
|
||||
LocalizedString,
|
||||
PlanRecipeType,
|
||||
MealTime,
|
||||
NutritionBasis,
|
||||
Recipe,
|
||||
TagType,
|
||||
TimeSlot,
|
||||
} from "@/types/domain"
|
||||
import extrasRaw from "@/data/extras.json"
|
||||
import foodsRaw from "@/data/foods.json"
|
||||
import howtosRaw from "@/data/howtos.json"
|
||||
import recipesRaw from "@/data/recipes.json"
|
||||
import stepsRaw from "@/data/steps.json"
|
||||
|
||||
@@ -19,8 +21,13 @@ interface RawFoodItem {
|
||||
id: string
|
||||
name: LocalizedString
|
||||
v: LocalizedString
|
||||
basis?: NutritionBasis
|
||||
kcal?: number
|
||||
proteinG?: number
|
||||
f?: TagType
|
||||
approx?: boolean
|
||||
note?: LocalizedString
|
||||
cautionWhy?: LocalizedString
|
||||
}
|
||||
|
||||
interface RawFoodCategory {
|
||||
@@ -29,6 +36,10 @@ interface RawFoodCategory {
|
||||
title: LocalizedString
|
||||
thumb: LocalizedString
|
||||
items: RawFoodItem[]
|
||||
sub?: {
|
||||
title: LocalizedString
|
||||
items: RawFoodItem[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlateStep {
|
||||
@@ -39,25 +50,87 @@ export interface PlateStep {
|
||||
text: LocalizedString
|
||||
}
|
||||
|
||||
function formatProteinG(n: number, lang: "de" | "en"): string {
|
||||
if (Number.isInteger(n)) return String(n)
|
||||
return lang === "de" ? String(n).replace(".", ",") : String(n)
|
||||
}
|
||||
|
||||
/** Build display valueInfo from structured macros when present. */
|
||||
export function formatFoodValueInfo(
|
||||
item: Pick<RawFoodItem, "kcal" | "proteinG" | "approx" | "note" | "v">,
|
||||
): LocalizedString {
|
||||
if (item.kcal != null && item.proteinG != null) {
|
||||
const prefix = item.approx ? "≈" : ""
|
||||
const noteDe = item.note?.de ? ` ${item.note.de}` : ""
|
||||
const noteEn = item.note?.en ? ` ${item.note.en}` : ""
|
||||
return {
|
||||
de: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "de")} g${noteDe}`,
|
||||
en: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "en")} g${noteEn}`,
|
||||
}
|
||||
}
|
||||
return item.v
|
||||
}
|
||||
|
||||
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
|
||||
const basis: NutritionBasis =
|
||||
item.basis ??
|
||||
(cat === "snack" ? "perServing" : item.id === "egg" ? "perUnit" : "per100g")
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
cat,
|
||||
name: item.name,
|
||||
valueInfo: item.v,
|
||||
valueInfo: formatFoodValueInfo(item),
|
||||
basis,
|
||||
kcal: item.kcal,
|
||||
proteinG: item.proteinG,
|
||||
tag: item.f,
|
||||
cautionWhy: item.cautionWhy,
|
||||
isApprox: item.approx,
|
||||
}
|
||||
}
|
||||
|
||||
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
|
||||
(category) => ({
|
||||
function mapCategory(category: RawFoodCategory): FoodCategory {
|
||||
const items = [
|
||||
...category.items.map((item) => mapItem(category.id, item)),
|
||||
...(category.sub?.items.map((item) => mapItem(category.id, item)) ?? []),
|
||||
]
|
||||
return {
|
||||
id: category.id,
|
||||
cls: category.cls,
|
||||
title: category.title,
|
||||
thumb: category.thumb,
|
||||
items: category.items.map((item) => mapItem(category.id, item)),
|
||||
}),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort: protein high→low within preferred basis first, then other bases,
|
||||
* then A–Z. Preferred basis is switchable later (perServing / perUnit views).
|
||||
*/
|
||||
export function sortFoodItems(
|
||||
items: FoodItem[],
|
||||
lang: "de" | "en",
|
||||
preferredBasis: NutritionBasis = "per100g",
|
||||
): FoodItem[] {
|
||||
function rank(item: FoodItem): number {
|
||||
if (item.proteinG != null && item.basis === preferredBasis) return 0
|
||||
if (item.proteinG != null) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const rankDiff = rank(a) - rank(b)
|
||||
if (rankDiff !== 0) return rankDiff
|
||||
if (a.proteinG != null && b.proteinG != null && a.proteinG !== b.proteinG) {
|
||||
return b.proteinG - a.proteinG
|
||||
}
|
||||
return a.name[lang].localeCompare(b.name[lang], lang)
|
||||
})
|
||||
}
|
||||
|
||||
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
|
||||
mapCategory,
|
||||
)
|
||||
|
||||
export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items)
|
||||
@@ -72,21 +145,29 @@ export const extraById: Record<string, ExtraItem> = Object.fromEntries(
|
||||
extras.map((item) => [item.id, item]),
|
||||
)
|
||||
|
||||
/** Plate suggestions (RECIPES). */
|
||||
export const recipes: Recipe[] = recipesRaw as Recipe[]
|
||||
|
||||
/** Prep / how-to instructions — not plate suggestions. */
|
||||
export const howtos: HowToRecipe[] = howtosRaw as HowToRecipe[]
|
||||
|
||||
export const plateSteps: PlateStep[] = stepsRaw as PlateStep[]
|
||||
|
||||
export const PLAN_TYPE_ORDER: PlanRecipeType[] = [
|
||||
export const MEAL_TIME_ORDER: MealTime[] = [
|
||||
"breakfast",
|
||||
"lunch",
|
||||
"snack",
|
||||
"full",
|
||||
"recipe",
|
||||
"dinner",
|
||||
]
|
||||
|
||||
export function foodsInCategory(cat: CategoryId): FoodItem[] {
|
||||
return foodCategories.find((category) => category.id === cat)?.items ?? []
|
||||
}
|
||||
|
||||
export function foodCategoryById(cat: CategoryId): FoodCategory | undefined {
|
||||
return foodCategories.find((category) => category.id === cat)
|
||||
}
|
||||
|
||||
export function resolveCatalogId(id: string): CatalogItem | null {
|
||||
const food = foodById[id]
|
||||
if (food) {
|
||||
@@ -97,6 +178,7 @@ export function resolveCatalogId(id: string): CatalogItem | null {
|
||||
cat: food.cat,
|
||||
valueInfo: food.valueInfo,
|
||||
tag: food.tag,
|
||||
cautionWhy: food.cautionWhy,
|
||||
}
|
||||
}
|
||||
const extra = extraById[id]
|
||||
@@ -110,30 +192,49 @@ export function resolveCatalogId(id: string): CatalogItem | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] {
|
||||
export function resolveRecipeUses(
|
||||
recipe: Pick<Recipe, "uses"> | Pick<HowToRecipe, "uses">,
|
||||
): 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)
|
||||
export function recipesForMealTime(time: MealTime): Recipe[] {
|
||||
return recipes.filter((recipe) => recipe.times.includes(time))
|
||||
}
|
||||
|
||||
/** Map plan recipe types onto suggestion time slots. */
|
||||
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] {
|
||||
switch (recipe.type) {
|
||||
case "breakfast":
|
||||
return ["morning"]
|
||||
/** Category color token for ingredient chips / plate segments. */
|
||||
export function categoryDotColor(cat: CategoryId | undefined): string {
|
||||
switch (cat) {
|
||||
case "protein":
|
||||
case "snack":
|
||||
return ["afternoon"]
|
||||
case "full":
|
||||
return ["midday", "evening"]
|
||||
case "recipe":
|
||||
return ["morning", "afternoon", "midday", "evening"]
|
||||
return "var(--cat-protein)"
|
||||
case "obst":
|
||||
case "gemuese":
|
||||
return "var(--cat-produce)"
|
||||
case "fett":
|
||||
return "var(--cat-fat)"
|
||||
case "kh":
|
||||
return "var(--cat-berry)"
|
||||
default:
|
||||
return "var(--ink-faint)"
|
||||
}
|
||||
}
|
||||
|
||||
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" {
|
||||
return recipe.type === "full" ? "full" : "half"
|
||||
/** Four plate slots for completeness scan (snack → protein). */
|
||||
export type PlateSlot = "protein" | "produce" | "fat" | "berry"
|
||||
|
||||
export function plateSlotForCategory(cat: CategoryId): PlateSlot {
|
||||
if (cat === "protein" || cat === "snack") return "protein"
|
||||
if (cat === "obst" || cat === "gemuese") return "produce"
|
||||
if (cat === "fett") return "fat"
|
||||
return "berry"
|
||||
}
|
||||
|
||||
export const PLATE_SLOT_COLOR: Record<PlateSlot, string> = {
|
||||
protein: "var(--cat-protein)",
|
||||
produce: "var(--cat-produce)",
|
||||
fat: "var(--cat-fat)",
|
||||
berry: "var(--cat-berry)",
|
||||
}
|
||||
|
||||
54
client/src/lib/chipStyle.ts
Normal file
54
client/src/lib/chipStyle.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from "react"
|
||||
|
||||
import { categoryDotColor } from "@/lib/catalog"
|
||||
import type { CategoryId, TagType } from "@/types/domain"
|
||||
|
||||
/**
|
||||
* C1: Category = soft tint only (no solid border).
|
||||
* C2: caution = dashed warning border. No pick ring on chips.
|
||||
*/
|
||||
export function categoryChipSurface(cat: CategoryId | undefined): CSSProperties {
|
||||
if (!cat) {
|
||||
return {
|
||||
background: "var(--glass-fill)",
|
||||
borderWidth: 0,
|
||||
borderStyle: "none",
|
||||
}
|
||||
}
|
||||
|
||||
const token = categoryDotColor(cat)
|
||||
return {
|
||||
background: `color-mix(in srgb, ${token} 15%, white)`,
|
||||
borderWidth: 0,
|
||||
borderStyle: "none",
|
||||
}
|
||||
}
|
||||
|
||||
export function chipTagModifiers(tag: TagType | undefined): {
|
||||
className: string
|
||||
style: CSSProperties
|
||||
} {
|
||||
if (tag === "caution") {
|
||||
return {
|
||||
className: "",
|
||||
style: {
|
||||
borderWidth: 1.5,
|
||||
borderStyle: "dashed",
|
||||
borderColor: "var(--signal-warning)",
|
||||
},
|
||||
}
|
||||
}
|
||||
return { className: "", style: {} }
|
||||
}
|
||||
|
||||
/** Screen-reader label when pick/caution is ring/border-only. */
|
||||
export function chipAriaLabel(
|
||||
name: string,
|
||||
tag: TagType | undefined,
|
||||
pickLabel: string,
|
||||
cautionLabel: string,
|
||||
): string {
|
||||
if (tag === "pick") return `${name} — ${pickLabel}`
|
||||
if (tag === "caution") return `${name} — ${cautionLabel}`
|
||||
return name
|
||||
}
|
||||
76
client/src/lib/daylight.ts
Normal file
76
client/src/lib/daylight.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Coffee, Moon, Sparkles, Sun, Utensils } from "lucide-react"
|
||||
|
||||
import type { MealTime } from "@/types/domain"
|
||||
|
||||
export type DaylightPeriod = "morning" | "midday" | "evening" | "night"
|
||||
|
||||
export const DAYLIGHT_GRADIENT: Record<DaylightPeriod, string> = {
|
||||
/* Stops tuned so ink (morning/midday) or white (evening/night) clear AA on UI icons */
|
||||
morning: "linear-gradient(135deg, #fbbf24 0%, #fb923c 100%)",
|
||||
midday: "linear-gradient(135deg, #7dd3fc 0%, #93c5fd 50%, #fde68a 100%)",
|
||||
evening: "linear-gradient(135deg, #be123c 0%, #6b21a8 55%, #b45309 100%)",
|
||||
night: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 55%, #4c1d95 100%)",
|
||||
}
|
||||
|
||||
/** Icon sits on a frosted plate so thin strokes never land on mid-tone gradient bands. */
|
||||
export function daylightIconPlate(period: DaylightPeriod): {
|
||||
plate: string
|
||||
icon: string
|
||||
} {
|
||||
if (period === "evening" || period === "night") {
|
||||
return {
|
||||
plate: "color-mix(in srgb, #000000 40%, transparent)",
|
||||
icon: "#ffffff",
|
||||
}
|
||||
}
|
||||
return {
|
||||
plate: "color-mix(in srgb, #ffffff 88%, transparent)",
|
||||
icon: "var(--ink)",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Wall-clock daylight band (Suggestion “now”). */
|
||||
export function daylightPeriodFromHour(hour: number): DaylightPeriod {
|
||||
if (hour >= 6 && hour < 11) return "morning"
|
||||
if (hour >= 11 && hour < 16) return "midday"
|
||||
if (hour >= 16 && hour < 22) return "evening"
|
||||
return "night"
|
||||
}
|
||||
|
||||
/**
|
||||
* Section identity for Wochenplan — NOT wall clock.
|
||||
* Clock-based fill would paint all four sections identically at any moment.
|
||||
*/
|
||||
export function daylightPeriodFromMealTime(mealTime: MealTime): DaylightPeriod {
|
||||
switch (mealTime) {
|
||||
case "breakfast":
|
||||
return "morning"
|
||||
case "lunch":
|
||||
return "midday"
|
||||
case "snack":
|
||||
return "evening"
|
||||
case "dinner":
|
||||
return "night"
|
||||
}
|
||||
}
|
||||
|
||||
export function mealTimeIcon(mealTime: MealTime): LucideIcon {
|
||||
switch (mealTime) {
|
||||
case "breakfast":
|
||||
return Coffee
|
||||
case "lunch":
|
||||
return Utensils
|
||||
case "snack":
|
||||
return Sparkles
|
||||
case "dinner":
|
||||
return Moon
|
||||
}
|
||||
}
|
||||
|
||||
export function periodFallbackIcon(period: DaylightPeriod): LucideIcon {
|
||||
if (period === "night" || period === "evening") return Moon
|
||||
if (period === "morning") return Coffee
|
||||
return Sun
|
||||
}
|
||||
10
client/src/lib/signals.ts
Normal file
10
client/src/lib/signals.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import type { Language, TagType } from "@/types/domain"
|
||||
|
||||
export function signalGlyphChar(tag: TagType): string {
|
||||
return tag === "pick" ? "★" : "!"
|
||||
}
|
||||
|
||||
export function signalGlyphLabel(tag: TagType, lang: Language): string {
|
||||
return tag === "pick" ? t(ui.pantry.pick, lang) : t(ui.pantry.caution, lang)
|
||||
}
|
||||
@@ -1,103 +1,32 @@
|
||||
import { foodById, foods, recipes, timeSlotsForRecipe } from "@/lib/catalog"
|
||||
import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
||||
import { recipesForMealTime } from "@/lib/catalog"
|
||||
import type { Effort, MealTime, Recipe } 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[]): T | null {
|
||||
if (items.length === 0) return null
|
||||
return items[Math.floor(Math.random() * items.length)] ?? null
|
||||
}
|
||||
|
||||
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 }
|
||||
export function filterRecipes(
|
||||
mealTime: MealTime,
|
||||
effort: Effort | null,
|
||||
): Recipe[] {
|
||||
const byTime = recipesForMealTime(mealTime)
|
||||
if (!effort) return byTime
|
||||
return byTime.filter((recipe) => recipe.effort === effort)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace unpinned ingredients with alternatives from the same category.
|
||||
* Pinned IDs stay in place; missing categories are filled when possible.
|
||||
* Pick a plate suggestion for the current meal time.
|
||||
* When `avoidId` is set, prefer a different recipe (falls back if only one match).
|
||||
*/
|
||||
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))
|
||||
export function pickSuggestion(
|
||||
mealTime: MealTime,
|
||||
effort: Effort | null,
|
||||
avoidId?: string,
|
||||
): Recipe | null {
|
||||
const pool = filterRecipes(mealTime, effort)
|
||||
if (pool.length === 0) return null
|
||||
|
||||
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
|
||||
const others = avoidId ? pool.filter((recipe) => recipe.id !== avoidId) : pool
|
||||
return pickRandom(others.length > 0 ? others : pool)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import type { MealType, TimeSlot } from "@/types/domain"
|
||||
import type { MealTime, MealType } from "@/types/domain"
|
||||
|
||||
export interface TimeContext {
|
||||
slot: TimeSlot
|
||||
mealTime: MealTime
|
||||
mealType: MealType
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
|
||||
/** Local-time meal context for the suggestion engine. */
|
||||
/**
|
||||
* Local-time meal context for the suggestion engine.
|
||||
* until 10:30 → breakfast · until 14:30 → lunch · until 17:30 → snack · else dinner
|
||||
*/
|
||||
export function getTimeContext(date = new Date()): TimeContext {
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const minutes = hour * 60 + minute
|
||||
|
||||
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 }
|
||||
}
|
||||
let mealTime: MealTime
|
||||
if (minutes < 10 * 60 + 30) mealTime = "breakfast"
|
||||
else if (minutes < 14 * 60 + 30) mealTime = "lunch"
|
||||
else if (minutes < 17 * 60 + 30) mealTime = "snack"
|
||||
else mealTime = "dinner"
|
||||
|
||||
// Late night / early morning: lean snack context
|
||||
return { slot: "afternoon", mealType: "half", hour, minute }
|
||||
const mealType: MealType =
|
||||
mealTime === "breakfast" || mealTime === "snack" ? "half" : "full"
|
||||
|
||||
return { mealTime, mealType, hour, minute }
|
||||
}
|
||||
|
||||
export function formatClock(hour: number, minute: number, lang: "de" | "en"): string {
|
||||
export function formatClock(hour: number, minute: number): string {
|
||||
const hh = String(hour).padStart(2, "0")
|
||||
const mm = String(minute).padStart(2, "0")
|
||||
return lang === "de" ? `${hh}:${mm}` : `${hh}:${mm}`
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,19 +1,63 @@
|
||||
import { useMemo } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
|
||||
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||
import { KitchenBackLink } from "@/components/home/NavCard"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodById } from "@/lib/catalog"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import {
|
||||
PLATE_SLOT_COLOR,
|
||||
plateSlotForCategory,
|
||||
resolveCatalogId,
|
||||
type PlateSlot,
|
||||
} from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import type { BuilderHandoffState } from "@/types/domain"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { BuilderHandoffState, CatalogItem } from "@/types/domain"
|
||||
|
||||
const SLOT_ORDER: PlateSlot[] = ["protein", "produce", "fat", "berry"]
|
||||
|
||||
const SLOT_LABEL = {
|
||||
protein: ui.pantry.legendProtein,
|
||||
produce: ui.pantry.legendProduce,
|
||||
fat: ui.pantry.legendFat,
|
||||
berry: ui.pantry.legendBerry,
|
||||
} as const
|
||||
|
||||
export function BuilderScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const location = useLocation()
|
||||
const handoff = location.state as BuilderHandoffState | null
|
||||
const ingredientIds = handoff?.ingredientIds ?? []
|
||||
const ingredientIds = useMemo(
|
||||
() => handoff?.ingredientIds ?? [],
|
||||
[handoff],
|
||||
)
|
||||
|
||||
const items: CatalogItem[] = useMemo(
|
||||
() =>
|
||||
ingredientIds
|
||||
.map((id) => resolveCatalogId(id))
|
||||
.filter((item): item is CatalogItem => Boolean(item)),
|
||||
[ingredientIds],
|
||||
)
|
||||
|
||||
const bySlot = useMemo(() => {
|
||||
const map: Record<PlateSlot, CatalogItem[]> = {
|
||||
protein: [],
|
||||
produce: [],
|
||||
fat: [],
|
||||
berry: [],
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!item.cat) continue
|
||||
map[plateSlotForCategory(item.cat)].push(item)
|
||||
}
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<KitchenBackLink />
|
||||
<GlassCard className="p-4">
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
@@ -26,27 +70,48 @@ export function BuilderScreen() {
|
||||
</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>
|
||||
<div className="flex flex-col gap-3">
|
||||
{SLOT_ORDER.map((slot) => {
|
||||
const slotItems = bySlot[slot]
|
||||
const filled = slotItems.length > 0
|
||||
return (
|
||||
<div
|
||||
key={slot}
|
||||
className={cx(
|
||||
"rounded-[var(--radius-card)] p-3",
|
||||
filled
|
||||
? "ring-1 ring-[var(--border)]"
|
||||
: "border border-dashed",
|
||||
)}
|
||||
style={{
|
||||
background: filled ? "var(--glass-fill)" : "transparent",
|
||||
borderColor: filled ? undefined : PLATE_SLOT_COLOR[slot],
|
||||
}}
|
||||
>
|
||||
<p className="type-label mb-2 flex items-center gap-2 text-[var(--ink-faint)]">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 rounded-full ring-1 ring-[var(--ink)]/20"
|
||||
style={{ background: PLATE_SLOT_COLOR[slot] }}
|
||||
/>
|
||||
{t(SLOT_LABEL[slot], lang)}
|
||||
</p>
|
||||
{filled ? (
|
||||
<IngredientChips items={slotItems} />
|
||||
) : (
|
||||
<p className="type-caption text-[var(--ink-faint)]">
|
||||
{t(ui.stubs.builderMissing, lang)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<p className="type-caption text-[var(--ink-faint)]">
|
||||
{t(ui.stubs.builderHandoff, lang)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
73
client/src/screens/HowToLibraryScreen.tsx
Normal file
73
client/src/screens/HowToLibraryScreen.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { KnowledgeBackLink } from "@/components/home/NavCard"
|
||||
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||
import { NestedDisclosure } from "@/components/ui-pp/Accordion"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { howtos, resolveRecipeUses } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import type { HowToRecipe } from "@/types/domain"
|
||||
|
||||
function HowToBody({ recipe, lang }: { recipe: HowToRecipe; lang: "de" | "en" }) {
|
||||
const ingredients = resolveRecipeUses(recipe)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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.howto.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.howto.uses, lang)}
|
||||
</p>
|
||||
<IngredientChips items={ingredients} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HowToLibraryScreen() {
|
||||
const { lang } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<KnowledgeBackLink />
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.howto.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.howto.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{howtos.map((recipe) => (
|
||||
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
|
||||
<HowToBody recipe={recipe} lang={lang} />
|
||||
</NestedDisclosure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
client/src/screens/KitchenHubScreen.tsx
Normal file
53
client/src/screens/KitchenHubScreen.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Blocks, Package, ShoppingBasket } from "lucide-react"
|
||||
|
||||
import { NavCard } from "@/components/home/NavCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
export function KitchenHubScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const soon = t(ui.placeholders.badge, lang)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.kitchen.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.kitchen.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<NavCard
|
||||
title={t(ui.kitchen.pantryTitle, lang)}
|
||||
body={t(ui.kitchen.pantryBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={
|
||||
<ShoppingBasket className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
}
|
||||
onNavigate={() => navigate("/kitchen/pantry")}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.kitchen.stockTitle, lang)}
|
||||
body={t(ui.kitchen.stockBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
badge={soon}
|
||||
disabled
|
||||
icon={<Package className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.kitchen.builderTitle, lang)}
|
||||
body={t(ui.kitchen.builderBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
badge={soon}
|
||||
disabled
|
||||
icon={<Blocks className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
client/src/screens/KnowledgeHubScreen.tsx
Normal file
55
client/src/screens/KnowledgeHubScreen.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { BookOpen, ChefHat, Settings2, Utensils } from "lucide-react"
|
||||
|
||||
import { NavCard } from "@/components/home/NavCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
export function KnowledgeHubScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.knowledge.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.knowledge.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<NavCard
|
||||
title={t(ui.knowledge.plateTitle, lang)}
|
||||
body={t(ui.knowledge.plateBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<Utensils className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge/plate")}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.knowledge.recipesTitle, lang)}
|
||||
body={t(ui.knowledge.recipesBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
icon={<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge/recipes")}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.knowledge.rulesTitle, lang)}
|
||||
body={t(ui.knowledge.rulesBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge/rules")}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.knowledge.settingsTitle, lang)}
|
||||
body={t(ui.knowledge.settingsBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
icon={<Settings2 className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge/settings")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { CategorySoftChip } from "@/components/food/CategorySoftChip"
|
||||
import { KitchenBackLink } from "@/components/home/NavCard"
|
||||
import { Accordion } from "@/components/ui-pp/Accordion"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodCategories } from "@/lib/catalog"
|
||||
import { foodCategories, sortFoodItems } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import { signalGlyphChar, signalGlyphLabel } from "@/lib/signals"
|
||||
import { DEFAULT_NUTRITION_BASIS } from "@/types/domain"
|
||||
import type { FoodItem } from "@/types/domain"
|
||||
|
||||
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
|
||||
@@ -16,29 +20,49 @@ export function PantryScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [query, setQuery] = useState("")
|
||||
const normalized = query.trim().toLowerCase()
|
||||
/** Switch later when UI offers perServing / perUnit views. */
|
||||
const displayBasis = DEFAULT_NUTRITION_BASIS
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
foodCategories
|
||||
.map((category) => {
|
||||
const items = category.items.filter((item) => matchesQuery(item, normalized, lang))
|
||||
const items = sortFoodItems(
|
||||
category.items.filter((item) =>
|
||||
matchesQuery(item, normalized, lang),
|
||||
),
|
||||
lang,
|
||||
displayBasis,
|
||||
)
|
||||
return { category, items }
|
||||
})
|
||||
.filter((section) => section.items.length > 0),
|
||||
[lang, normalized],
|
||||
[lang, normalized, displayBasis],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<KitchenBackLink />
|
||||
<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)}
|
||||
<p className="type-caption mt-3 text-[var(--ink-soft)]">
|
||||
<span className="font-bold text-[var(--signal-success)]" aria-hidden>
|
||||
★
|
||||
</span>{" "}
|
||||
{t(ui.pantry.pick, lang)}
|
||||
{" · "}
|
||||
<span className="text-[var(--color-caution)]">●</span> {t(ui.pantry.caution, lang)}
|
||||
<span className="font-bold text-[var(--signal-warning)]" aria-hidden>
|
||||
!
|
||||
</span>{" "}
|
||||
{t(ui.pantry.caution, lang)}
|
||||
</p>
|
||||
{displayBasis === "per100g" ? (
|
||||
<p className="type-caption mt-1 text-[var(--ink-faint)]">
|
||||
{t(ui.pantry.per100gHint, lang)}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<label className="sr-only" htmlFor="pantry-search">
|
||||
@@ -53,13 +77,15 @@ export function PantryScreen() {
|
||||
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",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--ui-accent)]/30",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
/>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<p className="type-body text-[var(--ink-soft)]">{t(ui.pantry.noResults, lang)}</p>
|
||||
<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 }) => (
|
||||
@@ -68,21 +94,7 @@ export function PantryScreen() {
|
||||
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>
|
||||
}
|
||||
icon={<CategorySoftChip categoryId={category.id} />}
|
||||
>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
@@ -90,31 +102,51 @@ export function PantryScreen() {
|
||||
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)]">
|
||||
<span className="type-body-emphasis min-w-0 truncate text-[var(--ink)]">
|
||||
{tr(item.name, lang)}
|
||||
</span>
|
||||
<span
|
||||
className={cx(
|
||||
"type-caption shrink-0 text-right text-[var(--ink-soft)]",
|
||||
"type-caption inline-flex shrink-0 items-center gap-1.5 text-right text-[var(--ink-soft)]",
|
||||
item.isApprox && "italic",
|
||||
)}
|
||||
>
|
||||
{item.tag === "pick" ? (
|
||||
<span className="mr-1 text-[var(--color-pick)]">●</span>
|
||||
<span>{tr(item.valueInfo, lang)}</span>
|
||||
{item.tag ? (
|
||||
<span
|
||||
className={cx(
|
||||
"font-bold",
|
||||
item.tag === "pick"
|
||||
? "text-[var(--signal-success)]"
|
||||
: "text-[var(--signal-warning)]",
|
||||
)}
|
||||
aria-label={
|
||||
item.tag === "caution" && item.cautionWhy
|
||||
? `${signalGlyphLabel(item.tag, lang)}: ${tr(item.cautionWhy, lang)}`
|
||||
: signalGlyphLabel(item.tag, lang)
|
||||
}
|
||||
title={
|
||||
item.tag === "caution" && item.cautionWhy
|
||||
? tr(item.cautionWhy, lang)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{signalGlyphChar(item.tag)}
|
||||
</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>
|
||||
{category.id !== "obst" ? (
|
||||
<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>
|
||||
) : null}
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
|
||||
41
client/src/screens/PlaceholderScreen.tsx
Normal file
41
client/src/screens/PlaceholderScreen.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { HubBackLink } from "@/components/home/NavCard"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import type { LocalizedString } from "@/types/domain"
|
||||
|
||||
export function PlaceholderScreen({
|
||||
backTo,
|
||||
backLabel,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
backTo: string
|
||||
backLabel: LocalizedString
|
||||
title: LocalizedString
|
||||
body: LocalizedString
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<HubBackLink to={backTo} label={backLabel} />
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.placeholders.badge, lang)}
|
||||
</p>
|
||||
<h1 className="type-display mt-1 text-[var(--ink)]">{t(title, lang)}</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">{t(body, lang)}</p>
|
||||
</header>
|
||||
|
||||
<GlassCard className="p-5">
|
||||
<p className="type-body text-[var(--ink-soft)]">
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
</p>
|
||||
<p className="type-caption mt-3 text-[var(--ink-faint)]">
|
||||
Pocket Pascal · A+B Hub
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
client/src/screens/PlanHubScreen.tsx
Normal file
67
client/src/screens/PlanHubScreen.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { BookMarked, CalendarDays, CalendarRange, ShoppingCart } from "lucide-react"
|
||||
|
||||
import { NavCard } from "@/components/home/NavCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
export function PlanHubScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const soon = t(ui.placeholders.badge, lang)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.planHub.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.planHub.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<NavCard
|
||||
title={t(ui.planHub.weekTitle, lang)}
|
||||
body={t(ui.planHub.weekBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={
|
||||
<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
}
|
||||
onNavigate={() => navigate("/plan/week")}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.planHub.mealPlannerTitle, lang)}
|
||||
body={t(ui.planHub.mealPlannerBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
badge={soon}
|
||||
disabled
|
||||
icon={
|
||||
<CalendarRange className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.planHub.shoppingTitle, lang)}
|
||||
body={t(ui.planHub.shoppingBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
badge={soon}
|
||||
disabled
|
||||
icon={
|
||||
<ShoppingCart className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
}
|
||||
/>
|
||||
<NavCard
|
||||
title={t(ui.planHub.recipesTitle, lang)}
|
||||
body={t(ui.planHub.recipesBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
badge={soon}
|
||||
disabled
|
||||
icon={
|
||||
<BookMarked className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import { KnowledgeBackLink } from "@/components/home/NavCard"
|
||||
import { DiningPlate } from "@/components/plate/DiningPlate"
|
||||
import { SegControl } from "@/components/ui-pp/SegControl"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
@@ -11,10 +12,10 @@ 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)",
|
||||
g: "var(--cat-protein)",
|
||||
t: "var(--cat-produce)",
|
||||
o: "var(--cat-fat)",
|
||||
s: "var(--cat-berry)",
|
||||
}
|
||||
|
||||
const FULL_WEIGHT: Record<string, number> = {
|
||||
@@ -81,10 +82,11 @@ function StepDetailCard({
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full type-button text-white"
|
||||
className="type-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
color: step.cls === "o" ? "var(--ink)" : "#fff",
|
||||
color:
|
||||
step.cls === "o" ? "var(--cat-fat-fg)" : "var(--cat-on-dark)",
|
||||
}}
|
||||
>
|
||||
{step.n}
|
||||
@@ -123,6 +125,7 @@ export function PlateScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [mode, setMode] = useState<MealMode>("full")
|
||||
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
|
||||
const [selectedId, setSelectedId] = useState<number>(1)
|
||||
const [highlightId, setHighlightId] = useState<number | null>(null)
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
@@ -152,7 +155,6 @@ export function PlateScreen() {
|
||||
}, [])
|
||||
|
||||
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) {
|
||||
@@ -161,9 +163,12 @@ export function PlateScreen() {
|
||||
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
|
||||
return next
|
||||
})
|
||||
}, [visibleSteps])
|
||||
const stillVisible = visibleSteps.some((step) => step.n === selectedId)
|
||||
if (!stillVisible && visibleSteps[0]) setSelectedId(visibleSteps[0].n)
|
||||
}, [visibleSteps, selectedId])
|
||||
|
||||
function focusStep(n: number) {
|
||||
setSelectedId(n)
|
||||
setOpenIds((current) => new Set(current).add(n))
|
||||
setHighlightId(n)
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current)
|
||||
@@ -177,6 +182,7 @@ export function PlateScreen() {
|
||||
}
|
||||
|
||||
function toggleStep(n: number) {
|
||||
setSelectedId(n)
|
||||
setOpenIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(n)) next.delete(n)
|
||||
@@ -190,6 +196,7 @@ export function PlateScreen() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<KnowledgeBackLink />
|
||||
<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)}
|
||||
@@ -206,47 +213,20 @@ export function PlateScreen() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
<div className="flex flex-col items-center gap-6 overflow-visible px-2 py-4">
|
||||
<DiningPlate
|
||||
key={mode}
|
||||
size={280}
|
||||
size={240}
|
||||
segments={segments}
|
||||
selectedId={String(selectedId)}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div key={step.n}>
|
||||
<StepDetailCard
|
||||
step={step}
|
||||
open={openIds.has(step.n)}
|
||||
@@ -258,7 +238,8 @@ export function PlateScreen() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-2 rounded-[var(--radius-card)] p-4 ring-1 ring-[var(--border)]"
|
||||
<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)]">
|
||||
|
||||
45
client/src/screens/RulesScreen.tsx
Normal file
45
client/src/screens/RulesScreen.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { KnowledgeBackLink } from "@/components/home/NavCard"
|
||||
import { Accordion } from "@/components/ui-pp/Accordion"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodCategories } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
export function RulesScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [openIds, setOpenIds] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(foodCategories.map((c) => [c.id, c.id === "protein"])),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<KnowledgeBackLink />
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.rules.title, lang)}
|
||||
</h1>
|
||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||
{t(ui.rules.intro, lang)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{foodCategories.map((category) => (
|
||||
<Accordion
|
||||
key={category.id}
|
||||
open={Boolean(openIds[category.id])}
|
||||
onOpenChange={(open) =>
|
||||
setOpenIds((current) => ({ ...current, [category.id]: open }))
|
||||
}
|
||||
title={tr(category.title, lang)}
|
||||
>
|
||||
<p className="type-body text-[var(--ink-soft)]">
|
||||
{tr(category.thumb, lang)}
|
||||
</p>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { KnowledgeBackLink } from "@/components/home/NavCard"
|
||||
import { Button } from "@/components/ui-pp/Button"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
@@ -14,45 +15,48 @@ 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="flex flex-col gap-4">
|
||||
<KnowledgeBackLink />
|
||||
<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 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>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" className="w-full" disabled>
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" className="w-full" disabled>
|
||||
{t(ui.stubs.comingSoon, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,99 +1,86 @@
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { Accordion, NestedDisclosure } from "@/components/ui-pp/Accordion"
|
||||
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||
import { PlanBackLink } from "@/components/home/NavCard"
|
||||
import { Accordion } from "@/components/ui-pp/Accordion"
|
||||
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { PLAN_TYPE_ORDER, recipesByType, resolveRecipeUses } from "@/lib/catalog"
|
||||
import { MEAL_TIME_ORDER, recipesForMealTime, resolveRecipeUses } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { PlanRecipeType, Recipe } from "@/types/domain"
|
||||
import type { Effort, MealTime, Recipe } from "@/types/domain"
|
||||
|
||||
const TYPE_ICON: Record<PlanRecipeType, string> = {
|
||||
breakfast: "🌅",
|
||||
snack: "🥤",
|
||||
full: "🍽️",
|
||||
recipe: "📖",
|
||||
function effortLabel(effort: Effort, lang: "de" | "en"): string {
|
||||
if (effort === "none") return t(ui.weekplan.effortNone, lang)
|
||||
if (effort === "quick") return t(ui.weekplan.effortQuick, lang)
|
||||
return t(ui.weekplan.effortCook, lang)
|
||||
}
|
||||
|
||||
function typeLabel(type: PlanRecipeType, lang: "de" | "en"): string {
|
||||
return t(ui.weekplan[type], lang)
|
||||
/** Show when cooking, or when a concrete duration is 15+ minutes. */
|
||||
function shouldShowEffortCaption(
|
||||
effort: Effort,
|
||||
durationMinutes?: number,
|
||||
): boolean {
|
||||
if (effort === "cook") return true
|
||||
if (durationMinutes != null && durationMinutes >= 15) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function RecipeBody({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
|
||||
function EffortCaption({
|
||||
effort,
|
||||
lang,
|
||||
}: {
|
||||
effort: Effort
|
||||
lang: "de" | "en"
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cx(
|
||||
"type-caption shrink-0 text-right",
|
||||
effort === "none" && "text-[var(--ink-faint)]",
|
||||
effort === "quick" && "text-[var(--ink-soft)]",
|
||||
effort === "cook" && "font-bold text-[var(--signal-warning)]",
|
||||
)}
|
||||
>
|
||||
{effortLabel(effort, lang)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipeRow({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
|
||||
const ingredients = resolveRecipeUses(recipe)
|
||||
const showEffort = shouldShowEffortCaption(recipe.effort)
|
||||
|
||||
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>
|
||||
<article className="border-b border-[var(--border)] py-3 last:border-0 last:pb-0 first:pt-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="type-title-sm min-w-0 text-[var(--ink)]">
|
||||
{tr(recipe.name, lang)}
|
||||
</h3>
|
||||
{showEffort ? <EffortCaption effort={recipe.effort} lang={lang} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<IngredientChips
|
||||
className="mt-2.5"
|
||||
items={ingredients}
|
||||
maxVisible={4}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekPlanScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [openTypes, setOpenTypes] = useState<Record<PlanRecipeType, boolean>>({
|
||||
breakfast: true,
|
||||
snack: false,
|
||||
full: false,
|
||||
recipe: false,
|
||||
})
|
||||
const [openTimes, setOpenTimes] = useState<Record<MealTime, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
MEAL_TIME_ORDER.map((time) => [time, time === "breakfast"]),
|
||||
) as Record<MealTime, boolean>,
|
||||
)
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
PLAN_TYPE_ORDER.map((type) => ({
|
||||
type,
|
||||
items: recipesByType(type),
|
||||
MEAL_TIME_ORDER.map((time) => ({
|
||||
time,
|
||||
items: recipesForMealTime(time),
|
||||
})),
|
||||
[],
|
||||
)
|
||||
@@ -101,6 +88,7 @@ export function WeekPlanScreen() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<PlanBackLink />
|
||||
<h1 className="type-display text-[var(--ink)]">
|
||||
{t(ui.weekplan.title, lang)}
|
||||
</h1>
|
||||
@@ -110,22 +98,25 @@ export function WeekPlanScreen() {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map(({ type, items }) => (
|
||||
{groups.map(({ time, items }) => (
|
||||
<Accordion
|
||||
key={type}
|
||||
open={openTypes[type]}
|
||||
key={time}
|
||||
open={openTimes[time]}
|
||||
onOpenChange={(open) =>
|
||||
setOpenTypes((current) => ({ ...current, [type]: open }))
|
||||
setOpenTimes((current) => ({ ...current, [time]: open }))
|
||||
}
|
||||
title={typeLabel(type, lang)}
|
||||
title={t(ui.weekplan[time], lang)}
|
||||
meta={items.length}
|
||||
icon={<span aria-hidden>{TYPE_ICON[type]}</span>}
|
||||
icon={
|
||||
<TimeIconBadge
|
||||
mealTime={time}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{items.map((recipe) => (
|
||||
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
|
||||
<RecipeBody recipe={recipe} lang={lang} />
|
||||
</NestedDisclosure>
|
||||
<RecipeRow key={recipe.id} recipe={recipe} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
* Category palette (locked): Emerald Bridge + Neon Punch fat
|
||||
* --cat-protein #2563EB Blau
|
||||
* --cat-produce #059669 Emerald (Obst + Gemüse)
|
||||
* --cat-fat #EAB308 Gold (from Neon Punch)
|
||||
* --cat-berry #C0267A Berry (Kohlenhydrate)
|
||||
*
|
||||
* Toggle dark mode by adding/removing the `dark` class on <html>
|
||||
* (matches Tailwind `darkMode: 'class'` — see README).
|
||||
* Signals (glyph-primary for pick/caution; color redundant):
|
||||
* --signal-success / --signal-error / --signal-warning
|
||||
*
|
||||
* 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.
|
||||
* --ui-accent is non-semantic (chrome only — tabs, focus), not a plate category.
|
||||
*/
|
||||
|
||||
: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 scale ---------- */
|
||||
--radius-card: 24px;
|
||||
--radius-nested: 12px;
|
||||
--radius-control: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* ---------- Shadows (resting → elevated → float; warm ink-tinted ambient) ---------- */
|
||||
/* ---------- Shadows ---------- */
|
||||
--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 ---------- */
|
||||
@@ -45,43 +39,65 @@
|
||||
--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;
|
||||
/* ---------- Canonical category palette ---------- */
|
||||
--cat-protein: #2563eb;
|
||||
--cat-produce: #059669;
|
||||
--cat-fat: #eab308;
|
||||
--cat-berry: #c0267a;
|
||||
/* Text on category fills: gold needs dark ink; others use white */
|
||||
--cat-fat-fg: #191800;
|
||||
--cat-on-dark: #ffffff;
|
||||
|
||||
/* ---------- 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;
|
||||
/* ---------- Signals (not categories) ---------- */
|
||||
--signal-success: #0f7a37;
|
||||
--signal-error: #c81e1e;
|
||||
--signal-warning: #ea580c;
|
||||
|
||||
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */
|
||||
/* ---------- Non-semantic UI chrome ---------- */
|
||||
--ui-accent: #191800;
|
||||
--ui-accent-soft: #5c5b48;
|
||||
|
||||
/* ---------- Legacy aliases → canonical (safe during migration) ---------- */
|
||||
--color-protein: var(--cat-protein);
|
||||
--color-protein-dk: #1e3a8a;
|
||||
--color-obstgem: var(--cat-produce);
|
||||
--color-obstgem-dk: #065f46;
|
||||
--color-fett: var(--cat-fat);
|
||||
--color-fett-dk: #a16207;
|
||||
--color-kh: var(--cat-berry);
|
||||
--color-kh-dk: #9d174d;
|
||||
--color-pick: var(--signal-success);
|
||||
--color-caution: var(--signal-error);
|
||||
|
||||
--plate-protein: var(--cat-protein);
|
||||
--plate-veg: var(--cat-produce);
|
||||
--plate-fat: var(--cat-fat);
|
||||
--plate-carb: var(--cat-berry);
|
||||
--plate-protein-soft: #dbeafe;
|
||||
--plate-veg-soft: #d1fae5;
|
||||
--plate-fat-soft: #fef9c3;
|
||||
--plate-carb-soft: #fce7f3;
|
||||
|
||||
--green: var(--cat-produce);
|
||||
--green-soft: var(--plate-veg-soft);
|
||||
--terracotta: var(--cat-berry);
|
||||
--terracotta-soft: var(--plate-carb-soft);
|
||||
--gold: var(--cat-fat);
|
||||
--gold-soft: var(--plate-fat-soft);
|
||||
--slate: var(--cat-protein);
|
||||
--slate-soft: var(--plate-protein-soft);
|
||||
|
||||
/* ---------- Surfaces ---------- */
|
||||
--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. */
|
||||
/* Decorative UI gradients — lime-vibrant brand chrome (NOT category encoding).
|
||||
Paired with --ink icons/text for WCAG AA on these light stops. */
|
||||
--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;
|
||||
@@ -105,17 +121,22 @@
|
||||
--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;
|
||||
--cat-protein: #2563eb;
|
||||
--cat-produce: #059669;
|
||||
--cat-fat: #eab308;
|
||||
--cat-berry: #c0267a;
|
||||
--cat-fat-fg: #191800;
|
||||
--cat-on-dark: #ffffff;
|
||||
|
||||
--signal-success: #4ade80;
|
||||
--signal-error: #ff6b60;
|
||||
--signal-warning: #fb923c;
|
||||
|
||||
--ui-accent: #f3f2e6;
|
||||
--ui-accent-soft: #c2c1ac;
|
||||
|
||||
--color-pick: var(--signal-success);
|
||||
--color-caution: var(--signal-error);
|
||||
|
||||
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
|
||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
|
||||
@@ -132,9 +153,6 @@
|
||||
}
|
||||
|
||||
/* ---------- 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;
|
||||
@@ -179,31 +197,30 @@
|
||||
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 */
|
||||
font-size: 1.5rem;
|
||||
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 */
|
||||
font-size: 1.25rem;
|
||||
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 */
|
||||
font-size: 1rem;
|
||||
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 */
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.type-body-emphasis {
|
||||
@@ -215,7 +232,7 @@
|
||||
.type-label {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -223,7 +240,7 @@
|
||||
.type-caption {
|
||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem; /* 12px */
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.type-button {
|
||||
@@ -234,31 +251,24 @@
|
||||
}
|
||||
.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-weight: 500;
|
||||
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;
|
||||
|
||||
@@ -15,22 +15,36 @@ export type CategoryId =
|
||||
|
||||
export type TagType = "pick" | "caution"
|
||||
|
||||
/** Plate size per building-block rule. */
|
||||
export type MealType = "full" | "half"
|
||||
|
||||
export type TimeSlot = "morning" | "midday" | "afternoon" | "evening"
|
||||
/** Day-part for plate suggestions (RECIPES.times). */
|
||||
export type MealTime = "breakfast" | "lunch" | "snack" | "dinner"
|
||||
|
||||
export type PlanRecipeType = "breakfast" | "snack" | "full" | "recipe"
|
||||
export type Effort = "none" | "quick" | "cook"
|
||||
|
||||
export type HomeWidgetId = "suggestion" | "knowledge" | "builder"
|
||||
/** How kcal / protein on a food record are measured. */
|
||||
export type NutritionBasis = "per100g" | "perServing" | "perUnit"
|
||||
|
||||
export type WidgetOrder = HomeWidgetId[]
|
||||
/**
|
||||
* Default display / sort basis for pantry lists.
|
||||
* Switch later when the UI offers perServing / perUnit views.
|
||||
*/
|
||||
export const DEFAULT_NUTRITION_BASIS: NutritionBasis = "per100g"
|
||||
|
||||
export interface FoodItem {
|
||||
id: string
|
||||
cat: CategoryId
|
||||
name: LocalizedString
|
||||
/** Display string (macros or qualitative e.g. vitamins). */
|
||||
valueInfo: LocalizedString
|
||||
/** Measurement basis for kcal / proteinG. */
|
||||
basis: NutritionBasis
|
||||
kcal?: number
|
||||
proteinG?: number
|
||||
tag?: TagType
|
||||
/** Required when tag is caution — why enjoy mindfully. */
|
||||
cautionWhy?: LocalizedString
|
||||
isApprox?: boolean
|
||||
}
|
||||
|
||||
@@ -56,20 +70,31 @@ export interface CatalogItem {
|
||||
cat?: CategoryId
|
||||
valueInfo?: LocalizedString
|
||||
tag?: TagType
|
||||
cautionWhy?: LocalizedString
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated meal / snack / how-to recipes.
|
||||
* Curated plate suggestion (half / full meal).
|
||||
* `uses` references ids from CATS (foods) or EXTRAS.
|
||||
*/
|
||||
export interface Recipe {
|
||||
id: string
|
||||
type: PlanRecipeType
|
||||
type: MealType
|
||||
times: MealTime[]
|
||||
effort: Effort
|
||||
name: LocalizedString
|
||||
uses: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prep / how-to instruction — not a plate suggestion.
|
||||
* Kept separate from RECIPES.
|
||||
*/
|
||||
export interface HowToRecipe {
|
||||
id: string
|
||||
name: LocalizedString
|
||||
uses: string[]
|
||||
cooked?: boolean
|
||||
text?: LocalizedString
|
||||
/** Optional flavor / protein twists shown under the base recipe text. */
|
||||
variations?: LocalizedString[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user