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:
Steffi Müller
2026-08-04 15:52:24 +02:00
parent a482bf5b09
commit a0d415b08f
43 changed files with 3400 additions and 1358 deletions

View File

@@ -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>
)
}