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:
42
client/src/components/food/CategorySoftChip.tsx
Normal file
42
client/src/components/food/CategorySoftChip.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { categoryDotColor } from "@/lib/catalog"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CategoryId } from "@/types/domain"
|
||||
|
||||
const CAT_ICON: Record<CategoryId, string> = {
|
||||
protein: "🥚",
|
||||
snack: "🥤",
|
||||
obst: "🍎",
|
||||
gemuese: "🥦",
|
||||
fett: "🥑",
|
||||
kh: "🌾",
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-Chip category marker (Vorrat accordion headers).
|
||||
* Category hue washed with white — readable emoji, no side stripe on cards.
|
||||
*/
|
||||
export function CategorySoftChip({
|
||||
categoryId,
|
||||
className,
|
||||
}: {
|
||||
categoryId: CategoryId
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"flex h-9 w-9 items-center justify-center rounded-[var(--radius-control)]",
|
||||
"text-lg leading-none ring-1 ring-[var(--border)]",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: categoryDotColor(categoryId),
|
||||
boxShadow:
|
||||
"inset 0 0 0 999px color-mix(in srgb, white 72%, transparent)",
|
||||
}}
|
||||
>
|
||||
{CAT_ICON[categoryId]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
159
client/src/components/food/IngredientChip.tsx
Normal file
159
client/src/components/food/IngredientChip.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Popover } from "@base-ui/react/popover"
|
||||
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { foodCategoryById } from "@/lib/catalog"
|
||||
import { categoryChipSurface, chipAriaLabel, chipTagModifiers } from "@/lib/chipStyle"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CatalogItem, Language } from "@/types/domain"
|
||||
|
||||
function detailAria(name: string, lang: Language): string {
|
||||
return t(ui.ingredients.detailAria, lang).replace("{name}", name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip + flyout (Base UI Popover). Tap opens; tap same chip / outside closes.
|
||||
* Content: valueInfo · caution (if tagged) · category Daumenregel.
|
||||
* Name is not repeated — the chip already shows it.
|
||||
*/
|
||||
export function IngredientChip({
|
||||
item,
|
||||
size,
|
||||
}: {
|
||||
item: CatalogItem
|
||||
size: "regular" | "large"
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const large = size === "large"
|
||||
const surface = categoryChipSurface(item.cat)
|
||||
const tagMod = chipTagModifiers(item.tag)
|
||||
const name = tr(item.name, lang)
|
||||
const label = chipAriaLabel(
|
||||
name,
|
||||
item.tag,
|
||||
t(ui.pantry.pick, lang),
|
||||
t(ui.pantry.caution, lang),
|
||||
)
|
||||
const category = item.cat ? foodCategoryById(item.cat) : undefined
|
||||
const valueInfo = item.valueInfo ? tr(item.valueInfo, lang) : null
|
||||
/** Obst: valueInfo is enough — no category tip in the flyout. */
|
||||
const showThumb = Boolean(category && item.cat !== "obst")
|
||||
const thumb = showThumb && category ? tr(category.thumb, lang) : null
|
||||
const categoryTitle = showThumb && category ? tr(category.title, lang) : null
|
||||
const showCaution = item.tag === "caution"
|
||||
const cautionWhy =
|
||||
showCaution && item.cautionWhy ? tr(item.cautionWhy, lang) : null
|
||||
|
||||
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
|
||||
const chipType = large
|
||||
? "type-body-emphasis text-[var(--ink)]"
|
||||
: "type-caption font-bold text-[var(--ink)]"
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
aria-label={detailAria(label, lang)}
|
||||
title={item.tag ? label : undefined}
|
||||
className={cx(
|
||||
"inline-flex items-center rounded-[var(--radius-pill)]",
|
||||
"cursor-pointer transition-[filter] active:brightness-[0.98]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
|
||||
"data-[popup-open]:ring-2 data-[popup-open]:ring-[var(--ink)]/25",
|
||||
chipType,
|
||||
chipBox,
|
||||
tagMod.className,
|
||||
)}
|
||||
style={{ ...surface, ...tagMod.style }}
|
||||
>
|
||||
<span className={cx("truncate", large ? "max-w-[10rem]" : "max-w-[8rem]")}>
|
||||
{name}
|
||||
</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
{/*
|
||||
Prefer bottom-end (bottom-right of chip). Collision avoidance flips
|
||||
when the panel would leave the viewport. No scale/transform — keeps
|
||||
backdrop-filter working.
|
||||
*/}
|
||||
<Popover.Positioner
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="z-50 outline-none"
|
||||
>
|
||||
<Popover.Popup
|
||||
className={cx(
|
||||
"relative w-[min(18rem,calc(100vw-2rem))] rounded-[var(--radius-card)] p-3",
|
||||
"shadow-[var(--shadow-float)]",
|
||||
"data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
|
||||
"transition-opacity duration-150 ease-out",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--glass-fill) 12%, transparent)",
|
||||
backdropFilter: "blur(28px) saturate(1.6)",
|
||||
WebkitBackdropFilter: "blur(28px) saturate(1.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-40"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(145deg, color-mix(in srgb, white 45%, transparent) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
/>
|
||||
|
||||
{/* Solid ink on glass — ink-soft fails AA over busy blurred chips */}
|
||||
<div className="relative z-10 [text-shadow:0_0.5px_0_color-mix(in_srgb,white_55%,transparent)]">
|
||||
<Popover.Title className="sr-only">{name}</Popover.Title>
|
||||
|
||||
{valueInfo ? (
|
||||
<Popover.Description className="type-body-emphasis text-[var(--ink)]">
|
||||
{valueInfo}
|
||||
</Popover.Description>
|
||||
) : null}
|
||||
|
||||
{showCaution ? (
|
||||
<div className={valueInfo ? "mt-2" : undefined}>
|
||||
<p className="type-caption font-bold text-[var(--signal-warning)]">
|
||||
{t(ui.pantry.caution, lang)}
|
||||
</p>
|
||||
{cautionWhy ? (
|
||||
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
|
||||
{cautionWhy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{thumb ? (
|
||||
<div
|
||||
className={cx(
|
||||
"border-t border-[var(--border)] pt-2.5",
|
||||
valueInfo || showCaution ? "mt-3" : undefined,
|
||||
)}
|
||||
>
|
||||
<p className="type-label text-[var(--ink-faint)]">
|
||||
{t(ui.pantry.tip, lang)}
|
||||
{categoryTitle ? ` · ${categoryTitle}` : null}
|
||||
</p>
|
||||
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
|
||||
{thumb}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
73
client/src/components/food/IngredientChips.tsx
Normal file
73
client/src/components/food/IngredientChips.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { IngredientChip } from "@/components/food/IngredientChip"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { CatalogItem } from "@/types/domain"
|
||||
|
||||
/**
|
||||
* Ingredient chips — two sizes only:
|
||||
* - `regular` (default): browse lists (Wochenplan, How-to, Builder, …)
|
||||
* - `large`: Jetzt suggestion only
|
||||
*
|
||||
* Each chip opens a flyout (tap again / outside to close).
|
||||
*/
|
||||
export function IngredientChips({
|
||||
items,
|
||||
maxVisible,
|
||||
className,
|
||||
size = "regular",
|
||||
}: {
|
||||
items: CatalogItem[]
|
||||
maxVisible?: number
|
||||
className?: string
|
||||
size?: "regular" | "large"
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const large = size === "large"
|
||||
const overflow =
|
||||
maxVisible !== undefined ? Math.max(0, items.length - maxVisible) : 0
|
||||
const visible =
|
||||
maxVisible !== undefined && !expanded
|
||||
? items.slice(0, maxVisible)
|
||||
: items
|
||||
|
||||
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
|
||||
|
||||
return (
|
||||
<ul className={cx("flex flex-wrap", large ? "gap-2" : "gap-1.5", className)}>
|
||||
{visible.map((item) => (
|
||||
<li key={item.id} className="inline-flex">
|
||||
<IngredientChip item={item} size={size} />
|
||||
</li>
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? t(ui.ingredients.showLess, lang)
|
||||
: t(ui.ingredients.showMore, lang)
|
||||
}
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
className={cx(
|
||||
"inline-flex items-center rounded-[var(--radius-pill)] ring-1 ring-[var(--border)]",
|
||||
"text-[var(--ink-faint)] cursor-pointer transition-colors",
|
||||
"hover:text-[var(--ink)] focus-visible:outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
|
||||
large ? "type-body-emphasis" : "type-caption font-bold",
|
||||
chipBox,
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{expanded ? `−${overflow}` : `+${overflow}`}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ChefHat } from "lucide-react"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
/** Navigation tile — whole card is the hit target (same pattern as KnowledgeHub). */
|
||||
export function BuilderSlot() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
variant="interactive"
|
||||
className="p-4"
|
||||
onClick={() => navigate("/builder")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
navigate("/builder")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
|
||||
style={{ backgroundImage: "var(--gradient-primary)" }}
|
||||
>
|
||||
<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="type-title-sm text-[var(--ink)]">
|
||||
{t(ui.builderSlot.title, lang)}
|
||||
</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">
|
||||
{t(ui.builderSlot.body, lang)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +1,23 @@
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { BuilderSlot } from "@/components/home/BuilderSlot"
|
||||
import { KnowledgeHub } from "@/components/home/KnowledgeHub"
|
||||
import { SuggestionCard } from "@/components/home/SuggestionCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import {
|
||||
DEFAULT_WIDGET_ORDER,
|
||||
moveWidget,
|
||||
readWidgetOrder,
|
||||
writeWidgetOrder,
|
||||
} from "@/lib/widgetOrder"
|
||||
import type { HomeWidgetId, WidgetOrder } from "@/types/domain"
|
||||
|
||||
function WidgetShell({
|
||||
id,
|
||||
editing,
|
||||
order,
|
||||
onMove,
|
||||
children,
|
||||
}: {
|
||||
id: HomeWidgetId
|
||||
editing: boolean
|
||||
order: WidgetOrder
|
||||
onMove: (id: HomeWidgetId, direction: "up" | "down") => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const index = order.indexOf(id)
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
{editing ? (
|
||||
<div className="mb-2 flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(ui.home.moveUp, lang)}
|
||||
disabled={index <= 0}
|
||||
onClick={() => onMove(id, "up")}
|
||||
className={cx(
|
||||
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
|
||||
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
"disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(ui.home.moveDown, lang)}
|
||||
disabled={index >= order.length - 1}
|
||||
onClick={() => onMove(id, "down")}
|
||||
className={cx(
|
||||
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
|
||||
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
"disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus-First Start: only the hunger suggestion — tools live in Küche / Planen. */
|
||||
export function HomeScreen() {
|
||||
const { lang } = useLanguage()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [order, setOrder] = useState<WidgetOrder>(DEFAULT_WIDGET_ORDER)
|
||||
|
||||
useEffect(() => {
|
||||
setOrder(readWidgetOrder())
|
||||
}, [])
|
||||
|
||||
function handleMove(id: HomeWidgetId, direction: "up" | "down") {
|
||||
setOrder((current) => {
|
||||
const next = moveWidget(current, id, direction)
|
||||
writeWidgetOrder(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const widgets: Record<HomeWidgetId, ReactNode> = {
|
||||
suggestion: <SuggestionCard />,
|
||||
knowledge: <KnowledgeHub />,
|
||||
builder: <BuilderSlot />,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
|
||||
<h1 className="type-display mt-1 text-[var(--ink)]">
|
||||
{t(ui.home.title, lang)}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing((value) => !value)}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[48px] items-center rounded-[var(--radius-pill)] px-4",
|
||||
"type-button text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{editing ? t(ui.home.doneEdit, lang) : t(ui.home.editLayout, lang)}
|
||||
</button>
|
||||
<header>
|
||||
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
|
||||
<h1 className="type-display mt-1 text-[var(--ink)]">
|
||||
{t(ui.home.title, lang)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{order.map((id) => (
|
||||
<WidgetShell
|
||||
key={id}
|
||||
id={id}
|
||||
editing={editing}
|
||||
order={order}
|
||||
onMove={handleMove}
|
||||
>
|
||||
{widgets[id]}
|
||||
</WidgetShell>
|
||||
))}
|
||||
<section aria-label={t(ui.home.title, lang)}>
|
||||
<SuggestionCard />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { BookOpen, CalendarDays, Refrigerator } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
function HubTile({
|
||||
title,
|
||||
body,
|
||||
gradient,
|
||||
icon,
|
||||
onNavigate,
|
||||
}: {
|
||||
title: string
|
||||
body: string
|
||||
gradient: string
|
||||
icon: ReactNode
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
return (
|
||||
<GlassCard
|
||||
variant="interactive"
|
||||
className="p-4"
|
||||
onClick={onNavigate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onNavigate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
|
||||
style={{ backgroundImage: gradient }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="type-title-sm text-[var(--ink)]">{title}</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeHub() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.plateTitle, lang)}
|
||||
body={t(ui.knowledgeHub.plateBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/knowledge")}
|
||||
/>
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.pantryTitle, lang)}
|
||||
body={t(ui.knowledgeHub.pantryBody, lang)}
|
||||
gradient="var(--gradient-accent)"
|
||||
icon={<Refrigerator className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/pantry")}
|
||||
/>
|
||||
<HubTile
|
||||
title={t(ui.knowledgeHub.weekplanTitle, lang)}
|
||||
body={t(ui.knowledgeHub.weekplanBody, lang)}
|
||||
gradient="var(--gradient-primary)"
|
||||
icon={<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
|
||||
onNavigate={() => navigate("/weekplan")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
client/src/components/home/NavCard.tsx
Normal file
131
client/src/components/home/NavCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import type { LocalizedString } from "@/types/domain"
|
||||
|
||||
export interface NavCardProps {
|
||||
title: string
|
||||
body: string
|
||||
icon: ReactNode
|
||||
gradient?: string
|
||||
/** Required when interactive; ignored when `disabled`. */
|
||||
onNavigate?: () => void
|
||||
className?: string
|
||||
size?: "default" | "featured"
|
||||
/** Status chip, e.g. Demnächst — typically paired with `disabled`. */
|
||||
badge?: string
|
||||
/** Coming-soon / unavailable: not clickable, no navigation. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Navigation card — noun-phrase body, ≥44px touch target when interactive. */
|
||||
export function NavCard({
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
gradient = "var(--gradient-primary)",
|
||||
onNavigate,
|
||||
className,
|
||||
size = "default",
|
||||
badge,
|
||||
disabled = false,
|
||||
}: NavCardProps) {
|
||||
const featured = size === "featured"
|
||||
const interactive = !disabled && Boolean(onNavigate)
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
variant={interactive ? "interactive" : "default"}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cx(
|
||||
featured ? "p-5" : "p-4",
|
||||
"min-h-[44px]",
|
||||
disabled && "opacity-55",
|
||||
className,
|
||||
)}
|
||||
onClick={interactive ? onNavigate : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onNavigate?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cx(
|
||||
"flex items-center justify-center rounded-[var(--radius-control)] text-[var(--ink)]",
|
||||
"[&_svg]:text-[var(--ink)]",
|
||||
featured ? "h-12 w-12" : "h-10 w-10",
|
||||
)}
|
||||
style={{ backgroundImage: gradient }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{badge ? (
|
||||
<span className="type-caption rounded-[var(--radius-pill)] px-2 py-1 font-bold text-[var(--ink-faint)] ring-1 ring-[var(--border)]">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className={cx(
|
||||
"text-[var(--ink)]",
|
||||
featured ? "type-title" : "type-title-sm",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubBackLink({
|
||||
to,
|
||||
label,
|
||||
}: {
|
||||
to: string
|
||||
label: LocalizedString
|
||||
}) {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(to)}
|
||||
className={cx(
|
||||
"type-caption mb-1 inline-flex min-h-[44px] items-center gap-1 font-bold text-[var(--ink-soft)]",
|
||||
"active:brightness-95",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>‹</span>
|
||||
{t(label, lang)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeBackLink() {
|
||||
return <HubBackLink to="/knowledge" label={ui.knowledge.back} />
|
||||
}
|
||||
|
||||
export function KitchenBackLink() {
|
||||
return <HubBackLink to="/kitchen" label={ui.kitchen.back} />
|
||||
}
|
||||
|
||||
export function PlanBackLink() {
|
||||
return <HubBackLink to="/plan" label={ui.planHub.back} />
|
||||
}
|
||||
@@ -1,127 +1,135 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Pin, RefreshCw } from "lucide-react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
import { IngredientChips } from "@/components/food/IngredientChips"
|
||||
import { Button } from "@/components/ui-pp/Button"
|
||||
import { GlassCard } from "@/components/ui-pp/GlassCard"
|
||||
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
|
||||
import { t, tr, ui } from "@/i18n/ui"
|
||||
import { resolveRecipeUses } from "@/lib/catalog"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
import { cx } from "@/lib/cx"
|
||||
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion"
|
||||
import { pickSuggestion } from "@/lib/suggestion"
|
||||
import { formatClock, getTimeContext } from "@/lib/timeContext"
|
||||
import type { FoodItem, Recipe, TimeSlot } from "@/types/domain"
|
||||
import type { Effort, MealTime, MealType, Recipe } from "@/types/domain"
|
||||
|
||||
const CAT_DOT: Record<string, string> = {
|
||||
protein: "var(--color-protein)",
|
||||
obst: "var(--color-obstgem)",
|
||||
gemuese: "var(--color-obstgem)",
|
||||
fett: "var(--color-fett)",
|
||||
kh: "var(--color-kh)",
|
||||
snack: "var(--color-protein)",
|
||||
const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"]
|
||||
|
||||
function effortLabel(effort: Effort, lang: "de" | "en"): string {
|
||||
if (effort === "none") return t(ui.suggestion.effortNone, lang)
|
||||
if (effort === "quick") return t(ui.suggestion.effortQuick, lang)
|
||||
return t(ui.suggestion.effortCook, lang)
|
||||
}
|
||||
|
||||
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string {
|
||||
return t(ui.suggestion[slot], lang)
|
||||
function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string {
|
||||
return t(ui.suggestion[mealTime], lang)
|
||||
}
|
||||
|
||||
function plateLabel(mealType: MealType, lang: "de" | "en"): string {
|
||||
return mealType === "full"
|
||||
? t(ui.suggestion.plateFull, lang)
|
||||
: t(ui.suggestion.plateHalf, lang)
|
||||
}
|
||||
|
||||
export function SuggestionCard() {
|
||||
const { lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const context = useMemo(() => getTimeContext(), [])
|
||||
const [effort, setEffort] = useState<Effort | null>(null)
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(() =>
|
||||
pickSuggestion(context.mealTime, null),
|
||||
)
|
||||
|
||||
const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot))
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set())
|
||||
const ingredients = recipe ? resolveRecipeUses(recipe) : []
|
||||
const clock = formatClock(context.hour, context.minute)
|
||||
const plateType = recipe?.type ?? context.mealType
|
||||
const eyebrow = `${mealTimeLabel(context.mealTime, lang)} · ${plateLabel(plateType, lang)}`
|
||||
|
||||
const recipe: Recipe | null = suggestion.recipe
|
||||
const items: FoodItem[] = suggestion.foods
|
||||
function applyEffort(next: Effort | null) {
|
||||
setEffort(next)
|
||||
setRecipe(pickSuggestion(context.mealTime, next, recipe?.id))
|
||||
}
|
||||
|
||||
const clock = formatClock(context.hour, context.minute, lang)
|
||||
const header =
|
||||
lang === "de"
|
||||
? `${clock} ${t(ui.suggestion.timeSuffix, lang)} · ${slotLabel(context.slot, lang)}`
|
||||
: `${clock} · ${slotLabel(context.slot, lang)}`
|
||||
|
||||
const title = recipe ? tr(recipe.name, lang) : t(ui.suggestion.fallbackName, lang)
|
||||
|
||||
function togglePin(id: string) {
|
||||
setPinnedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
function toggleEffort(value: Effort) {
|
||||
applyEffort(effort === value ? null : value)
|
||||
}
|
||||
|
||||
function reroll() {
|
||||
if (pinnedIds.size === 0) {
|
||||
setSuggestion(buildSuggestion(context.slot, recipe?.id))
|
||||
return
|
||||
}
|
||||
setSuggestion({
|
||||
recipe: null,
|
||||
foods: reshuffleSuggestion(items, pinnedIds, context.slot),
|
||||
})
|
||||
}
|
||||
|
||||
function sendToBuilder() {
|
||||
navigate("/builder", {
|
||||
state: {
|
||||
ingredientIds: items.map((item) => item.id),
|
||||
source: "suggestion" as const,
|
||||
},
|
||||
})
|
||||
setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined))
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard className="p-4">
|
||||
<GlassCard className="p-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="type-label text-[var(--ink-faint)]">{header}</p>
|
||||
<h2 className="type-title text-[var(--ink)]">{title}</h2>
|
||||
<div className="flex gap-3">
|
||||
<TimeIconBadge
|
||||
hour={context.hour}
|
||||
mealTime={context.mealTime}
|
||||
aria-label={eyebrow}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="type-label uppercase tracking-wide text-[var(--ink-soft)]">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<p className="type-caption text-[var(--ink-faint)]" aria-hidden>
|
||||
{clock}
|
||||
{lang === "de" ? " Uhr" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{recipe ? (
|
||||
<h2 className="type-title text-[var(--ink)]">
|
||||
{tr(recipe.name, lang)}
|
||||
</h2>
|
||||
) : (
|
||||
<p className="type-body text-[var(--ink-soft)]">
|
||||
{t(ui.suggestion.emptyFilter, lang)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => {
|
||||
const pinned = pinnedIds.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => togglePin(item.id)}
|
||||
aria-pressed={pinned}
|
||||
aria-label={`${tr(item.name, lang)} — ${pinned ? t(ui.suggestion.unpin, lang) : t(ui.suggestion.pin, lang)}`}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[48px] items-center gap-2 rounded-[var(--radius-pill)] px-3",
|
||||
"type-body-emphasis text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
pinned && "ring-2 ring-[var(--color-pick)]/50",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: CAT_DOT[item.cat] ?? "var(--ink-faint)" }}
|
||||
/>
|
||||
<span>{tr(item.name, lang)}</span>
|
||||
{pinned ? (
|
||||
<Pin className="h-3.5 w-3.5 text-[var(--color-pick)]" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{ingredients.length > 0 ? (
|
||||
<IngredientChips items={ingredients} size="large" />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="type-label mb-2 text-[var(--ink-faint)]">
|
||||
{t(ui.suggestion.effortLabel, lang)}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t(ui.suggestion.effortLabel, lang)}
|
||||
>
|
||||
{EFFORT_OPTIONS.map((option) => {
|
||||
const active = effort === option
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleEffort(option)}
|
||||
className={cx(
|
||||
"elevation-transition inline-flex min-h-[44px] items-center rounded-[var(--radius-pill)] px-3",
|
||||
"type-caption font-bold text-[var(--ink)]",
|
||||
"ring-1 ring-[var(--border)]",
|
||||
"active:translate-y-px active:brightness-95",
|
||||
active && "ring-2 ring-[var(--ui-accent)]/40",
|
||||
)}
|
||||
style={{ background: "var(--glass-fill)" }}
|
||||
>
|
||||
{effortLabel(option, lang)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="secondary" className="flex-1" onClick={reroll}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
||||
{t(ui.suggestion.reroll, lang)}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={sendToBuilder}>
|
||||
{t(ui.suggestion.toBuilder, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={reroll}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden />
|
||||
{t(ui.suggestion.reroll, lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom"
|
||||
import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react"
|
||||
import { BookOpen, CalendarDays, CookingPot, Sparkles } from "lucide-react"
|
||||
|
||||
import { BottomNav } from "@/components/ui-pp/BottomNav"
|
||||
import { t, ui } from "@/i18n/ui"
|
||||
import { useLanguage } from "@/lib/language"
|
||||
|
||||
const ROUTES = [
|
||||
{ key: "home", path: "/", icon: Home, labelKey: "home" as const },
|
||||
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const },
|
||||
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const },
|
||||
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const },
|
||||
{ key: "now", path: "/", icon: Sparkles, labelKey: "now" as const },
|
||||
{ key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const },
|
||||
{ key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const },
|
||||
{
|
||||
key: "knowledge",
|
||||
path: "/knowledge",
|
||||
icon: BookOpen,
|
||||
labelKey: "knowledge" as const,
|
||||
},
|
||||
]
|
||||
|
||||
export function AppShell() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
@@ -12,7 +13,9 @@ export interface PlateSegment {
|
||||
|
||||
export interface DiningPlateProps {
|
||||
segments: PlateSegment[]
|
||||
/** Mobile-first default. */
|
||||
size?: number
|
||||
selectedId?: string | null
|
||||
onSelect?: (id: string) => void
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
@@ -23,160 +26,189 @@ function polar(centerX: number, centerY: number, r: number, angleDeg: number) {
|
||||
return { x: centerX + r * Math.cos(rad), y: centerY + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
/** Filled pie wedge from center (food on the plate — not a thin outer ring). */
|
||||
function wedgePath(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
r: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): string {
|
||||
if (endAngle - startAngle >= 359.9) {
|
||||
return [
|
||||
`M ${centerX} ${centerY - r}`,
|
||||
`A ${r} ${r} 0 1 1 ${centerX} ${centerY + r}`,
|
||||
`A ${r} ${r} 0 1 1 ${centerX} ${centerY - r}`,
|
||||
"Z",
|
||||
].join(" ")
|
||||
}
|
||||
const start = polar(centerX, centerY, r, startAngle)
|
||||
const end = polar(centerX, centerY, r, endAngle)
|
||||
const large = endAngle - startAngle > 180 ? 1 : 0
|
||||
return [
|
||||
`M ${centerX} ${centerY}`,
|
||||
`L ${start.x} ${start.y}`,
|
||||
`A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`,
|
||||
"Z",
|
||||
].join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceramic dining plate with food-like category wedges inside the rim.
|
||||
* Rim depth via CSS inset shadows; wedges are SVG (clickable).
|
||||
* Ceramic dining plate with detached, rounded pie wedges (Recharts).
|
||||
* padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile.
|
||||
*/
|
||||
export function DiningPlate({
|
||||
segments,
|
||||
size = 280,
|
||||
size = 240,
|
||||
selectedId = null,
|
||||
onSelect,
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: DiningPlateProps) {
|
||||
const uid = useId().replace(/:/g, "")
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const rimPad = size * 0.09
|
||||
const rimPad = size * 0.14
|
||||
const foodR = size / 2 - rimPad
|
||||
const gapDeg = 2.5
|
||||
const center = size / 2
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
|
||||
let angle = 0
|
||||
const wedges = segments.map((segment) => {
|
||||
const sweep = (segment.weight / total) * 360
|
||||
const start = angle + gapDeg / 2
|
||||
const end = angle + sweep - gapDeg / 2
|
||||
angle += sweep
|
||||
return {
|
||||
...segment,
|
||||
start,
|
||||
end,
|
||||
d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null,
|
||||
}
|
||||
})
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
segments.map((segment) => ({
|
||||
id: segment.id,
|
||||
value: segment.weight,
|
||||
color: segment.color,
|
||||
label: segment.label,
|
||||
})),
|
||||
[segments],
|
||||
)
|
||||
|
||||
/** Mid-angles for rim dots / floating pill (Recharts starts at +90° / 12 o'clock). */
|
||||
const markers = useMemo(() => {
|
||||
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
|
||||
const padAngle = 5
|
||||
const padTotal = padAngle * segments.length
|
||||
const usable = 360 - padTotal
|
||||
let angle = 0
|
||||
return segments.map((segment) => {
|
||||
const sweep = (segment.weight / total) * usable
|
||||
const start = angle + padAngle / 2
|
||||
const end = angle + padAngle / 2 + sweep
|
||||
const mid = (start + end) / 2
|
||||
angle += sweep + padAngle
|
||||
return {
|
||||
id: segment.id,
|
||||
label: segment.label,
|
||||
color: segment.color,
|
||||
marker: polar(center, center, foodR + rimPad * 0.48, mid),
|
||||
pill: polar(center, center, foodR + rimPad * 1.45, mid),
|
||||
}
|
||||
})
|
||||
}, [segments, center, foodR, rimPad])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("relative select-none", className)}
|
||||
style={{ width: size, height: size }}
|
||||
className={cx("relative mx-auto select-none overflow-visible", className)}
|
||||
style={{ width: size, height: size, maxWidth: "100%" }}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* Ceramic body + raised rim */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 45%, var(--plate-well-edge) 100%)",
|
||||
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 48%, var(--plate-well-edge) 100%)",
|
||||
boxShadow: `
|
||||
0 8px 24px rgba(25, 24, 0, 0.12),
|
||||
inset 4px 5px 10px rgba(255, 255, 255, 0.85),
|
||||
inset -5px -6px 12px rgba(25, 24, 0, 0.12),
|
||||
inset 0 0 0 1px rgba(25, 24, 0, 0.06)
|
||||
0 6px 18px rgba(25, 24, 0, 0.1),
|
||||
inset 5px 6px 12px rgba(255, 255, 255, 0.9),
|
||||
inset -6px -7px 14px rgba(25, 24, 0, 0.11),
|
||||
inset 0 0 0 1px rgba(25, 24, 0, 0.05)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft recessed well under the food */}
|
||||
{/* Recessed well — ceramic shows through wedge gaps */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
className="absolute overflow-hidden rounded-full"
|
||||
style={{
|
||||
inset: rimPad * 0.55,
|
||||
inset: rimPad,
|
||||
background:
|
||||
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
|
||||
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
|
||||
"radial-gradient(circle at 50% 42%, #fbfbf7 0%, #ecece4 100%)",
|
||||
boxShadow: "inset 0 2px 10px rgba(25, 24, 0, 0.07)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="relative z-10"
|
||||
>
|
||||
<defs>
|
||||
<filter id={`${uid}-food-soft`} x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="blur" />
|
||||
<feOffset dy="1" result="off" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.18" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter={`url(#${uid}-food-soft)`}>
|
||||
{wedges.map(
|
||||
(wedge) =>
|
||||
wedge.d && (
|
||||
<path
|
||||
key={wedge.id}
|
||||
d={wedge.d}
|
||||
fill={wedge.color}
|
||||
opacity={0.92}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={0}
|
||||
outerRadius="98%"
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
paddingAngle={5}
|
||||
cornerRadius={10}
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
onClick={(_, index) => {
|
||||
const segment = chartData[index]
|
||||
if (segment) onSelect?.(segment.id)
|
||||
}}
|
||||
>
|
||||
{chartData.map((entry) => (
|
||||
<Cell
|
||||
key={entry.id}
|
||||
fill={entry.color}
|
||||
fillOpacity={
|
||||
selectedId && selectedId !== entry.id ? 0.72 : 0.96
|
||||
}
|
||||
className={cx(
|
||||
"outline-none transition-[opacity,filter] duration-150",
|
||||
onSelect &&
|
||||
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
|
||||
"outline-none",
|
||||
onSelect && "cursor-pointer focus:outline-none",
|
||||
)}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
role={onSelect ? "button" : undefined}
|
||||
aria-label={wedge.label}
|
||||
onClick={() => onSelect?.(wedge.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (!onSelect) return
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onSelect(wedge.id)
|
||||
}
|
||||
}}
|
||||
style={{ outline: "none" }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Subtle inner rim line separating food from ceramic lip */}
|
||||
<circle
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
r={foodR + 1}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.55)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</svg>
|
||||
{/* Rim markers */}
|
||||
{markers.map((marker) => {
|
||||
const selected = selectedId === marker.id
|
||||
return (
|
||||
<button
|
||||
key={`marker-${marker.id}`}
|
||||
type="button"
|
||||
aria-label={marker.label}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect?.(marker.id)}
|
||||
className={cx(
|
||||
"absolute z-20 -translate-x-1/2 -translate-y-1/2",
|
||||
"flex min-h-[44px] min-w-[44px] items-center justify-center",
|
||||
"no-select [-webkit-tap-highlight-color:transparent]",
|
||||
)}
|
||||
style={{ left: marker.marker.x, top: marker.marker.y }}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"elevation-transition block rounded-full",
|
||||
selected ? "h-3.5 w-3.5" : "h-2.5 w-2.5",
|
||||
)}
|
||||
style={{
|
||||
background: marker.color,
|
||||
boxShadow: selected
|
||||
? "0 0 0 3px rgba(255,255,255,0.95), 0 0 0 5px rgba(25,24,0,0.2), 0 2px 6px rgba(25,24,0,0.18)"
|
||||
: "0 0 0 2px rgba(255,255,255,0.95), 0 0 0 3px rgba(25,24,0,0.18)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Floating label pill for selection */}
|
||||
{markers.map((marker) => {
|
||||
if (selectedId !== marker.id) return null
|
||||
return (
|
||||
<div
|
||||
key={`pill-${marker.id}`}
|
||||
className={cx(
|
||||
"pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2",
|
||||
"type-caption whitespace-nowrap rounded-[var(--radius-pill)] px-3 py-1.5 font-bold text-[var(--ink)]",
|
||||
"shadow-[var(--shadow-float)] ring-1 ring-[var(--border)]",
|
||||
)}
|
||||
style={{
|
||||
left: marker.pill.x,
|
||||
top: marker.pill.y,
|
||||
background: "var(--glass-fill)",
|
||||
backdropFilter: "blur(var(--glass-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--glass-blur))",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
|
||||
style={{ background: marker.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
{marker.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export function Accordion({
|
||||
"active:brightness-[0.98]",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="shrink-0 text-xl leading-none">{icon}</span>
|
||||
) : null}
|
||||
{icon ? <span className="shrink-0 leading-none">{icon}</span> : null}
|
||||
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface BottomNavProps {
|
||||
|
||||
/**
|
||||
* Floating pill bottom navigation — float elevation above content cards.
|
||||
* Active state uses --ui-accent (non-semantic chrome), not category colors.
|
||||
*/
|
||||
export function BottomNav({ items, className }: BottomNavProps) {
|
||||
return (
|
||||
@@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
||||
className={cx(
|
||||
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
|
||||
item.active
|
||||
? "text-[var(--color-obstgem-dk)]"
|
||||
? "text-[var(--ui-accent)]"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
>
|
||||
@@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) {
|
||||
className={cx(
|
||||
"type-label normal-case tracking-wide",
|
||||
item.active
|
||||
? "bg-clip-text text-transparent"
|
||||
? "text-[var(--ui-accent)]"
|
||||
: "text-[var(--ink-soft)]",
|
||||
)}
|
||||
style={
|
||||
item.active
|
||||
? { backgroundImage: "var(--gradient-accent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
84
client/src/components/ui-pp/TimeIconBadge.tsx
Normal file
84
client/src/components/ui-pp/TimeIconBadge.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
import {
|
||||
DAYLIGHT_GRADIENT,
|
||||
daylightIconPlate,
|
||||
daylightPeriodFromHour,
|
||||
daylightPeriodFromMealTime,
|
||||
mealTimeIcon,
|
||||
periodFallbackIcon,
|
||||
type DaylightPeriod,
|
||||
} from "@/lib/daylight"
|
||||
import type { MealTime } from "@/types/domain"
|
||||
|
||||
export type { DaylightPeriod }
|
||||
|
||||
export interface TimeIconBadgeProps {
|
||||
icon?: LucideIcon
|
||||
/** Explicit period wins over hour / mealTime. */
|
||||
period?: DaylightPeriod
|
||||
/**
|
||||
* Wall-clock hour for live “now” gradient.
|
||||
* When set (without `period`), drives the sky gradient — even if `mealTime` is set for the icon.
|
||||
*/
|
||||
hour?: number
|
||||
/** Section meal slot — drives icon; drives gradient only when `hour` is omitted. */
|
||||
mealTime?: MealTime
|
||||
className?: string
|
||||
"aria-hidden"?: boolean
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded gradient badge for time-of-day / meal-section headers.
|
||||
* - Suggestion (now): pass `hour` + `mealTime` (sky from hour, icon from meal).
|
||||
* - Wochenplan sections: pass `mealTime` only (stable per section).
|
||||
*/
|
||||
export function TimeIconBadge({
|
||||
icon,
|
||||
period: periodProp,
|
||||
hour,
|
||||
mealTime,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-label": ariaLabel,
|
||||
}: TimeIconBadgeProps) {
|
||||
const period: DaylightPeriod =
|
||||
periodProp ??
|
||||
(hour !== undefined
|
||||
? daylightPeriodFromHour(hour)
|
||||
: mealTime
|
||||
? daylightPeriodFromMealTime(mealTime)
|
||||
: daylightPeriodFromHour(new Date().getHours()))
|
||||
|
||||
const Icon =
|
||||
icon ?? (mealTime ? mealTimeIcon(mealTime) : periodFallbackIcon(period))
|
||||
|
||||
const { plate, icon: iconColor } = daylightIconPlate(period)
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-control)]",
|
||||
"shadow-[var(--shadow-resting)]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundImage: DAYLIGHT_GRADIENT[period] }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px]"
|
||||
style={{ background: plate }}
|
||||
>
|
||||
<Icon
|
||||
className="h-4 w-4"
|
||||
strokeWidth={2.4}
|
||||
color={iconColor}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user