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": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"shadcn": "^4.16.1",
|
"shadcn": "^4.16.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import { HomeScreen } from "@/components/home/HomeScreen"
|
|||||||
import { AppShell } from "@/components/layout/AppShell"
|
import { AppShell } from "@/components/layout/AppShell"
|
||||||
import { LanguageProvider } from "@/lib/language"
|
import { LanguageProvider } from "@/lib/language"
|
||||||
import { BuilderScreen } from "@/screens/BuilderScreen"
|
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 { PantryScreen } from "@/screens/PantryScreen"
|
||||||
|
import { PlaceholderScreen } from "@/screens/PlaceholderScreen"
|
||||||
|
import { PlanHubScreen } from "@/screens/PlanHubScreen"
|
||||||
import { PlateScreen } from "@/screens/PlateScreen"
|
import { PlateScreen } from "@/screens/PlateScreen"
|
||||||
|
import { RulesScreen } from "@/screens/RulesScreen"
|
||||||
import { SettingsScreen } from "@/screens/SettingsScreen"
|
import { SettingsScreen } from "@/screens/SettingsScreen"
|
||||||
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
|
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
|
||||||
|
import { ui } from "@/i18n/ui"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -16,11 +23,70 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<HomeScreen />} />
|
<Route index element={<HomeScreen />} />
|
||||||
<Route path="pantry" element={<PantryScreen />} />
|
|
||||||
<Route path="knowledge" element={<PlateScreen />} />
|
<Route path="kitchen" element={<KitchenHubScreen />} />
|
||||||
<Route path="weekplan" element={<WeekPlanScreen />} />
|
<Route path="kitchen/pantry" element={<PantryScreen />} />
|
||||||
<Route path="builder" element={<BuilderScreen />} />
|
<Route path="kitchen/builder" element={<BuilderScreen />} />
|
||||||
<Route path="settings" element={<SettingsScreen />} />
|
<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 path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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 { SuggestionCard } from "@/components/home/SuggestionCard"
|
||||||
import { t, ui } from "@/i18n/ui"
|
import { t, ui } from "@/i18n/ui"
|
||||||
import { useLanguage } from "@/lib/language"
|
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() {
|
export function HomeScreen() {
|
||||||
const { lang } = useLanguage()
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<header className="flex items-start justify-between gap-3">
|
<header>
|
||||||
<div>
|
|
||||||
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
|
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
|
||||||
<h1 className="type-display mt-1 text-[var(--ink)]">
|
<h1 className="type-display mt-1 text-[var(--ink)]">
|
||||||
{t(ui.home.title, lang)}
|
{t(ui.home.title, lang)}
|
||||||
</h1>
|
</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>
|
</header>
|
||||||
|
|
||||||
{order.map((id) => (
|
<section aria-label={t(ui.home.title, lang)}>
|
||||||
<WidgetShell
|
<SuggestionCard />
|
||||||
key={id}
|
</section>
|
||||||
id={id}
|
|
||||||
editing={editing}
|
|
||||||
order={order}
|
|
||||||
onMove={handleMove}
|
|
||||||
>
|
|
||||||
{widgets[id]}
|
|
||||||
</WidgetShell>
|
|
||||||
))}
|
|
||||||
</div>
|
</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 { useMemo, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { RefreshCw } from "lucide-react"
|
||||||
import { Pin, RefreshCw } from "lucide-react"
|
|
||||||
|
|
||||||
|
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||||
import { Button } from "@/components/ui-pp/Button"
|
import { Button } from "@/components/ui-pp/Button"
|
||||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||||
|
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
|
||||||
import { t, tr, ui } from "@/i18n/ui"
|
import { t, tr, ui } from "@/i18n/ui"
|
||||||
|
import { resolveRecipeUses } from "@/lib/catalog"
|
||||||
import { useLanguage } from "@/lib/language"
|
import { useLanguage } from "@/lib/language"
|
||||||
import { cx } from "@/lib/cx"
|
import { cx } from "@/lib/cx"
|
||||||
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion"
|
import { pickSuggestion } from "@/lib/suggestion"
|
||||||
import { formatClock, getTimeContext } from "@/lib/timeContext"
|
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> = {
|
const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"]
|
||||||
protein: "var(--color-protein)",
|
|
||||||
obst: "var(--color-obstgem)",
|
function effortLabel(effort: Effort, lang: "de" | "en"): string {
|
||||||
gemuese: "var(--color-obstgem)",
|
if (effort === "none") return t(ui.suggestion.effortNone, lang)
|
||||||
fett: "var(--color-fett)",
|
if (effort === "quick") return t(ui.suggestion.effortQuick, lang)
|
||||||
kh: "var(--color-kh)",
|
return t(ui.suggestion.effortCook, lang)
|
||||||
snack: "var(--color-protein)",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string {
|
function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string {
|
||||||
return t(ui.suggestion[slot], lang)
|
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() {
|
export function SuggestionCard() {
|
||||||
const { lang } = useLanguage()
|
const { lang } = useLanguage()
|
||||||
const navigate = useNavigate()
|
|
||||||
const context = useMemo(() => getTimeContext(), [])
|
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 ingredients = recipe ? resolveRecipeUses(recipe) : []
|
||||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set())
|
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
|
function applyEffort(next: Effort | null) {
|
||||||
const items: FoodItem[] = suggestion.foods
|
setEffort(next)
|
||||||
|
setRecipe(pickSuggestion(context.mealTime, next, recipe?.id))
|
||||||
|
}
|
||||||
|
|
||||||
const clock = formatClock(context.hour, context.minute, lang)
|
function toggleEffort(value: Effort) {
|
||||||
const header =
|
applyEffort(effort === value ? null : value)
|
||||||
lang === "de"
|
|
||||||
? `${clock} ${t(ui.suggestion.timeSuffix, lang)} · ${slotLabel(context.slot, lang)}`
|
|
||||||
: `${clock} · ${slotLabel(context.slot, lang)}`
|
|
||||||
|
|
||||||
const title = recipe ? tr(recipe.name, lang) : t(ui.suggestion.fallbackName, lang)
|
|
||||||
|
|
||||||
function togglePin(id: string) {
|
|
||||||
setPinnedIds((current) => {
|
|
||||||
const next = new Set(current)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reroll() {
|
function reroll() {
|
||||||
if (pinnedIds.size === 0) {
|
setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined))
|
||||||
setSuggestion(buildSuggestion(context.slot, recipe?.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSuggestion({
|
|
||||||
recipe: null,
|
|
||||||
foods: reshuffleSuggestion(items, pinnedIds, context.slot),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendToBuilder() {
|
|
||||||
navigate("/builder", {
|
|
||||||
state: {
|
|
||||||
ingredientIds: items.map((item) => item.id),
|
|
||||||
source: "suggestion" as const,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard className="p-4">
|
<GlassCard className="p-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex gap-3">
|
||||||
<p className="type-label text-[var(--ink-faint)]">{header}</p>
|
<TimeIconBadge
|
||||||
<h2 className="type-title text-[var(--ink)]">{title}</h2>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{recipe ? (
|
||||||
{items.map((item) => {
|
<h2 className="type-title text-[var(--ink)]">
|
||||||
const pinned = pinnedIds.has(item.id)
|
{tr(recipe.name, lang)}
|
||||||
|
</h2>
|
||||||
|
) : (
|
||||||
|
<p className="type-body text-[var(--ink-soft)]">
|
||||||
|
{t(ui.suggestion.emptyFilter, lang)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => togglePin(item.id)}
|
aria-pressed={active}
|
||||||
aria-pressed={pinned}
|
onClick={() => toggleEffort(option)}
|
||||||
aria-label={`${tr(item.name, lang)} — ${pinned ? t(ui.suggestion.unpin, lang) : t(ui.suggestion.pin, lang)}`}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
"elevation-transition inline-flex min-h-[48px] items-center gap-2 rounded-[var(--radius-pill)] px-3",
|
"elevation-transition inline-flex min-h-[44px] items-center rounded-[var(--radius-pill)] px-3",
|
||||||
"type-body-emphasis text-[var(--ink)]",
|
"type-caption font-bold text-[var(--ink)]",
|
||||||
"ring-1 ring-[var(--border)]",
|
"ring-1 ring-[var(--border)]",
|
||||||
"active:translate-y-px active:brightness-95",
|
"active:translate-y-px active:brightness-95",
|
||||||
pinned && "ring-2 ring-[var(--color-pick)]/50",
|
active && "ring-2 ring-[var(--ui-accent)]/40",
|
||||||
)}
|
)}
|
||||||
style={{ background: "var(--glass-fill)" }}
|
style={{ background: "var(--glass-fill)" }}
|
||||||
>
|
>
|
||||||
<span
|
{effortLabel(option, lang)}
|
||||||
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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<Button variant="secondary" onClick={reroll}>
|
||||||
<Button variant="secondary" className="flex-1" onClick={reroll}>
|
|
||||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
<RefreshCw className="h-4 w-4" aria-hidden />
|
||||||
{t(ui.suggestion.reroll, lang)}
|
{t(ui.suggestion.reroll, lang)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="flex-1" onClick={sendToBuilder}>
|
|
||||||
{t(ui.suggestion.toBuilder, lang)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom"
|
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 { BottomNav } from "@/components/ui-pp/BottomNav"
|
||||||
import { t, ui } from "@/i18n/ui"
|
import { t, ui } from "@/i18n/ui"
|
||||||
import { useLanguage } from "@/lib/language"
|
import { useLanguage } from "@/lib/language"
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
{ key: "home", path: "/", icon: Home, labelKey: "home" as const },
|
{ key: "now", path: "/", icon: Sparkles, labelKey: "now" as const },
|
||||||
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const },
|
{ key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const },
|
||||||
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const },
|
{ key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const },
|
||||||
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const },
|
{
|
||||||
|
key: "knowledge",
|
||||||
|
path: "/knowledge",
|
||||||
|
icon: BookOpen,
|
||||||
|
labelKey: "knowledge" as const,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppShell() {
|
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"
|
import { cx } from "@/lib/cx"
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ export interface PlateSegment {
|
|||||||
|
|
||||||
export interface DiningPlateProps {
|
export interface DiningPlateProps {
|
||||||
segments: PlateSegment[]
|
segments: PlateSegment[]
|
||||||
|
/** Mobile-first default. */
|
||||||
size?: number
|
size?: number
|
||||||
|
selectedId?: string | null
|
||||||
onSelect?: (id: string) => void
|
onSelect?: (id: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
"aria-label"?: 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) }
|
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.
|
* Ceramic dining plate with detached, rounded pie wedges (Recharts).
|
||||||
* Rim depth via CSS inset shadows; wedges are SVG (clickable).
|
* padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile.
|
||||||
*/
|
*/
|
||||||
export function DiningPlate({
|
export function DiningPlate({
|
||||||
segments,
|
segments,
|
||||||
size = 280,
|
size = 240,
|
||||||
|
selectedId = null,
|
||||||
onSelect,
|
onSelect,
|
||||||
className,
|
className,
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
}: DiningPlateProps) {
|
}: DiningPlateProps) {
|
||||||
const uid = useId().replace(/:/g, "")
|
const rimPad = size * 0.14
|
||||||
const centerX = size / 2
|
|
||||||
const centerY = size / 2
|
|
||||||
const rimPad = size * 0.09
|
|
||||||
const foodR = size / 2 - rimPad
|
const foodR = size / 2 - rimPad
|
||||||
const gapDeg = 2.5
|
const center = size / 2
|
||||||
|
|
||||||
|
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 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
|
let angle = 0
|
||||||
const wedges = segments.map((segment) => {
|
return segments.map((segment) => {
|
||||||
const sweep = (segment.weight / total) * 360
|
const sweep = (segment.weight / total) * usable
|
||||||
const start = angle + gapDeg / 2
|
const start = angle + padAngle / 2
|
||||||
const end = angle + sweep - gapDeg / 2
|
const end = angle + padAngle / 2 + sweep
|
||||||
angle += sweep
|
const mid = (start + end) / 2
|
||||||
|
angle += sweep + padAngle
|
||||||
return {
|
return {
|
||||||
...segment,
|
id: segment.id,
|
||||||
start,
|
label: segment.label,
|
||||||
end,
|
color: segment.color,
|
||||||
d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null,
|
marker: polar(center, center, foodR + rimPad * 0.48, mid),
|
||||||
|
pill: polar(center, center, foodR + rimPad * 1.45, mid),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}, [segments, center, foodR, rimPad])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx("relative select-none", className)}
|
className={cx("relative mx-auto select-none overflow-visible", className)}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size, maxWidth: "100%" }}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
{/* Ceramic body + raised rim */}
|
{/* Ceramic body + raised rim */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-full"
|
className="absolute inset-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background:
|
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: `
|
boxShadow: `
|
||||||
0 8px 24px rgba(25, 24, 0, 0.12),
|
0 6px 18px rgba(25, 24, 0, 0.1),
|
||||||
inset 4px 5px 10px rgba(255, 255, 255, 0.85),
|
inset 5px 6px 12px rgba(255, 255, 255, 0.9),
|
||||||
inset -5px -6px 12px rgba(25, 24, 0, 0.12),
|
inset -6px -7px 14px rgba(25, 24, 0, 0.11),
|
||||||
inset 0 0 0 1px rgba(25, 24, 0, 0.06)
|
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
|
<div
|
||||||
className="absolute rounded-full"
|
className="absolute overflow-hidden rounded-full"
|
||||||
style={{
|
style={{
|
||||||
inset: rimPad * 0.55,
|
inset: rimPad,
|
||||||
background:
|
background:
|
||||||
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
|
"radial-gradient(circle at 50% 42%, #fbfbf7 0%, #ecece4 100%)",
|
||||||
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
|
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>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<filter id={`${uid}-food-soft`} x="-20%" y="-20%" width="140%" height="140%">
|
<PieChart>
|
||||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="blur" />
|
<Pie
|
||||||
<feOffset dy="1" result="off" />
|
data={chartData}
|
||||||
<feComponentTransfer>
|
dataKey="value"
|
||||||
<feFuncA type="linear" slope="0.18" />
|
nameKey="label"
|
||||||
</feComponentTransfer>
|
cx="50%"
|
||||||
<feMerge>
|
cy="50%"
|
||||||
<feMergeNode />
|
innerRadius={0}
|
||||||
<feMergeNode in="SourceGraphic" />
|
outerRadius="98%"
|
||||||
</feMerge>
|
startAngle={90}
|
||||||
</filter>
|
endAngle={-270}
|
||||||
</defs>
|
paddingAngle={5}
|
||||||
|
cornerRadius={10}
|
||||||
<g filter={`url(#${uid}-food-soft)`}>
|
stroke="none"
|
||||||
{wedges.map(
|
isAnimationActive={false}
|
||||||
(wedge) =>
|
onClick={(_, index) => {
|
||||||
wedge.d && (
|
const segment = chartData[index]
|
||||||
<path
|
if (segment) onSelect?.(segment.id)
|
||||||
key={wedge.id}
|
}}
|
||||||
d={wedge.d}
|
>
|
||||||
fill={wedge.color}
|
{chartData.map((entry) => (
|
||||||
opacity={0.92}
|
<Cell
|
||||||
className={cx(
|
key={entry.id}
|
||||||
"outline-none transition-[opacity,filter] duration-150",
|
fill={entry.color}
|
||||||
onSelect &&
|
fillOpacity={
|
||||||
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
|
selectedId && selectedId !== entry.id ? 0.72 : 0.96
|
||||||
)}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
className={cx(
|
||||||
|
"outline-none",
|
||||||
|
onSelect && "cursor-pointer focus:outline-none",
|
||||||
|
)}
|
||||||
|
style={{ outline: "none" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
)
|
||||||
</g>
|
})}
|
||||||
|
|
||||||
{/* Subtle inner rim line separating food from ceramic lip */}
|
{/* Floating label pill for selection */}
|
||||||
<circle
|
{markers.map((marker) => {
|
||||||
cx={centerX}
|
if (selectedId !== marker.id) return null
|
||||||
cy={centerY}
|
return (
|
||||||
r={foodR + 1}
|
<div
|
||||||
fill="none"
|
key={`pill-${marker.id}`}
|
||||||
stroke="rgba(255,255,255,0.55)"
|
className={cx(
|
||||||
strokeWidth={2}
|
"pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2",
|
||||||
pointerEvents="none"
|
"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
|
||||||
/>
|
/>
|
||||||
</svg>
|
{marker.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ export function Accordion({
|
|||||||
"active:brightness-[0.98]",
|
"active:brightness-[0.98]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon ? (
|
{icon ? <span className="shrink-0 leading-none">{icon}</span> : null}
|
||||||
<span className="shrink-0 text-xl leading-none">{icon}</span>
|
|
||||||
) : null}
|
|
||||||
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface BottomNavProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating pill bottom navigation — float elevation above content cards.
|
* 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) {
|
export function BottomNav({ items, className }: BottomNavProps) {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
|
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
|
||||||
item.active
|
item.active
|
||||||
? "text-[var(--color-obstgem-dk)]"
|
? "text-[var(--ui-accent)]"
|
||||||
: "text-[var(--ink-soft)]",
|
: "text-[var(--ink-soft)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"type-label normal-case tracking-wide",
|
"type-label normal-case tracking-wide",
|
||||||
item.active
|
item.active
|
||||||
? "bg-clip-text text-transparent"
|
? "text-[var(--ui-accent)]"
|
||||||
: "text-[var(--ink-soft)]",
|
: "text-[var(--ink-soft)]",
|
||||||
)}
|
)}
|
||||||
style={
|
|
||||||
item.active
|
|
||||||
? { backgroundImage: "var(--gradient-accent)" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</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",
|
"id": "skyr_oats_blueberry",
|
||||||
"type": "breakfast",
|
"type": "half",
|
||||||
|
"times": ["breakfast"],
|
||||||
|
"effort": "none",
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Skyr + Haferflocken + Blaubeeren",
|
"de": "Skyr + Haferflocken + Blaubeeren",
|
||||||
"en": "Skyr + oats + blueberries"
|
"en": "Skyr + oats + blueberries"
|
||||||
@@ -9,14 +11,18 @@
|
|||||||
"uses": ["skyr", "oats", "blueberry"]
|
"uses": ["skyr", "oats", "blueberry"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bf_eggs_tomato",
|
"id": "eggs_tomato",
|
||||||
"type": "breakfast",
|
"type": "half",
|
||||||
|
"times": ["breakfast"],
|
||||||
|
"effort": "quick",
|
||||||
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
|
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
|
||||||
"uses": ["egg", "tomato"]
|
"uses": ["egg", "tomato"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bf_quark_pomegranate",
|
"id": "quark_pomegranate",
|
||||||
"type": "breakfast",
|
"type": "half",
|
||||||
|
"times": ["breakfast", "snack"],
|
||||||
|
"effort": "none",
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Magerquark + Granatapfel",
|
"de": "Magerquark + Granatapfel",
|
||||||
"en": "Low-fat quark + pomegranate"
|
"en": "Low-fat quark + pomegranate"
|
||||||
@@ -24,14 +30,29 @@
|
|||||||
"uses": ["low_fat_quark", "pomegranate"]
|
"uses": ["low_fat_quark", "pomegranate"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sn_skyr_blueberry",
|
"id": "shake_banana",
|
||||||
"type": "snack",
|
"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" },
|
"name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
|
||||||
"uses": ["skyr", "blueberry"]
|
"uses": ["skyr", "blueberry"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sn_cottage_cucumber",
|
"id": "cottage_cucumber",
|
||||||
"type": "snack",
|
"type": "half",
|
||||||
|
"times": ["snack"],
|
||||||
|
"effort": "none",
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Hüttenkäse + Gurke",
|
"de": "Hüttenkäse + Gurke",
|
||||||
"en": "Cottage cheese + cucumber"
|
"en": "Cottage cheese + cucumber"
|
||||||
@@ -39,122 +60,68 @@
|
|||||||
"uses": ["cottage_cheese", "cucumber"]
|
"uses": ["cottage_cheese", "cucumber"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sn_quark_pomegranate",
|
"id": "protein_pudding_snack",
|
||||||
"type": "snack",
|
"type": "half",
|
||||||
"name": {
|
"times": ["snack"],
|
||||||
"de": "Magerquark + Granatapfel",
|
"effort": "none",
|
||||||
"en": "Low-fat quark + pomegranate"
|
|
||||||
},
|
|
||||||
"uses": ["low_fat_quark", "pomegranate"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sn_protein_pudding",
|
|
||||||
"type": "snack",
|
|
||||||
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
|
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
|
||||||
"uses": ["protein_pudding"]
|
"uses": ["protein_pudding"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "full_chicken_cooked",
|
"id": "chicken_warm_plate",
|
||||||
"type": "full",
|
"type": "full",
|
||||||
"cooked": true,
|
"times": ["lunch", "dinner"],
|
||||||
|
"effort": "cook",
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen",
|
"de": "Hühnerbrust warm mit Gemüse",
|
||||||
"en": "Warm chicken breast + veg + linseed oil + red lentils"
|
"en": "Warm chicken breast with veg"
|
||||||
},
|
},
|
||||||
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
|
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "full_cold_platter",
|
"id": "cold_platter",
|
||||||
"type": "full",
|
"type": "full",
|
||||||
"cooked": false,
|
"times": ["lunch", "dinner"],
|
||||||
"name": {
|
"effort": "quick",
|
||||||
"de": "Kalte Brotzeit: Feta + Feldsalat + Walnüsse + Nudelsalat",
|
"name": { "de": "Kalte Brotzeit", "en": "Cold platter" },
|
||||||
"en": "Cold platter: feta + lamb's lettuce + walnuts + pasta salad"
|
|
||||||
},
|
|
||||||
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
|
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "full_bowl_nocook",
|
"id": "tofu_bowl",
|
||||||
"type": "full",
|
"type": "full",
|
||||||
"cooked": false,
|
"times": ["lunch", "dinner"],
|
||||||
"name": {
|
"effort": "none",
|
||||||
"de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)",
|
"name": { "de": "Räuchertofu-Bowl", "en": "Smoked tofu bowl" },
|
||||||
"en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils"
|
|
||||||
},
|
|
||||||
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
|
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "full_leftovers_cold",
|
"id": "leftovers_plate",
|
||||||
"type": "full",
|
"type": "full",
|
||||||
"cooked": false,
|
"times": ["lunch", "dinner"],
|
||||||
"name": {
|
"effort": "none",
|
||||||
"de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)",
|
"name": { "de": "Resteteller kalt", "en": "Cold leftovers plate" },
|
||||||
"en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)"
|
|
||||||
},
|
|
||||||
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
|
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "rec_protein_shake",
|
"id": "tempeh_stirfry_plate",
|
||||||
"type": "recipe",
|
"type": "full",
|
||||||
"name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" },
|
"times": ["lunch", "dinner"],
|
||||||
"uses": ["protein_shake"],
|
"effort": "cook",
|
||||||
"text": {
|
|
||||||
"de": "40 g Proteinpulver + 250–300 ml Wasser oder Milch shaken. ≈30 g Protein.",
|
|
||||||
"en": "Shake 40 g protein powder with 250–300 ml water or milk. ≈30 g protein."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rec_tempeh_pan",
|
|
||||||
"type": "recipe",
|
|
||||||
"name": { "de": "Tempeh-Pfanne", "en": "Pan-fried tempeh" },
|
|
||||||
"uses": ["tempeh", "soy_sauce", "lime", "garlic"],
|
|
||||||
"text": {
|
|
||||||
"de": "Tempeh würfeln, 10 Min. in Sojasauce + Limette + Knoblauch marinieren, knusprig braten (5–7 Min.).",
|
|
||||||
"en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (5–7 min)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rec_silken_tofu_pudding",
|
|
||||||
"type": "recipe",
|
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Schoko-Seidentofu-Pudding",
|
"de": "Tempeh-Gemüsepfanne",
|
||||||
"en": "Chocolate silken-tofu pudding"
|
"en": "Tempeh vegetable stir-fry"
|
||||||
},
|
},
|
||||||
"uses": ["silken_tofu", "cocoa_powder", "honey"],
|
"uses": ["tempeh", "bell_pepper", "zucchini", "sesame_oil", "quinoa"]
|
||||||
"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",
|
"id": "chicken_salad_plate",
|
||||||
"type": "recipe",
|
"type": "full",
|
||||||
|
"times": ["lunch", "dinner"],
|
||||||
|
"effort": "quick",
|
||||||
"name": {
|
"name": {
|
||||||
"de": "Grundrezept: Gemüsepfanne",
|
"de": "Hühnersalat mit Kichererbsen",
|
||||||
"en": "Base recipe: vegetable stir-fry"
|
"en": "Chicken salad with chickpeas"
|
||||||
},
|
},
|
||||||
"uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"],
|
"uses": ["chicken_breast", "lambs_lettuce", "avocado", "chickpeas"]
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
"n": 2,
|
"n": 2,
|
||||||
"cls": "t",
|
"cls": "t",
|
||||||
"title": {
|
"title": {
|
||||||
"de": "Obst ODER Gemüse",
|
"de": "Obst & Gemüse",
|
||||||
"en": "Fruit OR Vegetables"
|
"en": "Fruit & Veggie"
|
||||||
},
|
},
|
||||||
"qty": {
|
"qty": {
|
||||||
"de": "eine Portion",
|
"de": "eine Portion",
|
||||||
|
|||||||
@@ -18,76 +18,192 @@ export function tr(value: LocalizedString, lang: Language): string {
|
|||||||
export const ui = {
|
export const ui = {
|
||||||
appName: { de: "Pocket Pascal", en: "Pocket Pascal" },
|
appName: { de: "Pocket Pascal", en: "Pocket Pascal" },
|
||||||
nav: {
|
nav: {
|
||||||
home: { de: "Start", en: "Home" },
|
now: { de: "Jetzt", en: "Now" },
|
||||||
pantry: { de: "Vorrat", en: "Pantry" },
|
kitchen: { de: "Küche", en: "Kitchen" },
|
||||||
knowledge: { de: "Teller", en: "Plate" },
|
plan: { de: "Planen", en: "Plan" },
|
||||||
settings: { de: "Einstellungen", en: "Settings" },
|
knowledge: { de: "Wissen", en: "Knowledge" },
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
|
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
|
||||||
editLayout: { de: "Layout", en: "Layout" },
|
},
|
||||||
doneEdit: { de: "Fertig", en: "Done" },
|
kitchen: {
|
||||||
moveUp: { de: "Nach oben", en: "Move up" },
|
title: { de: "Küche", en: "Kitchen" },
|
||||||
moveDown: { de: "Nach unten", en: "Move down" },
|
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: {
|
suggestion: {
|
||||||
morning: { de: "Morgen · Protein + Obst", en: "Morning · Protein + fruit" },
|
breakfast: { de: "Frühstück", en: "Breakfast" },
|
||||||
midday: { de: "Mittag · Ganzer Teller", en: "Midday · Full plate" },
|
lunch: { de: "Mittag", en: "Lunch" },
|
||||||
afternoon: { de: "Nachmittag · Snack-Zeit", en: "Afternoon · Snack time" },
|
snack: { de: "Snack", en: "Snack" },
|
||||||
evening: { de: "Abend · Ganzer Teller", en: "Evening · Full plate" },
|
dinner: { de: "Abend", en: "Dinner" },
|
||||||
timeSuffix: { de: "Uhr", en: "" },
|
plateFull: { de: "Ganzer Teller", en: "Full plate" },
|
||||||
pinned: { de: "Fixiert", en: "Pinned" },
|
plateHalf: { de: "Halber Teller", en: "Half plate" },
|
||||||
pin: { de: "Fixieren", en: "Pin" },
|
effortLabel: { de: "Wie viel Zeit?", en: "How much time?" },
|
||||||
unpin: { de: "Lösen", en: "Unpin" },
|
effortNone: { de: "Keine Zeit", en: "No time" },
|
||||||
reroll: { de: "Was anderes", en: "Something else" },
|
effortQuick: { de: "15 Min", en: "15 min" },
|
||||||
toBuilder: { de: "In den Builder übernehmen", en: "Send to builder" },
|
effortCook: { de: "Kann kochen", en: "Can cook" },
|
||||||
fallbackName: { de: "Dein Teller jetzt", en: "Your plate now" },
|
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?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
title: { de: "Wissen", en: "Knowledge" },
|
||||||
|
intro: {
|
||||||
|
de: "Regeln, Rezepte und Etikett-Checks",
|
||||||
|
en: "Rules, recipes and label checks",
|
||||||
},
|
},
|
||||||
knowledgeHub: {
|
|
||||||
plateTitle: { de: "Mein Teller", en: "My Plate" },
|
plateTitle: { de: "Mein Teller", en: "My Plate" },
|
||||||
plateBody: {
|
plateBody: {
|
||||||
de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate",
|
de: "Ganze und halbe Mahlzeit",
|
||||||
en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs",
|
en: "Full and half meals",
|
||||||
},
|
},
|
||||||
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
|
recipesTitle: { de: "Rezept-Bibliothek", en: "Recipe library" },
|
||||||
pantryBody: {
|
recipesBody: {
|
||||||
de: "Kategorien, Suche, Pick- & Caution-Markierungen",
|
de: "Zubereitung und Grundrezepte",
|
||||||
en: "Categories, search, pick & caution markers",
|
en: "Prep steps and base recipes",
|
||||||
},
|
},
|
||||||
weekplanTitle: { de: "Wochenplan", en: "Weekly plan" },
|
rulesTitle: { de: "Daumenregeln", en: "Rules of thumb" },
|
||||||
weekplanBody: {
|
rulesBody: {
|
||||||
de: "Frühstück, Snacks, volle Teller & Rezepte",
|
de: "Etikett-Check pro Kategorie",
|
||||||
en: "Breakfast, snacks, full plates & recipes",
|
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: {
|
weekplan: {
|
||||||
title: { de: "Wochenplan", en: "Weekly plan" },
|
title: { de: "Wochenplan", en: "Weekly plan" },
|
||||||
intro: {
|
intro: {
|
||||||
de: "Kuratierte Kombinationen aus dem Baukasten — gruppiert nach Mahlzeitentyp.",
|
de: "Browsebare Inspiration für die Woche",
|
||||||
en: "Curated building-block combos — grouped by meal type.",
|
en: "Browsable inspiration for the week",
|
||||||
},
|
},
|
||||||
breakfast: { de: "Frühstück", en: "Breakfast" },
|
breakfast: { de: "Frühstück", en: "Breakfast" },
|
||||||
|
lunch: { de: "Mittag", en: "Lunch" },
|
||||||
snack: { de: "Snack", en: "Snack" },
|
snack: { de: "Snack", en: "Snack" },
|
||||||
full: { de: "Ganzer Teller", en: "Full plate" },
|
dinner: { de: "Abend", en: "Dinner" },
|
||||||
recipe: { de: "Rezepte", en: "Recipes" },
|
effortNone: { de: "ohne Kochen", en: "no cook" },
|
||||||
cooked: { de: "Warm / gekocht", en: "Warm / cooked" },
|
effortQuick: { de: "15 Min", en: "15 min" },
|
||||||
cold: { de: "Kalt / ohne Kochen", en: "Cold / no cook" },
|
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" },
|
uses: { de: "Zutaten", en: "Ingredients" },
|
||||||
variations: { de: "Variationen", en: "Variations" },
|
variations: { de: "Variationen", en: "Variations" },
|
||||||
},
|
},
|
||||||
pantry: {
|
pantry: {
|
||||||
title: { de: "Vorratskammer", en: "Pantry" },
|
title: { de: "Küche", en: "Kitchen" },
|
||||||
search: { de: "Lebensmittel suchen…", en: "Search foods…" },
|
search: { de: "Lebensmittel suchen…", en: "Search foods…" },
|
||||||
pick: { de: "empfohlen", en: "recommended" },
|
pick: { de: "empfohlen", en: "recommended" },
|
||||||
caution: { de: "bewusst genießen", en: "enjoy mindfully" },
|
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" },
|
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: {
|
plate: {
|
||||||
title: { de: "Mein Teller", en: "My Plate" },
|
title: { de: "Mein Teller", en: "My Plate" },
|
||||||
subtitle: {
|
subtitle: {
|
||||||
de: "Baukasten-Reihenfolge: zuerst Protein, dann Obst oder Gemüse, danach Fett und Kohlenhydrate.",
|
de: "Protein, Obst & Gemüse, Fett, Kohlenhydrate",
|
||||||
en: "Building-block order: protein first, then fruit or vegetables, then fat and carbohydrates.",
|
en: "Protein, fruit & veggie, fat, carbohydrates",
|
||||||
},
|
},
|
||||||
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
|
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
|
||||||
segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
|
segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
|
||||||
@@ -114,33 +230,17 @@ export const ui = {
|
|||||||
en: "2 L of water daily.",
|
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: {
|
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" },
|
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
|
||||||
builderBody: {
|
builderBody: {
|
||||||
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen.",
|
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen",
|
||||||
en: "Phase 1 stub — chip selection & scoring coming next.",
|
en: "Phase 1 stub — chip selection and scoring next",
|
||||||
},
|
},
|
||||||
builderHandoff: {
|
builderHandoff: {
|
||||||
de: "Übernommen aus dem Vorschlag:",
|
de: "Übernommen aus dem Vorschlag",
|
||||||
en: "Handed off from suggestion:",
|
en: "Handed off from suggestion",
|
||||||
},
|
},
|
||||||
|
builderMissing: { de: "Fehlt noch", en: "Still missing" },
|
||||||
settingsTitle: { de: "Einstellungen", en: "Settings" },
|
settingsTitle: { de: "Einstellungen", en: "Settings" },
|
||||||
language: { de: "Sprache", en: "Language" },
|
language: { de: "Sprache", en: "Language" },
|
||||||
comingSoon: { de: "Demnächst", en: "Coming soon" },
|
comingSoon: { de: "Demnächst", en: "Coming soon" },
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import type {
|
|||||||
ExtraItem,
|
ExtraItem,
|
||||||
FoodCategory,
|
FoodCategory,
|
||||||
FoodItem,
|
FoodItem,
|
||||||
|
HowToRecipe,
|
||||||
LocalizedString,
|
LocalizedString,
|
||||||
PlanRecipeType,
|
MealTime,
|
||||||
|
NutritionBasis,
|
||||||
Recipe,
|
Recipe,
|
||||||
TagType,
|
TagType,
|
||||||
TimeSlot,
|
|
||||||
} from "@/types/domain"
|
} from "@/types/domain"
|
||||||
import extrasRaw from "@/data/extras.json"
|
import extrasRaw from "@/data/extras.json"
|
||||||
import foodsRaw from "@/data/foods.json"
|
import foodsRaw from "@/data/foods.json"
|
||||||
|
import howtosRaw from "@/data/howtos.json"
|
||||||
import recipesRaw from "@/data/recipes.json"
|
import recipesRaw from "@/data/recipes.json"
|
||||||
import stepsRaw from "@/data/steps.json"
|
import stepsRaw from "@/data/steps.json"
|
||||||
|
|
||||||
@@ -19,8 +21,13 @@ interface RawFoodItem {
|
|||||||
id: string
|
id: string
|
||||||
name: LocalizedString
|
name: LocalizedString
|
||||||
v: LocalizedString
|
v: LocalizedString
|
||||||
|
basis?: NutritionBasis
|
||||||
|
kcal?: number
|
||||||
|
proteinG?: number
|
||||||
f?: TagType
|
f?: TagType
|
||||||
approx?: boolean
|
approx?: boolean
|
||||||
|
note?: LocalizedString
|
||||||
|
cautionWhy?: LocalizedString
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawFoodCategory {
|
interface RawFoodCategory {
|
||||||
@@ -29,6 +36,10 @@ interface RawFoodCategory {
|
|||||||
title: LocalizedString
|
title: LocalizedString
|
||||||
thumb: LocalizedString
|
thumb: LocalizedString
|
||||||
items: RawFoodItem[]
|
items: RawFoodItem[]
|
||||||
|
sub?: {
|
||||||
|
title: LocalizedString
|
||||||
|
items: RawFoodItem[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlateStep {
|
export interface PlateStep {
|
||||||
@@ -39,25 +50,87 @@ export interface PlateStep {
|
|||||||
text: LocalizedString
|
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 {
|
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
|
||||||
|
const basis: NutritionBasis =
|
||||||
|
item.basis ??
|
||||||
|
(cat === "snack" ? "perServing" : item.id === "egg" ? "perUnit" : "per100g")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
cat,
|
cat,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
valueInfo: item.v,
|
valueInfo: formatFoodValueInfo(item),
|
||||||
|
basis,
|
||||||
|
kcal: item.kcal,
|
||||||
|
proteinG: item.proteinG,
|
||||||
tag: item.f,
|
tag: item.f,
|
||||||
|
cautionWhy: item.cautionWhy,
|
||||||
isApprox: item.approx,
|
isApprox: item.approx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
|
function mapCategory(category: RawFoodCategory): FoodCategory {
|
||||||
(category) => ({
|
const items = [
|
||||||
|
...category.items.map((item) => mapItem(category.id, item)),
|
||||||
|
...(category.sub?.items.map((item) => mapItem(category.id, item)) ?? []),
|
||||||
|
]
|
||||||
|
return {
|
||||||
id: category.id,
|
id: category.id,
|
||||||
cls: category.cls,
|
cls: category.cls,
|
||||||
title: category.title,
|
title: category.title,
|
||||||
thumb: category.thumb,
|
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)
|
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]),
|
extras.map((item) => [item.id, item]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Plate suggestions (RECIPES). */
|
||||||
export const recipes: Recipe[] = recipesRaw as Recipe[]
|
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 plateSteps: PlateStep[] = stepsRaw as PlateStep[]
|
||||||
|
|
||||||
export const PLAN_TYPE_ORDER: PlanRecipeType[] = [
|
export const MEAL_TIME_ORDER: MealTime[] = [
|
||||||
"breakfast",
|
"breakfast",
|
||||||
|
"lunch",
|
||||||
"snack",
|
"snack",
|
||||||
"full",
|
"dinner",
|
||||||
"recipe",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function foodsInCategory(cat: CategoryId): FoodItem[] {
|
export function foodsInCategory(cat: CategoryId): FoodItem[] {
|
||||||
return foodCategories.find((category) => category.id === cat)?.items ?? []
|
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 {
|
export function resolveCatalogId(id: string): CatalogItem | null {
|
||||||
const food = foodById[id]
|
const food = foodById[id]
|
||||||
if (food) {
|
if (food) {
|
||||||
@@ -97,6 +178,7 @@ export function resolveCatalogId(id: string): CatalogItem | null {
|
|||||||
cat: food.cat,
|
cat: food.cat,
|
||||||
valueInfo: food.valueInfo,
|
valueInfo: food.valueInfo,
|
||||||
tag: food.tag,
|
tag: food.tag,
|
||||||
|
cautionWhy: food.cautionWhy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const extra = extraById[id]
|
const extra = extraById[id]
|
||||||
@@ -110,30 +192,49 @@ export function resolveCatalogId(id: string): CatalogItem | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] {
|
export function resolveRecipeUses(
|
||||||
|
recipe: Pick<Recipe, "uses"> | Pick<HowToRecipe, "uses">,
|
||||||
|
): CatalogItem[] {
|
||||||
return recipe.uses
|
return recipe.uses
|
||||||
.map((id) => resolveCatalogId(id))
|
.map((id) => resolveCatalogId(id))
|
||||||
.filter((item): item is CatalogItem => Boolean(item))
|
.filter((item): item is CatalogItem => Boolean(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recipesByType(type: PlanRecipeType): Recipe[] {
|
export function recipesForMealTime(time: MealTime): Recipe[] {
|
||||||
return recipes.filter((recipe) => recipe.type === type)
|
return recipes.filter((recipe) => recipe.times.includes(time))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map plan recipe types onto suggestion time slots. */
|
/** Category color token for ingredient chips / plate segments. */
|
||||||
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] {
|
export function categoryDotColor(cat: CategoryId | undefined): string {
|
||||||
switch (recipe.type) {
|
switch (cat) {
|
||||||
case "breakfast":
|
case "protein":
|
||||||
return ["morning"]
|
|
||||||
case "snack":
|
case "snack":
|
||||||
return ["afternoon"]
|
return "var(--cat-protein)"
|
||||||
case "full":
|
case "obst":
|
||||||
return ["midday", "evening"]
|
case "gemuese":
|
||||||
case "recipe":
|
return "var(--cat-produce)"
|
||||||
return ["morning", "afternoon", "midday", "evening"]
|
case "fett":
|
||||||
|
return "var(--cat-fat)"
|
||||||
|
case "kh":
|
||||||
|
return "var(--cat-berry)"
|
||||||
|
default:
|
||||||
|
return "var(--ink-faint)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" {
|
/** Four plate slots for completeness scan (snack → protein). */
|
||||||
return recipe.type === "full" ? "full" : "half"
|
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 { recipesForMealTime } from "@/lib/catalog"
|
||||||
import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
import type { Effort, MealTime, Recipe } from "@/types/domain"
|
||||||
|
|
||||||
const SLOT_CATEGORIES: Record<TimeSlot, CategoryId[]> = {
|
function pickRandom<T>(items: T[]): T | null {
|
||||||
morning: ["protein", "obst", "fett"],
|
if (items.length === 0) return null
|
||||||
midday: ["protein", "gemuese", "fett", "kh"],
|
return items[Math.floor(Math.random() * items.length)] ?? null
|
||||||
afternoon: ["protein", "gemuese"],
|
|
||||||
evening: ["protein", "gemuese", "fett", "kh"],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickRandom<T>(items: T[], excludeIds: Set<string>, getId: (item: T) => string): T | null {
|
export function filterRecipes(
|
||||||
const pool = items.filter((item) => !excludeIds.has(getId(item)))
|
mealTime: MealTime,
|
||||||
if (pool.length === 0) return null
|
effort: Effort | null,
|
||||||
return pool[Math.floor(Math.random() * pool.length)] ?? null
|
): Recipe[] {
|
||||||
}
|
const byTime = recipesForMealTime(mealTime)
|
||||||
|
if (!effort) return byTime
|
||||||
export function recipesForSlot(slot: TimeSlot): Recipe[] {
|
return byTime.filter((recipe) => recipe.effort === effort)
|
||||||
return recipes.filter(
|
|
||||||
(recipe) =>
|
|
||||||
recipe.type !== "recipe" && timeSlotsForRecipe(recipe).includes(slot),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plate foods only (skip EXTRAS) for suggestion chips / builder handoff. */
|
|
||||||
export function resolveRecipeFoods(recipe: Recipe): FoodItem[] {
|
|
||||||
return recipe.uses
|
|
||||||
.map((id) => foodById[id])
|
|
||||||
.filter((item): item is FoodItem => Boolean(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prefer curated recipes; fall back to one random food per expected category. */
|
|
||||||
export function buildSuggestion(slot: TimeSlot, avoidRecipeId?: string): {
|
|
||||||
recipe: Recipe | null
|
|
||||||
foods: FoodItem[]
|
|
||||||
} {
|
|
||||||
const candidates = recipesForSlot(slot).filter((recipe) => recipe.id !== avoidRecipeId)
|
|
||||||
const recipe =
|
|
||||||
candidates.length > 0
|
|
||||||
? (pickRandom(candidates, new Set(), (item) => item.id) as Recipe)
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (recipe) {
|
|
||||||
return { recipe, foods: resolveRecipeFoods(recipe) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const generated = SLOT_CATEGORIES[slot]
|
|
||||||
.map((cat) =>
|
|
||||||
pickRandom(
|
|
||||||
foods.filter((item) => item.cat === cat),
|
|
||||||
new Set(),
|
|
||||||
(item) => item.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter((item): item is FoodItem => Boolean(item))
|
|
||||||
|
|
||||||
return { recipe: null, foods: generated }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace unpinned ingredients with alternatives from the same category.
|
* Pick a plate suggestion for the current meal time.
|
||||||
* Pinned IDs stay in place; missing categories are filled when possible.
|
* When `avoidId` is set, prefer a different recipe (falls back if only one match).
|
||||||
*/
|
*/
|
||||||
export function reshuffleSuggestion(
|
export function pickSuggestion(
|
||||||
current: FoodItem[],
|
mealTime: MealTime,
|
||||||
pinnedIds: Set<string>,
|
effort: Effort | null,
|
||||||
slot: TimeSlot,
|
avoidId?: string,
|
||||||
): FoodItem[] {
|
): Recipe | null {
|
||||||
const pinned = current.filter((item) => pinnedIds.has(item.id))
|
const pool = filterRecipes(mealTime, effort)
|
||||||
const pinnedCats = new Set(pinned.map((item) => item.cat))
|
if (pool.length === 0) return null
|
||||||
const exclude = new Set(current.map((item) => item.id))
|
|
||||||
|
|
||||||
const next: FoodItem[] = [...pinned]
|
const others = avoidId ? pool.filter((recipe) => recipe.id !== avoidId) : pool
|
||||||
|
return pickRandom(others.length > 0 ? others : pool)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
import type { MealType, TimeSlot } from "@/types/domain"
|
import type { MealTime, MealType } from "@/types/domain"
|
||||||
|
|
||||||
export interface TimeContext {
|
export interface TimeContext {
|
||||||
slot: TimeSlot
|
mealTime: MealTime
|
||||||
mealType: MealType
|
mealType: MealType
|
||||||
hour: number
|
hour: number
|
||||||
minute: 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 {
|
export function getTimeContext(date = new Date()): TimeContext {
|
||||||
const hour = date.getHours()
|
const hour = date.getHours()
|
||||||
const minute = date.getMinutes()
|
const minute = date.getMinutes()
|
||||||
|
const minutes = hour * 60 + minute
|
||||||
|
|
||||||
if (hour >= 6 && hour < 11) {
|
let mealTime: MealTime
|
||||||
return { slot: "morning", mealType: "half", hour, minute }
|
if (minutes < 10 * 60 + 30) mealTime = "breakfast"
|
||||||
}
|
else if (minutes < 14 * 60 + 30) mealTime = "lunch"
|
||||||
if (hour >= 11 && hour < 15) {
|
else if (minutes < 17 * 60 + 30) mealTime = "snack"
|
||||||
return { slot: "midday", mealType: "full", hour, minute }
|
else mealTime = "dinner"
|
||||||
}
|
|
||||||
if (hour >= 15 && hour < 18) {
|
const mealType: MealType =
|
||||||
return { slot: "afternoon", mealType: "half", hour, minute }
|
mealTime === "breakfast" || mealTime === "snack" ? "half" : "full"
|
||||||
}
|
|
||||||
if (hour >= 18 && hour < 22) {
|
return { mealTime, mealType, hour, minute }
|
||||||
return { slot: "evening", mealType: "full", hour, minute }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Late night / early morning: lean snack context
|
export function formatClock(hour: number, minute: number): string {
|
||||||
return { slot: "afternoon", mealType: "half", hour, minute }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatClock(hour: number, minute: number, lang: "de" | "en"): string {
|
|
||||||
const hh = String(hour).padStart(2, "0")
|
const hh = String(hour).padStart(2, "0")
|
||||||
const mm = String(minute).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 { 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 { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||||
import { t, tr, ui } from "@/i18n/ui"
|
import { t, ui } from "@/i18n/ui"
|
||||||
import { foodById } from "@/lib/catalog"
|
import {
|
||||||
|
PLATE_SLOT_COLOR,
|
||||||
|
plateSlotForCategory,
|
||||||
|
resolveCatalogId,
|
||||||
|
type PlateSlot,
|
||||||
|
} from "@/lib/catalog"
|
||||||
import { useLanguage } from "@/lib/language"
|
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() {
|
export function BuilderScreen() {
|
||||||
const { lang } = useLanguage()
|
const { lang } = useLanguage()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const handoff = location.state as BuilderHandoffState | null
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<KitchenBackLink />
|
||||||
<GlassCard className="p-4">
|
<GlassCard className="p-4">
|
||||||
<p className="type-label text-[var(--ink-faint)]">
|
<p className="type-label text-[var(--ink-faint)]">
|
||||||
{t(ui.stubs.comingSoon, lang)}
|
{t(ui.stubs.comingSoon, lang)}
|
||||||
@@ -26,27 +70,48 @@ export function BuilderScreen() {
|
|||||||
</p>
|
</p>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
{ingredientIds.length > 0 ? (
|
<div className="flex flex-col gap-3">
|
||||||
<GlassCard className="p-4">
|
{SLOT_ORDER.map((slot) => {
|
||||||
<p className="type-body-emphasis text-[var(--ink)]">
|
const slotItems = bySlot[slot]
|
||||||
{t(ui.stubs.builderHandoff, lang)}
|
const filled = slotItems.length > 0
|
||||||
</p>
|
|
||||||
<ul className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{ingredientIds.map((id) => {
|
|
||||||
const item = foodById[id]
|
|
||||||
if (!item) return null
|
|
||||||
return (
|
return (
|
||||||
<li
|
<div
|
||||||
key={id}
|
key={slot}
|
||||||
className="type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)] ring-1 ring-[var(--border)]"
|
className={cx(
|
||||||
style={{ background: "var(--glass-fill)" }}
|
"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],
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tr(item.name, lang)}
|
<p className="type-label mb-2 flex items-center gap-2 text-[var(--ink-faint)]">
|
||||||
</li>
|
<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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
</GlassCard>
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<p className="type-caption text-[var(--ink-faint)]">
|
||||||
|
{t(ui.stubs.builderHandoff, lang)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { CategorySoftChip } from "@/components/food/CategorySoftChip"
|
||||||
|
import { KitchenBackLink } from "@/components/home/NavCard"
|
||||||
import { Accordion } from "@/components/ui-pp/Accordion"
|
import { Accordion } from "@/components/ui-pp/Accordion"
|
||||||
import { t, tr, ui } from "@/i18n/ui"
|
import { t, tr, ui } from "@/i18n/ui"
|
||||||
import { foodCategories } from "@/lib/catalog"
|
import { foodCategories, sortFoodItems } from "@/lib/catalog"
|
||||||
import { useLanguage } from "@/lib/language"
|
import { useLanguage } from "@/lib/language"
|
||||||
import { cx } from "@/lib/cx"
|
import { cx } from "@/lib/cx"
|
||||||
|
import { signalGlyphChar, signalGlyphLabel } from "@/lib/signals"
|
||||||
|
import { DEFAULT_NUTRITION_BASIS } from "@/types/domain"
|
||||||
import type { FoodItem } from "@/types/domain"
|
import type { FoodItem } from "@/types/domain"
|
||||||
|
|
||||||
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
|
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
|
||||||
@@ -16,29 +20,49 @@ export function PantryScreen() {
|
|||||||
const { lang } = useLanguage()
|
const { lang } = useLanguage()
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const normalized = query.trim().toLowerCase()
|
const normalized = query.trim().toLowerCase()
|
||||||
|
/** Switch later when UI offers perServing / perUnit views. */
|
||||||
|
const displayBasis = DEFAULT_NUTRITION_BASIS
|
||||||
|
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
foodCategories
|
foodCategories
|
||||||
.map((category) => {
|
.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 }
|
return { category, items }
|
||||||
})
|
})
|
||||||
.filter((section) => section.items.length > 0),
|
.filter((section) => section.items.length > 0),
|
||||||
[lang, normalized],
|
[lang, normalized, displayBasis],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<header>
|
<header>
|
||||||
|
<KitchenBackLink />
|
||||||
<h1 className="type-display text-[var(--ink)]">
|
<h1 className="type-display text-[var(--ink)]">
|
||||||
{t(ui.pantry.title, lang)}
|
{t(ui.pantry.title, lang)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="type-caption mt-2 text-[var(--ink-soft)]">
|
<p className="type-caption mt-3 text-[var(--ink-soft)]">
|
||||||
<span className="text-[var(--color-pick)]">●</span> {t(ui.pantry.pick, lang)}
|
<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>
|
</p>
|
||||||
|
{displayBasis === "per100g" ? (
|
||||||
|
<p className="type-caption mt-1 text-[var(--ink-faint)]">
|
||||||
|
{t(ui.pantry.per100gHint, lang)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<label className="sr-only" htmlFor="pantry-search">
|
<label className="sr-only" htmlFor="pantry-search">
|
||||||
@@ -53,13 +77,15 @@ export function PantryScreen() {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"type-body min-h-[48px] w-full rounded-[var(--radius-control)] px-4 text-[var(--ink)]",
|
"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)]",
|
"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)" }}
|
style={{ background: "var(--glass-fill)" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sections.length === 0 ? (
|
{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">
|
<div className="flex flex-col gap-3">
|
||||||
{sections.map(({ category, items }) => (
|
{sections.map(({ category, items }) => (
|
||||||
@@ -68,21 +94,7 @@ export function PantryScreen() {
|
|||||||
defaultOpen={Boolean(normalized)}
|
defaultOpen={Boolean(normalized)}
|
||||||
title={tr(category.title, lang)}
|
title={tr(category.title, lang)}
|
||||||
meta={items.length}
|
meta={items.length}
|
||||||
icon={
|
icon={<CategorySoftChip categoryId={category.id} />}
|
||||||
<span aria-hidden>
|
|
||||||
{category.id === "protein"
|
|
||||||
? "🥚"
|
|
||||||
: category.id === "snack"
|
|
||||||
? "🥤"
|
|
||||||
: category.id === "obst"
|
|
||||||
? "🍎"
|
|
||||||
: category.id === "gemuese"
|
|
||||||
? "🥦"
|
|
||||||
: category.id === "fett"
|
|
||||||
? "🥑"
|
|
||||||
: "🌾"}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -90,31 +102,51 @@ export function PantryScreen() {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-baseline justify-between gap-3 border-b border-[var(--border)] pb-2 last:border-0 last:pb-0"
|
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)}
|
{tr(item.name, lang)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cx(
|
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.isApprox && "italic",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.tag === "pick" ? (
|
<span>{tr(item.valueInfo, lang)}</span>
|
||||||
<span className="mr-1 text-[var(--color-pick)]">●</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}
|
) : null}
|
||||||
{item.tag === "caution" ? (
|
|
||||||
<span className="mr-1 text-[var(--color-caution)]">●</span>
|
|
||||||
) : null}
|
|
||||||
{tr(item.valueInfo, lang)}
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{category.id !== "obst" ? (
|
||||||
<p className="type-caption mt-3 text-[var(--ink-soft)]">
|
<p className="type-caption mt-3 text-[var(--ink-soft)]">
|
||||||
<b className="font-bold text-[var(--ink)]">{t(ui.pantry.tip, lang)}</b>
|
<b className="font-bold text-[var(--ink)]">
|
||||||
|
{t(ui.pantry.tip, lang)}
|
||||||
|
</b>
|
||||||
<br />
|
<br />
|
||||||
{tr(category.thumb, lang)}
|
{tr(category.thumb, lang)}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { KnowledgeBackLink } from "@/components/home/NavCard"
|
||||||
import { DiningPlate } from "@/components/plate/DiningPlate"
|
import { DiningPlate } from "@/components/plate/DiningPlate"
|
||||||
import { SegControl } from "@/components/ui-pp/SegControl"
|
import { SegControl } from "@/components/ui-pp/SegControl"
|
||||||
import { t, tr, ui } from "@/i18n/ui"
|
import { t, tr, ui } from "@/i18n/ui"
|
||||||
@@ -11,10 +12,10 @@ import { cx } from "@/lib/cx"
|
|||||||
type MealMode = "full" | "half"
|
type MealMode = "full" | "half"
|
||||||
|
|
||||||
const CLS_COLOR: Record<string, string> = {
|
const CLS_COLOR: Record<string, string> = {
|
||||||
g: "var(--green)",
|
g: "var(--cat-protein)",
|
||||||
t: "var(--terracotta)",
|
t: "var(--cat-produce)",
|
||||||
o: "var(--gold)",
|
o: "var(--cat-fat)",
|
||||||
s: "var(--slate)",
|
s: "var(--cat-berry)",
|
||||||
}
|
}
|
||||||
|
|
||||||
const FULL_WEIGHT: Record<string, number> = {
|
const FULL_WEIGHT: Record<string, number> = {
|
||||||
@@ -81,10 +82,11 @@ function StepDetailCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: color,
|
background: color,
|
||||||
color: step.cls === "o" ? "var(--ink)" : "#fff",
|
color:
|
||||||
|
step.cls === "o" ? "var(--cat-fat-fg)" : "var(--cat-on-dark)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step.n}
|
{step.n}
|
||||||
@@ -123,6 +125,7 @@ export function PlateScreen() {
|
|||||||
const { lang } = useLanguage()
|
const { lang } = useLanguage()
|
||||||
const [mode, setMode] = useState<MealMode>("full")
|
const [mode, setMode] = useState<MealMode>("full")
|
||||||
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
|
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
|
||||||
|
const [selectedId, setSelectedId] = useState<number>(1)
|
||||||
const [highlightId, setHighlightId] = useState<number | null>(null)
|
const [highlightId, setHighlightId] = useState<number | null>(null)
|
||||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -152,7 +155,6 @@ export function PlateScreen() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Drop open state for cards that are hidden in half-meal mode
|
|
||||||
setOpenIds((current) => {
|
setOpenIds((current) => {
|
||||||
const next = new Set<number>()
|
const next = new Set<number>()
|
||||||
for (const id of current) {
|
for (const id of current) {
|
||||||
@@ -161,9 +163,12 @@ export function PlateScreen() {
|
|||||||
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
|
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
|
||||||
return next
|
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) {
|
function focusStep(n: number) {
|
||||||
|
setSelectedId(n)
|
||||||
setOpenIds((current) => new Set(current).add(n))
|
setOpenIds((current) => new Set(current).add(n))
|
||||||
setHighlightId(n)
|
setHighlightId(n)
|
||||||
if (highlightTimer.current) clearTimeout(highlightTimer.current)
|
if (highlightTimer.current) clearTimeout(highlightTimer.current)
|
||||||
@@ -177,6 +182,7 @@ export function PlateScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleStep(n: number) {
|
function toggleStep(n: number) {
|
||||||
|
setSelectedId(n)
|
||||||
setOpenIds((current) => {
|
setOpenIds((current) => {
|
||||||
const next = new Set(current)
|
const next = new Set(current)
|
||||||
if (next.has(n)) next.delete(n)
|
if (next.has(n)) next.delete(n)
|
||||||
@@ -190,6 +196,7 @@ export function PlateScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<header>
|
<header>
|
||||||
|
<KnowledgeBackLink />
|
||||||
<h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1>
|
<h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1>
|
||||||
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
<p className="type-body mt-2 text-[var(--ink-soft)]">
|
||||||
{t(ui.plate.subtitle, lang)}
|
{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
|
<DiningPlate
|
||||||
key={mode}
|
key={mode}
|
||||||
size={280}
|
size={240}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
|
selectedId={String(selectedId)}
|
||||||
aria-label={t(ui.plate.plateAria, lang)}
|
aria-label={t(ui.plate.plateAria, lang)}
|
||||||
onSelect={(id) => focusStep(Number(id))}
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{visibleSteps.map((step) => (
|
{visibleSteps.map((step) => (
|
||||||
<div
|
<div key={step.n}>
|
||||||
key={step.n}
|
|
||||||
className={cx(
|
|
||||||
"transition-[opacity,transform] duration-300 ease-out",
|
|
||||||
"motion-reduce:transition-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<StepDetailCard
|
<StepDetailCard
|
||||||
step={step}
|
step={step}
|
||||||
open={openIds.has(step.n)}
|
open={openIds.has(step.n)}
|
||||||
@@ -258,7 +238,8 @@ export function PlateScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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)" }}
|
style={{ background: "var(--glass-fill)" }}
|
||||||
>
|
>
|
||||||
<p className="type-label mb-3 text-[var(--ink-faint)]">
|
<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 { Button } from "@/components/ui-pp/Button"
|
||||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||||
import { t, ui } from "@/i18n/ui"
|
import { t, ui } from "@/i18n/ui"
|
||||||
@@ -14,6 +15,8 @@ export function SettingsScreen() {
|
|||||||
const { lang, setLang } = useLanguage()
|
const { lang, setLang } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<KnowledgeBackLink />
|
||||||
<GlassCard className="p-4">
|
<GlassCard className="p-4">
|
||||||
<h1 className="type-display text-[var(--ink)]">
|
<h1 className="type-display text-[var(--ink)]">
|
||||||
{t(ui.stubs.settingsTitle, lang)}
|
{t(ui.stubs.settingsTitle, lang)}
|
||||||
@@ -54,5 +57,6 @@ export function SettingsScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,86 @@
|
|||||||
import { useMemo, useState } from "react"
|
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 { 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 { useLanguage } from "@/lib/language"
|
||||||
import { cx } from "@/lib/cx"
|
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> = {
|
function effortLabel(effort: Effort, lang: "de" | "en"): string {
|
||||||
breakfast: "🌅",
|
if (effort === "none") return t(ui.weekplan.effortNone, lang)
|
||||||
snack: "🥤",
|
if (effort === "quick") return t(ui.weekplan.effortQuick, lang)
|
||||||
full: "🍽️",
|
return t(ui.weekplan.effortCook, lang)
|
||||||
recipe: "📖",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeLabel(type: PlanRecipeType, lang: "de" | "en"): string {
|
/** Show when cooking, or when a concrete duration is 15+ minutes. */
|
||||||
return t(ui.weekplan[type], lang)
|
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 ingredients = resolveRecipeUses(recipe)
|
||||||
|
const showEffort = shouldShowEffortCaption(recipe.effort)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<article className="border-b border-[var(--border)] py-3 last:border-0 last:pb-0 first:pt-0">
|
||||||
{recipe.cooked !== undefined ? (
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<p className="type-label text-[var(--ink-faint)]">
|
<h3 className="type-title-sm min-w-0 text-[var(--ink)]">
|
||||||
{recipe.cooked ? t(ui.weekplan.cooked, lang) : t(ui.weekplan.cold, lang)}
|
{tr(recipe.name, lang)}
|
||||||
</p>
|
</h3>
|
||||||
) : null}
|
{showEffort ? <EffortCaption effort={recipe.effort} lang={lang} /> : null}
|
||||||
|
|
||||||
{recipe.text ? (
|
|
||||||
<p className="type-body text-[var(--ink-soft)]">{tr(recipe.text, lang)}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{recipe.variations && recipe.variations.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
|
||||||
{t(ui.weekplan.variations, lang)}
|
|
||||||
</p>
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{recipe.variations.map((variation, index) => (
|
|
||||||
<li
|
|
||||||
key={`${recipe.id}-var-${index}`}
|
|
||||||
className="type-body flex gap-2 text-[var(--ink-soft)]"
|
|
||||||
>
|
|
||||||
<span className="text-[var(--ink-faint)]" aria-hidden>
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<span>{tr(variation, lang)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
|
||||||
{t(ui.weekplan.uses, lang)}
|
|
||||||
</p>
|
|
||||||
<ul className="flex flex-wrap gap-2">
|
|
||||||
{ingredients.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.id}
|
|
||||||
className={cx(
|
|
||||||
"type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)]",
|
|
||||||
"ring-1 ring-[var(--border)]",
|
|
||||||
)}
|
|
||||||
style={{ background: "var(--glass-fill)" }}
|
|
||||||
>
|
|
||||||
{tr(item.name, lang)}
|
|
||||||
{item.source === "extra" ? (
|
|
||||||
<span className="ml-1 text-[var(--ink-faint)]">+</span>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<IngredientChips
|
||||||
|
className="mt-2.5"
|
||||||
|
items={ingredients}
|
||||||
|
maxVisible={4}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekPlanScreen() {
|
export function WeekPlanScreen() {
|
||||||
const { lang } = useLanguage()
|
const { lang } = useLanguage()
|
||||||
const [openTypes, setOpenTypes] = useState<Record<PlanRecipeType, boolean>>({
|
const [openTimes, setOpenTimes] = useState<Record<MealTime, boolean>>(() =>
|
||||||
breakfast: true,
|
Object.fromEntries(
|
||||||
snack: false,
|
MEAL_TIME_ORDER.map((time) => [time, time === "breakfast"]),
|
||||||
full: false,
|
) as Record<MealTime, boolean>,
|
||||||
recipe: false,
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
PLAN_TYPE_ORDER.map((type) => ({
|
MEAL_TIME_ORDER.map((time) => ({
|
||||||
type,
|
time,
|
||||||
items: recipesByType(type),
|
items: recipesForMealTime(time),
|
||||||
})),
|
})),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -101,6 +88,7 @@ export function WeekPlanScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<header>
|
<header>
|
||||||
|
<PlanBackLink />
|
||||||
<h1 className="type-display text-[var(--ink)]">
|
<h1 className="type-display text-[var(--ink)]">
|
||||||
{t(ui.weekplan.title, lang)}
|
{t(ui.weekplan.title, lang)}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -110,22 +98,25 @@ export function WeekPlanScreen() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{groups.map(({ type, items }) => (
|
{groups.map(({ time, items }) => (
|
||||||
<Accordion
|
<Accordion
|
||||||
key={type}
|
key={time}
|
||||||
open={openTypes[type]}
|
open={openTimes[time]}
|
||||||
onOpenChange={(open) =>
|
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}
|
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) => (
|
{items.map((recipe) => (
|
||||||
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
|
<RecipeRow key={recipe.id} recipe={recipe} lang={lang} />
|
||||||
<RecipeBody recipe={recipe} lang={lang} />
|
|
||||||
</NestedDisclosure>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -1,42 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Pocket Pascal — Elevated Surface Design Tokens
|
* Pocket Pascal — Elevated Surface Design Tokens
|
||||||
*
|
*
|
||||||
* Pivoted from heavy glassmorphism to a low-transparency, tactile system:
|
* Category palette (locked): Emerald Bridge + Neon Punch fat
|
||||||
* cards read as solid raised surfaces (elevation shadow + faint top/bottom
|
* --cat-protein #2563EB Blau
|
||||||
* bevel), buttons visibly depress on press (inset shadow + 1px downward
|
* --cat-produce #059669 Emerald (Obst + Gemüse)
|
||||||
* shift) instead of just scaling. `--glass-*` names kept for minimal diff
|
* --cat-fat #EAB308 Gold (from Neon Punch)
|
||||||
* across components — they now mean "surface fill / surface blur", not
|
* --cat-berry #C0267A Berry (Kohlenhydrate)
|
||||||
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
|
|
||||||
* whatever sits behind a card without reading as transparent.
|
|
||||||
*
|
*
|
||||||
* Toggle dark mode by adding/removing the `dark` class on <html>
|
* Signals (glyph-primary for pick/caution; color redundant):
|
||||||
* (matches Tailwind `darkMode: 'class'` — see README).
|
* --signal-success / --signal-error / --signal-warning
|
||||||
*
|
*
|
||||||
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited
|
* --ui-accent is non-semantic (chrome only — tabs, focus), not a plate category.
|
||||||
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
|
|
||||||
* section 05/08 for the full rationale and contrast numbers.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ---------- Elevated surface (formerly "glass") ---------- */
|
/* ---------- Elevated surface (formerly "glass") ---------- */
|
||||||
--glass-fill: rgba(255, 255, 255, 0.9);
|
--glass-fill: rgba(255, 255, 255, 0.9);
|
||||||
--glass-blur: 8px;
|
--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-from: rgba(255, 255, 255, 0.9);
|
||||||
--glass-border-to: rgba(0, 0, 0, 0.06);
|
--glass-border-to: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
/* ---------- Radius scale (nested: outer ≈ inner + padding) ---------- */
|
/* ---------- Radius scale ---------- */
|
||||||
--radius-card: 24px;
|
--radius-card: 24px;
|
||||||
--radius-nested: 12px;
|
--radius-nested: 12px;
|
||||||
--radius-control: 16px;
|
--radius-control: 16px;
|
||||||
--radius-pill: 9999px;
|
--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-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-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-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);
|
--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);
|
--shadow-ambient: var(--shadow-float);
|
||||||
|
|
||||||
/* ---------- Brand ink / neutrals ---------- */
|
/* ---------- Brand ink / neutrals ---------- */
|
||||||
@@ -45,43 +39,65 @@
|
|||||||
--ink-faint: #6b6a56;
|
--ink-faint: #6b6a56;
|
||||||
--border: #e4e2d8;
|
--border: #e4e2d8;
|
||||||
|
|
||||||
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */
|
/* ---------- Canonical category palette ---------- */
|
||||||
--color-protein: #d9f227;
|
--cat-protein: #2563eb;
|
||||||
--color-protein-dk: #2e3200;
|
--cat-produce: #059669;
|
||||||
--color-obstgem: #ff6a3d;
|
--cat-fat: #eab308;
|
||||||
--color-obstgem-dk: #3d1508;
|
--cat-berry: #c0267a;
|
||||||
--color-fett: #ffb300;
|
/* Text on category fills: gold needs dark ink; others use white */
|
||||||
--color-fett-dk: #3d2900;
|
--cat-fat-fg: #191800;
|
||||||
--color-kh: #1fd9c4;
|
--cat-on-dark: #ffffff;
|
||||||
--color-kh-dk: #04302b;
|
|
||||||
--color-pick: #0f7a37;
|
|
||||||
--color-caution: #c81e1e;
|
|
||||||
|
|
||||||
/* ---------- Plate / Baukasten category hues (from prototype) ---------- */
|
/* ---------- Signals (not categories) ---------- */
|
||||||
--green: #2f5233;
|
--signal-success: #0f7a37;
|
||||||
--green-soft: #e3ece4;
|
--signal-error: #c81e1e;
|
||||||
--terracotta: #b33d22;
|
--signal-warning: #ea580c;
|
||||||
--terracotta-soft: #f7e0d8;
|
|
||||||
--gold: #e8b93a;
|
|
||||||
--gold-soft: #fdf1d9;
|
|
||||||
--slate: #6b4c9a;
|
|
||||||
--slate-soft: #efe9f5;
|
|
||||||
|
|
||||||
/* ---------- 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%);
|
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
|
||||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
|
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
|
||||||
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
|
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
|
||||||
|
|
||||||
/* ---------- Action gradients (vibrant, multi-stop) ---------- */
|
/* Decorative UI gradients — lime-vibrant brand chrome (NOT category encoding).
|
||||||
/* NOTE: brand hues are light/high-luminance by design (see contrast audit),
|
Paired with --ink icons/text for WCAG AA on these light stops. */
|
||||||
so action gradients pair with --ink text, not white — this keeps every
|
|
||||||
button at AA contrast without text-shadow hacks. Do not swap to white
|
|
||||||
text on these without re-checking contrast. */
|
|
||||||
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
|
||||||
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
|
||||||
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
|
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
|
||||||
|
|
||||||
/* ---------- Dining Plate chart tokens ---------- */
|
|
||||||
--plate-rim-light: #ffffff;
|
--plate-rim-light: #ffffff;
|
||||||
--plate-rim-shadow: #d8d8d0;
|
--plate-rim-shadow: #d8d8d0;
|
||||||
--plate-well-center: #f7f7f2;
|
--plate-well-center: #f7f7f2;
|
||||||
@@ -105,17 +121,22 @@
|
|||||||
--ink-faint: #8f8e7a;
|
--ink-faint: #8f8e7a;
|
||||||
--border: #33342a;
|
--border: #33342a;
|
||||||
|
|
||||||
/* category primaries stay identical (they're the brand, not a theme) */
|
--cat-protein: #2563eb;
|
||||||
--color-protein: #d9f227;
|
--cat-produce: #059669;
|
||||||
--color-protein-dk: #2e3200;
|
--cat-fat: #eab308;
|
||||||
--color-obstgem: #ff6a3d;
|
--cat-berry: #c0267a;
|
||||||
--color-obstgem-dk: #3d1508;
|
--cat-fat-fg: #191800;
|
||||||
--color-fett: #ffb300;
|
--cat-on-dark: #ffffff;
|
||||||
--color-fett-dk: #3d2900;
|
|
||||||
--color-kh: #1fd9c4;
|
--signal-success: #4ade80;
|
||||||
--color-kh-dk: #04302b;
|
--signal-error: #ff6b60;
|
||||||
--color-pick: #4ade80;
|
--signal-warning: #fb923c;
|
||||||
--color-caution: #ff6b60;
|
|
||||||
|
--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%);
|
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
|
||||||
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
|
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
|
||||||
@@ -132,9 +153,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Fonts ---------- */
|
/* ---------- 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-face {
|
||||||
font-family: "Unbounded";
|
font-family: "Unbounded";
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -179,31 +197,30 @@
|
|||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Type ramp (single hierarchy) ---------- */
|
|
||||||
.type-display {
|
.type-display {
|
||||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 1.5rem; /* 24px */
|
font-size: 1.5rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.type-title {
|
.type-title {
|
||||||
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
font-family: "Unbounded", ui-sans-serif, sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.25rem; /* 20px */
|
font-size: 1.25rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.type-title-sm {
|
.type-title-sm {
|
||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem; /* 16px */
|
font-size: 1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.type-body {
|
.type-body {
|
||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 0.875rem; /* 14px */
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.type-body-emphasis {
|
.type-body-emphasis {
|
||||||
@@ -215,7 +232,7 @@
|
|||||||
.type-label {
|
.type-label {
|
||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.6875rem; /* 11px */
|
font-size: 0.6875rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -223,7 +240,7 @@
|
|||||||
.type-caption {
|
.type-caption {
|
||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 0.75rem; /* 12px */
|
font-size: 0.75rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.type-button {
|
.type-button {
|
||||||
@@ -234,31 +251,24 @@
|
|||||||
}
|
}
|
||||||
.type-button-secondary {
|
.type-button-secondary {
|
||||||
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
|
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;
|
font-size: 0.875rem;
|
||||||
line-height: 1.2;
|
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 {
|
.glass-border {
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
padding: 1px;
|
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));
|
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: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
|
|
||||||
.elevation-transition {
|
.elevation-transition {
|
||||||
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
|
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- PWA polish ---------- */
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|||||||
@@ -15,22 +15,36 @@ export type CategoryId =
|
|||||||
|
|
||||||
export type TagType = "pick" | "caution"
|
export type TagType = "pick" | "caution"
|
||||||
|
|
||||||
|
/** Plate size per building-block rule. */
|
||||||
export type MealType = "full" | "half"
|
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 {
|
export interface FoodItem {
|
||||||
id: string
|
id: string
|
||||||
cat: CategoryId
|
cat: CategoryId
|
||||||
name: LocalizedString
|
name: LocalizedString
|
||||||
|
/** Display string (macros or qualitative e.g. vitamins). */
|
||||||
valueInfo: LocalizedString
|
valueInfo: LocalizedString
|
||||||
|
/** Measurement basis for kcal / proteinG. */
|
||||||
|
basis: NutritionBasis
|
||||||
|
kcal?: number
|
||||||
|
proteinG?: number
|
||||||
tag?: TagType
|
tag?: TagType
|
||||||
|
/** Required when tag is caution — why enjoy mindfully. */
|
||||||
|
cautionWhy?: LocalizedString
|
||||||
isApprox?: boolean
|
isApprox?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +70,31 @@ export interface CatalogItem {
|
|||||||
cat?: CategoryId
|
cat?: CategoryId
|
||||||
valueInfo?: LocalizedString
|
valueInfo?: LocalizedString
|
||||||
tag?: TagType
|
tag?: TagType
|
||||||
|
cautionWhy?: LocalizedString
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated meal / snack / how-to recipes.
|
* Curated plate suggestion (half / full meal).
|
||||||
* `uses` references ids from CATS (foods) or EXTRAS.
|
* `uses` references ids from CATS (foods) or EXTRAS.
|
||||||
*/
|
*/
|
||||||
export interface Recipe {
|
export interface Recipe {
|
||||||
id: string
|
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
|
name: LocalizedString
|
||||||
uses: string[]
|
uses: string[]
|
||||||
cooked?: boolean
|
|
||||||
text?: LocalizedString
|
text?: LocalizedString
|
||||||
/** Optional flavor / protein twists shown under the base recipe text. */
|
|
||||||
variations?: LocalizedString[]
|
variations?: LocalizedString[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,8 @@
|
|||||||
"n": 2,
|
"n": 2,
|
||||||
"cls": "t",
|
"cls": "t",
|
||||||
"title": {
|
"title": {
|
||||||
"de": "Obst ODER Gemüse",
|
"de": "Obst & Gemüse",
|
||||||
"en": "Fruit OR Vegetables"
|
"en": "Fruit & Veggie"
|
||||||
},
|
},
|
||||||
"qty": {
|
"qty": {
|
||||||
"de": "eine Portion",
|
"de": "eine Portion",
|
||||||
|
|||||||
288
pnpm-lock.yaml
generated
288
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.18.2
|
specifier: ^7.18.2
|
||||||
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.16.1
|
specifier: ^4.16.1
|
||||||
version: 4.16.1(typescript@6.0.3)
|
version: 4.16.1(typescript@6.0.3)
|
||||||
@@ -1022,6 +1025,17 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0':
|
||||||
|
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||||
|
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.2':
|
'@rolldown/binding-android-arm64@1.2.2':
|
||||||
resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==}
|
resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1290,6 +1304,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.3':
|
'@tailwindcss/node@4.3.3':
|
||||||
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
||||||
|
|
||||||
@@ -1396,6 +1416,33 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -1446,6 +1493,9 @@ packages:
|
|||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2':
|
'@types/validate-npm-package-name@4.0.2':
|
||||||
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
||||||
|
|
||||||
@@ -1773,6 +1823,50 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.2:
|
||||||
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1806,6 +1900,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1945,6 +2042,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
|
resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.50.0:
|
||||||
|
resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==}
|
||||||
|
|
||||||
esbuild@0.28.1:
|
esbuild@0.28.1:
|
||||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1977,6 +2077,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
eventsource-parser@3.1.0:
|
eventsource-parser@3.1.0:
|
||||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -2239,6 +2342,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immer@11.1.15:
|
||||||
|
resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2253,6 +2359,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ip-address@10.4.0:
|
ip-address@10.4.0:
|
||||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -3038,6 +3148,21 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.8
|
react: ^19.2.8
|
||||||
|
|
||||||
|
react-is@19.2.8:
|
||||||
|
resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==}
|
||||||
|
|
||||||
|
react-redux@9.3.0:
|
||||||
|
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-router-dom@7.18.2:
|
react-router-dom@7.18.2:
|
||||||
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
|
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -3067,6 +3192,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==}
|
resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
recharts@3.10.1:
|
||||||
|
resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3555,6 +3696,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite-plugin-pwa@1.3.0:
|
vite-plugin-pwa@1.3.0:
|
||||||
resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==}
|
resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -4697,6 +4841,18 @@ snapshots:
|
|||||||
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.1.15
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.2.0
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.2':
|
'@rolldown/binding-android-arm64@1.2.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4863,6 +5019,10 @@ snapshots:
|
|||||||
|
|
||||||
'@sindresorhus/merge-streams@4.0.0': {}
|
'@sindresorhus/merge-streams@4.0.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.3':
|
'@tailwindcss/node@4.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -4957,6 +5117,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/express-serve-static-core@5.1.3':
|
'@types/express-serve-static-core@5.1.3':
|
||||||
@@ -5016,6 +5200,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))':
|
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))':
|
||||||
@@ -5342,6 +5528,44 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.2: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.2
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -5372,6 +5596,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-response: 3.1.0
|
mimic-response: 3.1.0
|
||||||
@@ -5547,6 +5773,8 @@ snapshots:
|
|||||||
is-date-object: 1.1.0
|
is-date-object: 1.1.0
|
||||||
is-symbol: 1.1.1
|
is-symbol: 1.1.1
|
||||||
|
|
||||||
|
es-toolkit@1.50.0: {}
|
||||||
|
|
||||||
esbuild@0.28.1:
|
esbuild@0.28.1:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.28.1
|
'@esbuild/aix-ppc64': 0.28.1
|
||||||
@@ -5590,6 +5818,8 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
eventsource-parser@3.1.0: {}
|
eventsource-parser@3.1.0: {}
|
||||||
|
|
||||||
eventsource@3.0.7:
|
eventsource@3.0.7:
|
||||||
@@ -5933,6 +6163,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
|
immer@11.1.15: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -5948,6 +6180,8 @@ snapshots:
|
|||||||
hasown: 2.0.4
|
hasown: 2.0.4
|
||||||
side-channel: 1.1.1
|
side-channel: 1.1.1
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
ip-address@10.4.0: {}
|
ip-address@10.4.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
@@ -6611,6 +6845,17 @@ snapshots:
|
|||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-is@19.2.8: {}
|
||||||
|
|
||||||
|
react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.8
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.18
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
@@ -6641,6 +6886,32 @@ snapshots:
|
|||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.50.0
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
immer: 11.1.15
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
react-is: 19.2.8
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
|
||||||
|
reselect: 5.2.0
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.8)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -7274,6 +7545,23 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.8
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite-plugin-pwa@1.3.0(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))(workbox-build@7.4.1)(workbox-window@7.4.1):
|
vite-plugin-pwa@1.3.0(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))(workbox-build@7.4.1)(workbox-window@7.4.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user