b0849c3989
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>
70 lines
2.0 KiB
TypeScript
70 lines
2.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|