feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
Six M-sized items from HANDOFF.md's new-features backlog: - Profile tabs: cooking-history stats (total cooked, last-cooked, streak) and a "cooked it" photo gallery, both owner-only - Display-time unit conversion (metric<->imperial) for recipe ingredients, respecting each user's unitPref; original value always shown alongside the conversion - Nutrition daily diary: per-day macro totals computed from cooking history x recipe nutritionData, compared against user goals - Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key) with an AI-vision fallback for unbarcoded items, always confirm-before- insert, both paths tier/rate-limited like other AI features - Weekly digest email: new followers/comments/ratings + trending recipes, sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron` compose service hitting a bearer-token-protected internal route - Meal-plan generation can now target a user's nutrition goals as a prompt-level nudge (recipes are AI-invented, not DB-sourced, so this can't be a hard macro constraint) Caught a real deploy-breaking issue while adding the cron stage: appending it after `runner` silently changed the Dockerfile's default build target, and `web`'s compose config didn't pin one — fixed by pinning `target: runner` explicitly. Verified with typecheck, lint, and three separate `docker build --target` runs (runner/cron/migrator) plus `docker compose config` validation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function BarcodeScanner({
|
||||
onDetected,
|
||||
onError,
|
||||
}: {
|
||||
onDetected: (barcode: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("pantry");
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [starting, setStarting] = useState(true);
|
||||
const detectedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let controls: { stop: () => void } | null = null;
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const { BrowserMultiFormatReader } = await import("@zxing/browser");
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
if (cancelled || !videoRef.current) return;
|
||||
|
||||
controls = await reader.decodeFromVideoDevice(
|
||||
undefined,
|
||||
videoRef.current,
|
||||
(result) => {
|
||||
if (result && !detectedRef.current) {
|
||||
detectedRef.current = true;
|
||||
onDetected(result.getText());
|
||||
controls?.stop();
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!cancelled) setStarting(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
onError?.(err instanceof Error ? err.message : t("cameraError"));
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controls?.stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
||||
<video ref={videoRef} className="h-full w-full object-cover" muted playsInline />
|
||||
{starting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-8 top-1/2 h-16 -translate-y-1/2 rounded-md border-2 border-white/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user