PraxaLing / app /(app) /camera /CameraClient.tsx
Reubencf's picture
Deploy: PraxaLing rename + HF Docker Space
16fde87
Raw
History Blame Contribute Delete
11.8 kB
"use client";
import { useRef, useState } from "react";
import { motion } from "framer-motion";
import type { Level } from "@/lib/languages";
import type { FuriSegment } from "@/lib/types/dialogue";
import { LevelPicker } from "@/components/LevelPicker";
import { Furi } from "@/components/Furi";
import { TTSButton } from "@/components/TTSButton";
import { PhotoMarkers } from "./PhotoMarkers";
import { ImagePlus, Camera, RotateCcw, Wand2, AlertCircle } from "lucide-react";
type AnalysisObject = {
label: string;
translation: string;
translationSegments?: FuriSegment[];
romanized?: string;
box: [number, number, number, number];
score: number;
};
type Analysis = {
id: string;
caption: string;
objects: AnalysisObject[];
sentences: Array<{ target: string; targetSegments?: FuriSegment[]; gloss: string; romanized?: string }>;
imageUrl?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
export function CameraClient({ defaultLevel, lang }: { defaultLevel: Level; lang: string }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>(null);
const [analysis, setAnalysis] = useState<Analysis | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [level, setLevel] = useState<Level>(defaultLevel);
const inputRef = useRef<HTMLInputElement>(null);
function pick(file: File) {
setError(null);
setAnalysis(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
const img = new Image();
img.onload = () => setImageSize({ w: img.naturalWidth, h: img.naturalHeight });
img.src = url;
void upload(file);
}
async function upload(file: File) {
setIsLoading(true);
try {
const form = new FormData();
form.append("image", file);
form.append("level", level);
const res = await fetch("/api/vision/analyze", { method: "POST", body: form });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
setError(j.message || j.error || "Analysis failed.");
return;
}
setAnalysis((await res.json()) as Analysis);
} catch {
setError("Network error. Please try again.");
} finally {
setIsLoading(false);
}
}
function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) pick(file);
}
function clear() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
setAnalysis(null);
setImageSize(null);
setError(null);
if (inputRef.current) inputRef.current.value = "";
}
return (
<div className="space-y-8">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
onChange={onInputChange}
className="hidden"
/>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 rounded-2xl bg-white p-5 border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)]">
<span className="text-sm font-black uppercase tracking-wide text-black">Sentences match level</span>
<LevelPicker value={level} onChange={setLevel} size="sm" />
</div>
{!previewUrl && (
<motion.button
type="button"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE }}
onClick={() => inputRef.current?.click()}
className="group relative flex min-h-[360px] w-full flex-col items-center justify-center rounded-2xl border-3 border-black bg-white shadow-[8px_8px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[10px_10px_0px_rgba(0,0,0,1)] active:translate-x-[2px] active:translate-y-[2px] active:shadow-[4px_4px_0px_rgba(0,0,0,1)] transition-all p-8 text-center overflow-hidden cursor-pointer"
>
<div className="relative flex flex-col items-center z-10">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-2xl border-3 border-black bg-[#FFD21E] shadow-[4px_4px_0px_rgba(0,0,0,1)] group-hover:scale-105 transition-transform duration-300">
<ImagePlus size={48} strokeWidth={2.5} className="text-black" />
</div>
<h3 className="text-2xl font-black text-black tracking-tight mb-2 uppercase">Drop anything to upload</h3>
<p className="max-w-sm text-sm font-bold text-gray-700 leading-relaxed">
Camera or gallery images automatically become interactive vocabulary cards.
</p>
</div>
</motion.button>
)}
{previewUrl && imageSize && (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<button
onClick={() => inputRef.current?.click()}
className="flex items-center justify-center gap-2.5 rounded-xl bg-[#3B82F6] border-3 border-black text-white px-8 py-3 text-base font-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[6px_6px_0px_rgba(0,0,0,1)] active:translate-x-[1px] active:translate-y-[1px] active:shadow-[1px_1px_0px_rgba(0,0,0,1)] transition-all uppercase tracking-wider"
>
<Camera size={20} strokeWidth={2.5} />
New photo
</button>
<button
onClick={clear}
className="flex items-center justify-center gap-2.5 rounded-xl bg-white border-3 border-black text-black px-8 py-3 text-base font-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[6px_6px_0px_rgba(0,0,0,1)] active:translate-x-[1px] active:translate-y-[1px] active:shadow-[1px_1px_0px_rgba(0,0,0,1)] transition-all uppercase tracking-wider"
>
<RotateCcw size={20} strokeWidth={2.5} />
Clear
</button>
</div>
<div className="relative inline-block max-w-full overflow-hidden rounded-2xl bg-white p-2 border-3 border-black shadow-[8px_8px_0px_rgba(0,0,0,1)]">
<div className="relative inline-block max-w-full overflow-hidden rounded-xl bg-black">
<img src={previewUrl} alt="Selected practice" className="block max-h-[560px] w-auto max-w-full object-contain" />
{analysis ? <PhotoMarkers objects={analysis.objects} imageSize={imageSize} /> : null}
{isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white/90 backdrop-blur-sm z-20">
<Wand2 size={48} strokeWidth={2.5} className="animate-bounce text-[#3B82F6]" />
<span className="text-lg font-black text-black uppercase tracking-wider">Analyzing image...</span>
</div>
)}
</div>
</div>
{analysis?.caption ? (
<div className="rounded-xl bg-[#FFD21E] border-3 border-black p-5 text-base font-black text-black shadow-[4px_4px_0px_rgba(0,0,0,1)] italic">
"{analysis.caption}"
</div>
) : null}
</div>
)}
{error ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-4 rounded-xl border-3 border-black bg-[#FF8080] p-5 shadow-[4px_4px_0px_rgba(0,0,0,1)]"
>
<AlertCircle size={24} strokeWidth={2.5} className="text-black shrink-0" />
<div>
<p className="text-base font-black text-black uppercase tracking-tight">Analysis failed</p>
<p className="mt-1 text-sm font-bold text-black/80">{error}</p>
</div>
</motion.div>
) : null}
{analysis ? (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE }}
className="grid gap-8 lg:grid-cols-2"
>
<ResultCard title="Objects detected">
{analysis.objects.length > 0 ? (
<ul className="space-y-4">
{analysis.objects.map((o, i) => (
<li key={`${o.translation}-row-${i}`} className="rounded-xl bg-white border-2 border-black flex items-center justify-between gap-4 p-4 transition-all shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[5px_5px_0px_rgba(0,0,0,1)]">
<div className="flex items-center gap-3 min-w-0">
<TTSButton text={o.translation} lang={lang} />
<div>
<div className={`text-lg font-black text-black ${o.translationSegments ? "has-furi" : ""}`}>
<Furi text={o.translation} segments={o.translationSegments} />
</div>
{o.romanized ? <div className="text-xs font-bold text-[#3B82F6] mt-0.5">{o.romanized}</div> : null}
<div className="mt-1.5"><span className="text-[10px] font-black uppercase tracking-wider text-black bg-[#E6FBFA] border border-black rounded px-1.5 py-0.5 shadow-[1px_1px_0px_rgba(0,0,0,1)]">{o.label}</span></div>
</div>
</div>
<div className="rounded-lg border-2 border-black bg-[#FFD21E] px-3 py-1 text-xs font-black text-black shadow-[2px_2px_0px_rgba(0,0,0,1)] shrink-0">
{Math.round(o.score * 100)}%
</div>
</li>
))}
</ul>
) : (
<p className="text-sm font-bold text-gray-500">No objects detected. Try a clearer photo.</p>
)}
</ResultCard>
<ResultCard title="Practice sentences">
{analysis.sentences.length > 0 ? (
<ul className="space-y-4">
{analysis.sentences.map((s, i) => (
<li key={`${s.target}-${i}`} className="rounded-xl bg-white border-2 border-black p-4 transition-all shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[5px_5px_0px_rgba(0,0,0,1)]">
<div className="flex items-start justify-between gap-3">
<div className={`text-lg font-black text-black ${s.targetSegments ? "has-furi" : ""}`}>
<Furi text={s.target} segments={s.targetSegments} />
</div>
<TTSButton text={s.target} lang={lang} />
</div>
{s.romanized ? <div className="mt-1 text-sm font-bold italic text-[#3B82F6]">{s.romanized}</div> : null}
<div className="mt-3 text-xs font-black text-black bg-[#FFF7D6] p-2.5 rounded-lg border-2 border-black shadow-[2px_2px_0px_rgba(0,0,0,1)]">{s.gloss}</div>
</li>
))}
</ul>
) : (
<p className="text-sm font-bold text-gray-500">No sentences generated. Retry the photo.</p>
)}
</ResultCard>
</motion.div>
) : null}
</div>
);
}
function ResultCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl bg-white p-6 border-3 border-black shadow-[6px_6px_0px_rgba(0,0,0,1)]">
<h2 className="text-sm font-black uppercase tracking-wider text-black mb-6 flex items-center gap-2">
<span className="w-2 h-6 bg-[#FFD21E] border-2 border-black rounded-full" />
{title}
</h2>
{children}
</section>
);
}