Files
zeavis-edu/apps/web/src/pages/scan-page.tsx
T
seriouselly da5c7c1cfa feat(scan): implement drag and drop functionality for image upload
- Add `onDragOver`, `onDragLeave`, and `onDrop` event handlers to capture dragged files.
- Introduce `isDragging` state to provide visual UI feedback when a file is hovered over the drop zone.
- Wire the dropped file data to the existing `handleFile` processing logic.
2026-06-16 19:12:44 +07:00

528 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState, useCallback } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import {
Camera,
Upload,
X,
ImageOff,
CircleAlert,
ZoomIn,
Sun,
AlignCenter,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import type { DiagnosisRecord } from "@zeavis/shared";
import { diseaseCatalogSeed } from "@zeavis/shared"; // Import data seed lokal ditambahkan
import { apiClient } from "@/lib/api-client";
import { trackScan, trackDiagnosisResult } from "@/lib/telemetry";
import { DiagnosisResultView } from "../components/diagnose-result-view";
import { CameraCapture } from "../components/camera-capture";
export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isDragging, setIsDragging] = useState(false);
// Camera mode state
const [useCamera, setUseCamera] = useState(false);
const handleCameraCapture = useCallback((file: File) => {
setFileName(file.name);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = url;
setUseCamera(false);
}, []);
const mutation = useMutation({
mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => {
trackDiagnosisResult(diagnosis.status !== "failed");
queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
setDiagnosisPreview(diagnosis);
setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = "";
},
onError: () => {
trackDiagnosisResult(false);
},
});
const handleFile = (f?: File) => {
if (!f) return;
if (f.size > 5 * 1024 * 1024) {
alert("Ukuran file melebihi batas 5MB");
return;
}
setFileName(f.name);
const url = URL.createObjectURL(f);
setPreviewUrl(url);
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = url;
};
const handleUpload = () => {
const file = inputRef.current?.files?.[0];
if (!file) return;
trackScan();
mutation.mutate(file);
};
const [previewOpen, setPreviewOpen] = useState(false);
const [diagnosisPreview, setDiagnosisPreview] =
useState<DiagnosisRecord | null>(null);
useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
enabled: previewOpen,
});
return (
<div className="space-y-6">
{/* Main Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Pindai Daun</h1>
<p className="text-gray-500 mt-1 text-md">
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara
real-time
</p>
</div>
<Button asChild variant="outline">
<Link to="/diagnoses">Riwayat Diagnosis</Link>
</Button>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Upload Area */}
<div className="lg:col-span-2 space-y-4">
<Card className="w-full h-fit py-3">
<CardContent className="px-6 py-4 flex flex-col">
<div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
<Camera className="text-green-500" size={25} />
Area Unggah Gambar
</div>
{!previewUrl ? (
<div className="space-y-3">
{/* Mode toggle */}
<div className="flex rounded-lg bg-gray-100 p-1">
<button
type="button"
onClick={() => setUseCamera(false)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
!useCamera
? "bg-white text-green-700 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
<Upload size={16} className="inline mr-1.5" />
Unggah
</button>
<button
type="button"
onClick={() => setUseCamera(true)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
useCamera
? "bg-white text-green-700 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
<Camera size={16} className="inline mr-1.5" />
Kamera
</button>
</div>
{useCamera ? (
<CameraCapture
onCapture={handleCameraCapture}
onClose={() => setUseCamera(false)}
/>
) : (
<>
<div
className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
isDragging
? "border-blue-500 bg-blue-100"
: "border-green-300"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFile(droppedFile);
}
}}
>
<Upload
className="mx-auto text-green-500 mb-3"
size={48}
/>
<h3 className="font-semibold text-base text-gray-800 mb-1">
Seret & Lepas Foto Daun
</h3>
<p className="text-xs text-gray-500 mb-3">
atau klik untuk memilih file berkas dari perangkat
Anda
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} /> PNG, JPG, JPEG, WEBP
</div>
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
<Check size={14} /> Maks. 5 MB
</div>
</div>
</div>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Upload size={18} /> Pilih Berkas
</button>
</>
)}
</div>
) : (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-muted w-full">
<img
ref={imageRef}
src={previewUrl}
alt="Preview"
className="w-full max-h-80 object-contain"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
}}
/>
{imageDimensions && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="absolute inset-0 bg-black/20" />
<div
className="relative flex items-center justify-center"
style={{
width: `${Math.min(imageDimensions.width * 0.7, 280)}px`,
height: `${Math.min(imageDimensions.height * 0.7, 320)}px`,
}}
>
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-lime-300" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-lime-300" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-lime-300" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-lime-300" />
<div className="text-white text-center flex flex-col gap-1">
<span className="text-xs font-semibold tracking-widest">
AREA SCAN
</span>
<span className="text-[10px] text-lime-200 font-medium">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
</div>
</div>
)}
<button
type="button"
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors z-10"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
File: <span className="font-medium">{fileName}</span>
</p>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewUrl(null);
setFileName(null);
setImageDimensions(null);
if (inputRef.current) inputRef.current.value = "";
}}
>
<ImageOff className="mr-2 h-4 w-4" /> Ganti Foto
</Button>
<Button
onClick={handleUpload}
disabled={mutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Upload className="mr-2 h-4 w-4" />
{mutation.isPending ? "Memproses..." : "Analisis"}
</Button>
</div>
</div>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{mutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
<p className="font-medium">Upload gagal</p>
<p className="mt-1">
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan saat memproses gambar"}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column: Guidelines */}
<aside className="lg:col-span-1 flex flex-col gap-5">
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
<CircleAlert className="text-emerald-600" size={20} />
</div>
<h2 className="font-bold text-emerald-800">
Panduan Pengambilan Foto
</h2>
</div>
<ul className="space-y-3">
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<ZoomIn className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Jarak 1530 cm dari daun
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<Sun className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Pencahayaan cukup merata
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<AlignCenter className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Posisi daun mengisi bingkai dengan jelas
</div>
</li>
<li className="flex items-center gap-3">
<div className="h-9 w-9 rounded bg-[#ECF4E8] flex items-center justify-center shrink-0">
<Camera className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Hindari bayangan, pantulan, & blur
</div>
</li>
</ul>
</CardContent>
</Card>
<Card className="w-full h-max">
<CardContent className="px-6 py-4">
<h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4">
Persyaratan Berkas
</h3>
<div className="flex flex-col gap-2">
{[
{ label: "Format", value: "PNG, JPG, JPEG, WEBP" },
{ label: "Ukuran Maks.", value: "5 MB" },
{ label: "Resolusi Min.", value: "512 × 512 px" },
{ label: "Objek Foto", value: "Daun jagung tunggal" },
].map((r, i) => (
<div
key={i}
className="flex items-center justify-between py-1.5"
style={{
borderBottom: i < 3 ? "1px solid #f0f0f0" : "none",
}}
>
<span className="text-gray-400 text-xs">{r.label}</span>
<span className="text-sm font-medium text-gray-700">
{r.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
{/* Modal Preview Diagnosis */}
<Modal
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
title="Hasil Analisis AI"
size="lg"
footer={
diagnosisPreview && (
<div className="flex items-center justify-end gap-3 w-full">
<Button
variant="outline"
onClick={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
>
Tutup Jendela
</Button>
<Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-[#214B11] hover:bg-[#1a3a0d] text-white"
>
Masuk ke Halaman Riwayat
</Button>
</div>
)
}
>
{diagnosisPreview ? (
<div className="space-y-6 p-1">
{/* Warning if expert review is needed */}
{diagnosisPreview.status === "needs_review" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
<strong>Catatan:</strong> Hasil ini masih sementara karena
tingkat keyakinan (confidence) di bawah standar minimum.
</div>
)}
{/* Message if AI fails to process */}
{diagnosisPreview.failureReason && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900 shadow-sm">
<strong>Gagal Memproses:</strong>{" "}
{diagnosisPreview.failureReason}
</div>
)}
{/* Render DiagnosisResultView dengan Fallback Obat */}
{(() => {
// Fallback logic for scientific name and medicine recommendations
const seedData = diagnosisPreview.disease
? diseaseCatalogSeed.find(
(seed) =>
seed.commonName === diagnosisPreview.disease?.commonName,
)
: null;
// If the API doesn't return medicine recommendations, use the seed data as a fallback
const finalMedicines =
(diagnosisPreview.disease as any)?.medicineRecommendations ||
seedData?.medicineRecommendations ||
[];
return (
<DiagnosisResultView
imageUrl={
previewUrl ||
diagnosisPreview.imageUrl ||
"https://placehold.co/600x400?text=Foto+Daun"
}
confidence={diagnosisPreview.confidence ?? 0}
diseaseName={
diagnosisPreview.disease?.commonName ?? "Tidak Diketahui"
}
scientificName={diagnosisPreview.disease?.label ?? ""}
riskLevel={diagnosisPreview.disease?.riskLevel ?? "Sedang"}
description={
diagnosisPreview.disease?.description ??
diagnosisPreview.disease?.summary ??
"Deskripsi tidak tersedia."
}
symptoms={diagnosisPreview.disease?.symptoms ?? []}
preventions={diagnosisPreview.disease?.recommendations ?? []}
medicines={finalMedicines} // Datanya terhubung ke sini!
/>
);
})()}
{/* All Model Predictions */}
{diagnosisPreview.predictions &&
diagnosisPreview.predictions.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4 md:p-6 mt-4">
<h4 className="font-bold text-[#214B11] mb-1">
Semua Prediksi Model
</h4>
<p className="text-xs text-slate-500 mb-4">
Tebakan alternatif lain dari sistem AI ZeaVis
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{diagnosisPreview.predictions
.sort((a, b) => a.rank - b.rank)
.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 p-3 text-sm"
>
<span className="text-slate-600 font-medium">
{pred.modelLabel}
</span>
<span className="font-bold text-slate-700">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
</div>
)}
</div>
) : null}
</Modal>
</div>
);
}