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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user