Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.
Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
182
client/src/components/plate/DiningPlate.tsx
Normal file
182
client/src/components/plate/DiningPlate.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useId } from "react"
|
||||
|
||||
import { cx } from "@/lib/cx"
|
||||
|
||||
export interface PlateSegment {
|
||||
id: string
|
||||
/** Share of the plate (relative weights; need not sum to 1). */
|
||||
weight: number
|
||||
color: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DiningPlateProps {
|
||||
segments: PlateSegment[]
|
||||
size?: number
|
||||
onSelect?: (id: string) => void
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
function polar(centerX: number, centerY: number, r: number, angleDeg: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||
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).
|
||||
*/
|
||||
export function DiningPlate({
|
||||
segments,
|
||||
size = 280,
|
||||
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 foodR = size / 2 - rimPad
|
||||
const gapDeg = 2.5
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("relative select-none", className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* 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%)",
|
||||
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)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Soft recessed well under the food */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
inset: rimPad * 0.55,
|
||||
background:
|
||||
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
|
||||
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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}
|
||||
className={cx(
|
||||
"outline-none transition-[opacity,filter] duration-150",
|
||||
onSelect &&
|
||||
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user