Merge branch 'ATLAS-PJK-GM007:main' into main

This commit is contained in:
Asep Haryana Saputra
2026-06-07 16:10:09 +07:00
committed by GitHub
33 changed files with 2173 additions and 1037 deletions
+2 -1
View File
@@ -29,6 +29,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.14" "vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1"
} }
} }
+72 -25
View File
@@ -1,52 +1,99 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import {
import { AuthGuard } from '@/components/auth-guard'; createBrowserRouter,
import { DashboardPage } from '@/pages/dashboard-page'; RouterProvider,
import { LandingPage } from '@/pages/landing-page'; Navigate,
import { CatalogPage } from '@/pages/catalog-page'; } from "react-router-dom";
import { DiseaseDetailPage } from '@/pages/disease-detail-page'; import { AuthInitializer } from "@/components/auth-initializer";
import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page'; // import { AuthGuard } from "@/components/auth-guard";
import { ExpertReviewsPage } from '@/pages/expert-reviews-page'; import { DashboardPage } from "@/pages/dashboard-page";
import { LoginPage } from '@/pages/login-page'; import { ScanPage } from "@/pages/scan-page";
import { RegisterPage } from '@/pages/register-page'; import { LibraryPage } from "@/pages/library-page";
import { CatalogPage } from "@/pages/catalog-page";
import { DiseaseDetailPage } from "@/pages/disease-detail-page";
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
import { DiagnosesPage } from "@/pages/diagnoses-page";
// import { LoginPage } from "@/pages/login-page";
// import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: '/', element: <LandingPage /> }, { path: "/", element: <Navigate to="/dashboard" replace /> },
{ path: '/login', element: <LoginPage /> }, // { path: "/login", element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> }, // { path: "/register", element: <RegisterPage /> },
{ {
path: '/dashboard', path: "/dashboard",
element: ( element: (
<AuthGuard> <MainLayout>
<DashboardPage /> <DashboardPage />
</AuthGuard> </MainLayout>
), ),
}, },
{ {
path: '/diagnoses/:id', path: "/scan",
element: ( element: (
<AuthGuard> <MainLayout>
<ScanPage />
</MainLayout>
),
},
{
path: "/library",
element: (
<MainLayout>
<LibraryPage />
</MainLayout>
),
},
{
path: "/diagnoses",
element: (
<MainLayout>
<DiagnosesPage />
</MainLayout>
),
},
{
path: "/diagnoses/:id",
element: (
<MainLayout>
<DiagnosisDetailPage /> <DiagnosisDetailPage />
</AuthGuard> </MainLayout>
), ),
}, },
{ {
path: '/expert/reviews', path: "/expert/reviews",
element: ( element: (
<AuthGuard requireExpert> <MainLayout>
<ExpertReviewsPage /> <ExpertReviewsPage />
</AuthGuard> </MainLayout>
),
},
{
path: "/catalog",
element: (
<MainLayout>
<CatalogPage />
</MainLayout>
),
},
{
path: "/catalog/:slug",
element: (
<MainLayout>
<DiseaseDetailPage />
</MainLayout>
), ),
}, },
{ path: '/catalog', element: <CatalogPage /> },
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> },
]); ]);
export function App() { export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
); );
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
export function AuthInitializer() {
const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(),
retry: false,
});
useEffect(() => {
if (query.data) {
setUser(query.data.user);
}
}, [query.data, setUser]);
return null;
}
+2 -2
View File
@@ -14,8 +14,8 @@ export function DiseaseCard({ disease }: DiseaseCardProps) {
<Link to={`/catalog/${disease.slug}`} className="block transition-transform hover:scale-105"> <Link to={`/catalog/${disease.slug}`} className="block transition-transform hover:scale-105">
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1"> <div className="min-w-0 flex-1">
<CardTitle className="text-xl">{disease.commonName}</CardTitle> <CardTitle className="text-xl">{disease.commonName}</CardTitle>
<CardDescription className="mt-1">{disease.label}</CardDescription> <CardDescription className="mt-1">{disease.label}</CardDescription>
</div> </div>
@@ -1,138 +0,0 @@
import { ChangeEvent, FormEvent, useRef, useState, useEffect } from 'react';
import type { DiagnosisRecord } from '@zeavis/shared';
import { Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
type ImageClassificationFormProps = {
onSubmit: (file: File) => Promise<void>;
isSubmitting: boolean;
latestResult: DiagnosisRecord | null;
};
export function ImageClassificationForm({
onSubmit,
isSubmitting,
latestResult,
}: ImageClassificationFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Revoke object URL on component unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
};
}, []);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const selectedFile = event.target.files?.[0] ?? null;
setError(null);
setFile(selectedFile);
// Revoke previous preview URL before replacing it
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
const newUrl = selectedFile ? URL.createObjectURL(selectedFile) : null;
previewUrlRef.current = newUrl;
setPreviewUrl(newUrl);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
setError('Pilih gambar terlebih dahulu');
return;
}
try {
await onSubmit(file);
// Revoke current preview URL after successful submit before setting null
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setFile(null);
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Gagal mengirim gambar');
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Diagnosis Gambar
</CardTitle>
<CardDescription>
Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/jpeg,image/png"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{file && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {file.name}
</p>
)}
</div>
{previewUrl && (
<img src={previewUrl} alt="Pratinjau gambar daun jagung untuk diagnosis" className="h-48 rounded-lg object-cover" />
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Memproses...' : 'Upload dan Diagnosis'}
</Button>
</form>
{latestResult && (
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="font-semibold">
{latestResult.disease?.commonName ?? 'Diagnosis gagal'}
</h3>
<DiagnosisStatusBadge status={latestResult.status} />
</div>
<p className="text-sm text-muted-foreground">
{latestResult.confidence === null
? 'Tidak ada confidence'
: `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`}
</p>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,9 @@
export function Footer() {
return (
<footer className="border-t bg-[#ECF4E8]">
<div className="mx-auto max-w-6xl px-6 py-6 text-sm text-muted-foreground text-center">
© 2026 ZeaVis Edu - AI for Smart Education
</div>
</footer>
);
}
@@ -0,0 +1,17 @@
import { ReactNode } from "react";
import { Navbar } from "./navbar";
import { Footer } from "./footer";
type Props = { children: ReactNode };
export function MainLayout({ children }: Props) {
return (
<div className="min-h-screen flex flex-col bg-[#ECF4E8]">
<Navbar />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 sm:px-6 py-6 sm:py-8">
{children}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,94 @@
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
import { X, Leaf } from "lucide-react";
type Props = {
open: boolean;
onClose: () => void;
};
const NAV_ITEMS = [
{ path: "/dashboard", label: "Dashboard" },
{ path: "/scan", label: "Scan Tanaman" },
{ path: "/diagnoses", label: "Diagnosa" },
{ path: "/catalog", label: "Pustaka", altPath: "/library" },
];
export function MobileNav({ open, onClose }: Props) {
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
const navContent = (
<div
// 2. Ubah z-50 menjadi z-[999] agar levelnya mentok paling atas
className={`fixed inset-0 z-[999] lg:hidden transition-opacity duration-300 ease-in-out ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
aria-hidden={!open}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div
className={`fixed inset-y-0 right-0 z-[999] flex w-full max-w-sm flex-col bg-[#306D29] shadow-2xl transition-transform duration-300 ease-in-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 h-20 border-b border-white/10 shrink-0">
<div className="flex items-center gap-3 font-semibold text-white">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111]">
<Link to="/dashboard" onClick={onClose}>
<Leaf className="h-5 w-5" />
</Link>
</div>
<div className="leading-tight">
<div className="text-lg font-bold">ZeaVis Edu</div>
<div className="text-[13px] font-normal text-[#9AD872]">
Smart AI for Corn Disease Detection
</div>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Tutup menu"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Nav items */}
<nav className="flex flex-col gap-2 overflow-y-auto px-4 py-6 flex-1">
{NAV_ITEMS.map((item) => {
const active =
isActive(item.path) || (item.altPath && isActive(item.altPath));
return (
<Link
key={item.path}
to={item.path}
onClick={onClose}
className={`rounded-full px-4 py-3 text-[16px] font-medium transition-colors ${
active
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white"
}`}
>
{item.label}
</Link>
);
})}
</nav>
</div>
</div>
);
return typeof document !== "undefined"
? createPortal(navContent, document.body)
: navContent;
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Leaf, Menu } from "lucide-react";
import { MobileNav } from "./mobile-nav";
export function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
//
const isActive = (path: string) => location.pathname === path;
const navLinkClassName = (active: boolean) =>
[
"inline-flex items-center rounded-full px-4 py-2 text-[16px] font-medium transition-colors",
active
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white",
].join(" ");
return (
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
<div className="flex items-center gap-3 font-semibold">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111] text-primary-foreground">
<Link to="/dashboard">
<Leaf className="h-5 w-5" />
</Link>
</div>
<div className="leading-tight font-bold text-2xl">
<div>ZeaVis Edu</div>
<div className="text-[14px] font-normal text-[#9AD872]">
Smart AI for Corn Disease Detection
</div>
</div>
</div>
<nav className="hidden lg:flex items-center gap-2">
<Link
to="/dashboard"
className={navLinkClassName(isActive("/dashboard"))}
>
Dashboard
</Link>
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
Scan Tanaman
</Link>
<Link
to="/diagnoses"
className={navLinkClassName(isActive("/diagnoses"))}
>
Diagnosa
</Link>
<Link
to="/catalog"
className={navLinkClassName(
isActive("/catalog") || isActive("/library"),
)}
>
Pustaka
</Link>
<Link
to="/expert/reviews"
className={navLinkClassName(isActive("/expert/reviews"))}
>
Review
</Link>
</nav>
<button
onClick={() => setMobileMenuOpen(true)}
className="lg:hidden rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Buka menu navigasi"
>
<Menu className="h-6 w-6" />
</button>
<MobileNav
open={mobileMenuOpen}
onClose={() => setMobileMenuOpen(false)}
/>
</div>
</header>
);
}
@@ -1,132 +0,0 @@
import { useEffect, useState } from 'react';
import type { DiseaseSlug, DiseaseCatalogItem, ManualClassificationRequest } from '@zeavis/shared';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export interface ManualClassificationFormProps {
diseases: DiseaseCatalogItem[];
onSubmit: (payload: ManualClassificationRequest) => Promise<void>;
isSubmitting: boolean;
}
export function ManualClassificationForm({
diseases,
onSubmit,
isSubmitting,
}: ManualClassificationFormProps) {
const [selectedSlug, setSelectedSlug] = useState<DiseaseSlug | ''>('');
const [observation, setObservation] = useState('');
const [location, setLocation] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (diseases.length > 0 && !selectedSlug) {
setSelectedSlug(diseases[0].slug);
}
}, [diseases, selectedSlug]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!selectedSlug || !observation.trim() || !location.trim()) {
setError('Semua field harus diisi');
return;
}
try {
await onSubmit({
diseaseSlug: selectedSlug,
observation: observation.trim(),
location: location.trim(),
});
setObservation('');
setLocation('');
if (diseases.length > 0) {
setSelectedSlug(diseases[0].slug);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengirim data');
}
};
const isFormValid = selectedSlug && observation.trim() && location.trim();
return (
<Card>
<CardHeader>
<CardTitle>Klasifikasi Manual</CardTitle>
<CardDescription>Laporkan penyakit daun jagung yang Anda temukan</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>
)}
<div>
<label htmlFor="disease" className="block text-sm font-medium">
Jenis Penyakit
</label>
<select
id="disease"
value={selectedSlug}
onChange={(e) => setSelectedSlug(e.target.value as DiseaseSlug)}
disabled={diseases.length === 0 || isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
>
{diseases.length === 0 ? (
<option value="">Memuat penyakit...</option>
) : (
diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName} ({disease.label})
</option>
))
)}
</select>
</div>
<div>
<label htmlFor="observation" className="block text-sm font-medium">
Pengamatan
</label>
<textarea
id="observation"
value={observation}
onChange={(e) => setObservation(e.target.value)}
placeholder="Jelaskan gejala atau kondisi daun yang Anda amati..."
disabled={isSubmitting}
rows={4}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium">
Lokasi
</label>
<input
id="location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Lokasi penemuan penyakit (desa, kecamatan, kabupaten)"
disabled={isSubmitting}
className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<Button
type="submit"
disabled={!isFormValid || isSubmitting || diseases.length === 0}
className="w-full"
>
{isSubmitting ? 'Mengirim...' : 'Kirim Laporan'}
</Button>
</form>
</CardContent>
</Card>
);
}
+26 -15
View File
@@ -1,25 +1,25 @@
import { Slot } from '@radix-ui/react-slot'; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from 'react'; import type { ButtonHTMLAttributes } from "react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50', "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: 'border border-border bg-transparent hover:bg-muted', outline: "border border-border bg-transparent hover:bg-muted",
ghost: 'hover:bg-muted', ghost: "hover:bg-[#48A111] hover:text-primary-foreground hover:rounded-[50px]",
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: "h-10 px-4 py-2",
lg: 'h-12 rounded-lg px-6', lg: "h-12 rounded-lg px-6",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
); );
@@ -29,8 +29,19 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
asChild?: boolean; asChild?: boolean;
}; };
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { export function Button({
const Comp = asChild ? Slot : 'button'; className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />; return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
} }
@@ -0,0 +1,12 @@
import React, { ReactNode } from 'react';
type Props = {
children?: ReactNode;
className?: string;
};
export function ModalFooter({ children, className }: Props) {
return <div className={`mt-4 text-right ${className ?? ''}`}>{children}</div>;
}
export default ModalFooter;
@@ -0,0 +1,18 @@
import React, { ReactNode } from 'react';
type Props = {
children?: ReactNode;
right?: ReactNode;
className?: string;
};
export function ModalHeader({ children, right, className }: Props) {
return (
<div className={`flex items-start justify-between ${className ?? ''}`}>
<div>{children}</div>
{right && <div>{right}</div>}
</div>
);
}
export default ModalHeader;
+82
View File
@@ -0,0 +1,82 @@
import React, { ReactNode, useEffect, useRef } from "react";
import { ModalHeader } from "./modal-header";
import { ModalFooter } from "./modal-footer";
type Size = "sm" | "md" | "lg" | "full";
const sizeClass: Record<Size, string> = {
sm: "max-w-xl",
md: "max-w-3xl",
lg: "max-w-5xl",
full: "max-w-full h-full",
};
type Props = {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
footer?: ReactNode;
headerRight?: ReactNode;
size?: Size;
closeOnBackdrop?: boolean;
className?: string;
};
export function Modal({
open,
onClose,
children,
title,
footer,
headerRight,
size = "md",
closeOnBackdrop = true,
className,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (open) window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (open) {
containerRef.current?.focus();
}
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => closeOnBackdrop && onClose()}
/>
<div
role="dialog"
aria-modal="true"
tabIndex={-1}
ref={containerRef}
className={`relative z-10 w-full ${sizeClass[size]} mx-4 max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
>
<ModalHeader right={headerRight}>
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
</ModalHeader>
<div className="mt-4">{children}</div>
<ModalFooter>
{footer ?? (
<button onClick={onClose} className="px-4 py-2 rounded bg-gray-200">
Tutup
</button>
)}
</ModalFooter>
</div>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.png";
declare module "*.svg";
+13 -1
View File
@@ -15,6 +15,11 @@ import type {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
export interface ApiError extends Error {
status: number;
source?: 'uploader' | 'model-service' | 'unknown';
}
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> { async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${apiBaseUrl}${endpoint}`; const url = `${apiBaseUrl}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
@@ -25,14 +30,21 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
if (!response.ok) { if (!response.ok) {
let errorMessage = `HTTP ${response.status}`; let errorMessage = `HTTP ${response.status}`;
let source: 'uploader' | 'model-service' | 'unknown' | undefined;
try { try {
const errorData = await response.json(); const errorData = await response.json();
if (errorData.error) { if (errorData.error) {
errorMessage = errorData.error; errorMessage = errorData.error;
} }
if (errorData.source) {
source = errorData.source;
}
} catch { } catch {
} }
throw new Error(errorMessage); const error = new Error(errorMessage) as ApiError;
error.status = response.status;
error.source = source;
throw error;
} }
return response.json(); return response.json();
+5 -5
View File
@@ -1,9 +1,9 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import { App } from './app'; import { App } from "./app";
import './index.css'; import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
+91 -26
View File
@@ -1,26 +1,39 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { AlertCircle } from 'lucide-react';
import type { RiskLevel } from '@zeavis/shared'; import type { RiskLevel } from '@zeavis/shared';
import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card';
import { Card } from '@/components/ui/card'; import { RiskBadge } from '@/components/risk-badge';
import { DiseaseCard } from '@/components/disease-card';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
export function CatalogPage() { export function CatalogPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialRisk = searchParams.get('risk') as RiskLevel | null;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>('all'); const [riskFilter, setRiskFilter] = useState<RiskLevel | 'all'>(initialRisk ?? 'all');
// Sync URL params with state
useEffect(() => {
if (initialRisk && initialRisk !== riskFilter) {
setRiskFilter(initialRisk);
}
}, [initialRisk]);
const { data: diseases, isLoading, error } = useQuery({ const { data: diseases, isLoading, error } = useQuery({
queryKey: ['diseases'], queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(), queryFn: () => apiClient.getDiseases(),
}); });
const filteredDiseases = (diseases || []).filter((disease) => { const filteredDiseases = (diseases || [])
.sort((a, b) => a.displayOrder - b.displayOrder)
.filter((disease) => {
const matchesSearch = const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) || disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) || disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.summary.toLowerCase().includes(searchQuery.toLowerCase()); disease.summary.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.symptoms.some((s) => s.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesRisk = riskFilter === 'all' || disease.riskLevel === riskFilter; const matchesRisk = riskFilter === 'all' || disease.riskLevel === riskFilter;
@@ -28,37 +41,34 @@ export function CatalogPage() {
}); });
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-6xl space-y-8"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<header className="space-y-4">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1> <h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<p className="mt-2 text-muted-foreground"> <p className="text-gray-500 mt-1 text-md">
Pelajari tentang penyakit daun jagung dan cara penanganannya Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu
</p> </p>
</div> </div>
<Button asChild variant="outline">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div> </div>
</header>
<div className="space-y-4"> {/* Filters */}
<div className="flex flex-col gap-4 md:flex-row md:items-end"> <div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1"> <div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2"> <label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit Cari penyakit
</label> </label>
<div className="relative">
<AlertCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input <input
id="search" id="search"
type="text" type="text"
placeholder="Cari berdasarkan nama atau gejala..." placeholder="Cari berdasarkan nama atau gejala..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" className="w-full rounded-md border border-border bg-background pl-10 pr-3 py-2 text-sm"
/> />
</div> </div>
</div>
<div> <div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2"> <label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
Filter risiko Filter risiko
@@ -66,7 +76,14 @@ export function CatalogPage() {
<select <select
id="risk-filter" id="risk-filter"
value={riskFilter} value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | 'all')} onChange={(e) => {
const v = e.target.value as RiskLevel | 'all';
setRiskFilter(v);
const next = new URLSearchParams(searchParams);
if (v === 'all') next.delete('risk');
else next.set('risk', v);
setSearchParams(next);
}}
className="rounded-md border border-border bg-background px-3 py-2 text-sm" className="rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="all">Semua Risiko</option> <option value="all">Semua Risiko</option>
@@ -76,7 +93,6 @@ export function CatalogPage() {
</select> </select>
</div> </div>
</div> </div>
</div>
{isLoading && ( {isLoading && (
<Card className="p-8 text-center text-muted-foreground"> <Card className="p-8 text-center text-muted-foreground">
@@ -97,13 +113,62 @@ export function CatalogPage() {
)} )}
{!isLoading && !error && filteredDiseases.length > 0 && ( {!isLoading && !error && filteredDiseases.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{filteredDiseases.map((disease) => ( {filteredDiseases.map((disease) => (
<DiseaseCard key={disease.slug} disease={disease} /> <Link
key={disease.slug}
to={`/catalog/${disease.slug}`}
className="block transition-transform hover:scale-[1.03]"
>
<Card className="h-full rounded-2xl bg-white shadow-sm hover:shadow-md">
<CardContent className="p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-lg font-bold text-[#214B11]">
{disease.commonName}
</h3>
<p className="text-sm text-slate-400 italic">
{disease.label}
</p>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-sm text-muted-foreground line-clamp-3">
{disease.summary}
</p>
{disease.symptoms.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground mb-1">
Gejala:
</h4>
<ul className="space-y-1">
{disease.symptoms.slice(0, 2).map((symptom, index) => (
<li key={index} className="text-xs text-muted-foreground flex items-start gap-1.5">
<span className="text-primary mt-0.5"></span>
{symptom}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t">
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: disease.accentColor }}
/>
<span className="text-xs text-primary font-medium">
Lihat detail
</span>
</div>
</CardContent>
</Card>
</Link>
))} ))}
</div> </div>
)} )}
</div> </div>
</main>
); );
} }
+266 -190
View File
@@ -1,118 +1,127 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMemo } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link } from "react-router-dom";
import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react'; import { useQuery } from "@tanstack/react-query";
import { Button } from '@/components/ui/button'; import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from "@/components/ui/button";
import { ManualClassificationForm } from '@/components/manual-classification-form'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ImageClassificationForm } from '@/components/image-classification-form'; import { useUiStore } from "@/store/ui-store";
import { DiagnosisCard } from '@/components/diagnosis-card'; import { apiClient } from "@/lib/api-client";
import { useAuthStore } from '@/store/auth-store'; import bg from "@/assets/images/dashboard-bg.png";
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
export function DashboardPage() { export function DashboardPage() {
const { user } = useAuthStore(); const { dashboardCompact } = useUiStore();
const setUser = useAuthStore((state) => state.setUser); const summaryQuery = useQuery({
const { dashboardCompact, toggleDashboardCompact } = useUiStore(); queryKey: ["dashboard-summary"],
const queryClient = useQueryClient();
const navigate = useNavigate();
const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(),
},
{
queryKey: ['dashboard-summary'],
queryFn: () => apiClient.getDashboardSummary(), queryFn: () => apiClient.getDashboardSummary(),
},
{
queryKey: ['diagnoses'],
queryFn: () => apiClient.getDiagnoses(),
},
{
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
],
}); });
const createDiagnosisMutation = useMutation({ const diseasesQuery = useQuery({
mutationFn: async (file: File) => { queryKey: ["diseases"],
return await apiClient.createDiagnosis(file); queryFn: () => apiClient.getDiseases(),
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['diagnoses'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
},
}); });
const createClassificationMutation = useMutation({
mutationFn: async (payload: Parameters<typeof apiClient.createManualClassification>[0]) => {
await apiClient.createManualClassification(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
queryClient.invalidateQueries({ queryKey: ['manual-classifications'] });
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
await apiClient.logout();
},
onSuccess: () => {
setUser(null);
queryClient.clear();
navigate('/login');
},
});
const diseases = diseasesQuery.data || [];
const summary = summaryQuery.data; const summary = summaryQuery.data;
const diagnoses = diagnosesQuery.data || []; const diseases = diseasesQuery.data ?? [];
const classifications = classificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading; const diseasesQuick = useMemo(() =>
const hasError = diseasesQuery.error || summaryQuery.error; diseases
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((d) => ({
name: d.commonName,
sci: d.label,
color: d.accentColor,
slug: d.slug,
})),
[diseases]
);
const missionCards = [
{
icon: Scan,
title: "Deteksi Otomatis",
description:
"Upload foto daun jagung dan AI kami akan mengidentifikasi penyakit secara instan.",
accent: "text-lime-600",
},
{
icon: BookOpen,
title: "Modul Edukasi",
description:
"Informasi detail tentang gejala, penyebab, dan dampak setiap penyakit daun jagung.",
accent: "text-amber-600",
},
{
icon: Shield,
title: "Panduan Pencegahan",
description:
"Strategi pencegahan berbasis sains untuk melindungi tanaman Anda dari infeksi.",
accent: "text-blue-600",
},
{
icon: Pill,
title: "Rekomendasi Obat",
description:
"Saran fungisida dan perawatan mandiri yang tepat sesuai jenis penyakit.",
accent: "text-violet-600",
},
];
const isLoadingData = summaryQuery.isLoading;
const hasError = Boolean(summaryQuery.error);
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-8">
<div className="mx-auto max-w-6xl space-y-8"> {/* Hero header */}
<header className="flex flex-col gap-4 rounded-3xl border bg-card p-6 shadow-sm md:flex-row md:items-center md:justify-between"> <header
<div className="space-y-2"> className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
<div className="flex items-center gap-2 text-sm font-medium text-primary"> style={{ backgroundImage: `url(${bg})` }}
<LayoutDashboard className="h-4 w-4" /> Dashboard >
</div> <div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1> <div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-6 md:p-10">
<p className="text-muted-foreground"> <div className="space-y-3 w-full md:w-2/3 text-white">
{user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'} <span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
AI FOR SMART EDUCATION
</span>
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
ZeaVis Edu
</h2>
<p className="mt-3 max-w-xl text-white/90">
Platform edukasi berbasis AI untuk membantu petani jagung
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
akurat.
</p> </p>
</div> <div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:gap-4">
<div className="flex gap-3"> <Button
{user?.role === 'expert' && ( asChild
<Button asChild variant="outline"> variant="outline"
<Link to="/expert/reviews"> className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white"
<CheckCircle2 className="h-4 w-4 mr-2" /> >
Review Pakar <Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link> </Link>
</Button> </Button>
)}
<Button variant="outline" onClick={toggleDashboardCompact}>
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}
</Button>
<Button <Button
asChild
variant="outline" variant="outline"
onClick={() => logoutMutation.mutate()} className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
disabled={logoutMutation.isPending}
> >
<LogOut className="h-4 w-4 mr-2" /> <Link
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'} to="/catalog"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button> </Button>
</div> </div>
</div>
<div className="w-full md:w-1/3" />
</div>
</header> </header>
{/* Loading / Error states */}
{isLoadingData && ( {isLoadingData && (
<Card className="p-8 text-center text-muted-foreground"> <Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard... Memuat data dashboard...
@@ -121,152 +130,219 @@ export function DashboardPage() {
{hasError && ( {hasError && (
<Card className="p-8 text-center text-red-600"> <Card className="p-8 text-center text-red-600">
Gagal memuat data dashboard <div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card> </Card>
)} )}
{/* Main dashboard content */}
{!isLoadingData && !hasError && ( {!isLoadingData && !hasError && (
<> <>
{summary && ( {summary && (
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}> <section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
Proyek Urgensi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Data ringkasan terbaru dari proyek Anda untuk memantau
perkembangan dan hasil deteksi penyakit daun jagung
</p>
</div>
{/* Total Diagnoses — the actual count from diagnoses table */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit Total Diagnosa
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">{summary.diseaseCount}</div> <div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent> </CardContent>
</Card> </Card>
{/* Menunggu Review */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.imageClassificationCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review Menunggu Review
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-amber-600"> <div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount} {summary.needsReviewCount}
</div> </div>
<Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent> </CardContent>
</Card> </Card>
{/* Diagnosa Gagal */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Tinggi Gagal
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-red-600">
</div>
<Link to="/diagnoses?status=failed" className="text-red-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
{/* Risiko Tinggi — catalog count, link to catalog filtered */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Kategori Risiko Tinggi
</CardTitle>
</CardHeader>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-red-600"> <div className="text-3xl font-bold text-red-600">
{summary.riskDistribution.high} {summary.riskDistribution.high}
</div> </div>
<Link to="/catalog?risk=high" className="text-red-600 ml-auto hover:underline">
Lihat pustaka
</Link>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
)} )}
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}> {/* Mission section */}
<Card> <section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<CardHeader> <div className="space-y-1">
<CardTitle className="flex items-center gap-2"> <h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
<BookOpen className="h-5 w-5" /> Misi Platform
Katalog Penyakit </h3>
</CardTitle> <p className="text-[15px] font-normal text-muted-foreground">
<CardDescription> Fitur inti yang kami sediakan untuk mendukung petani jagung
Pelajari tentang {diseases.length} penyakit daun jagung Indonesia
</CardDescription> </p>
</CardHeader> </div>
<CardContent>
<Button asChild className="w-full">
<Link to="/catalog">Buka Katalog</Link>
</Button>
</CardContent>
</Card>
<Card> <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<CardHeader> {missionCards.map((card) => {
<CardTitle className="flex items-center gap-2"> const Icon = card.icon;
<TrendingUp className="h-5 w-5" />
Distribusi Risiko return (
</CardTitle> <Card
<CardDescription> key={card.title}
Penyakit berdasarkan tingkat risiko className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full"
</CardDescription> >
</CardHeader> <CardContent className="space-y-5 p-6 h-full flex flex-col justify-between">
<CardContent> <div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]">
{summary && ( <Icon className={`h-7 w-7 ${card.accent}`} />
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <h4 className="text-lg font-bold text-[#214B11]">
<span className="text-muted-foreground">Risiko Tinggi</span> {card.title}
<span className="font-semibold">{summary.riskDistribution.high}</span> </h4>
<p className="text-sm leading-6 text-slate-500">
{card.description}
</p>
</div> </div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Sedang</span>
<span className="font-semibold">{summary.riskDistribution.medium}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Rendah</span>
<span className="font-semibold">{summary.riskDistribution.low}</span>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
);
})}
</div>
</section> </section>
<ImageClassificationForm {/* Diseases quick access - now clickable, link to catalog/:slug */}
onSubmit={async (file) => { <section className="space-y-4">
await createDiagnosisMutation.mutateAsync(file); <div className="mb-2 flex items-start justify-between">
}} <div>
isSubmitting={createDiagnosisMutation.isPending} <h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
latestResult={diagnoses[0] ?? null} Penyakit yang Dapat Dideteksi
/> </h3>
<p className="text-[15px] font-normal text-muted-foreground">
{diseases.length} kelas penyakit dan kondisi daun jagung dalam sistem kami
</p>
</div>
<Link
to="/catalog"
className="text-emerald-600 font-semibold inline-flex items-center gap-1"
>
Lihat Pustaka <ChevronRight className="w-4 h-4" />
</Link>
</div>
<ManualClassificationForm {diseasesQuery.isLoading ? (
diseases={diseases} <div className="text-center text-muted-foreground py-4">
onSubmit={async (payload) => { Memuat data penyakit...
await createClassificationMutation.mutateAsync(payload); </div>
}} ) : (
isSubmitting={createClassificationMutation.isPending} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{diseasesQuick.map((d) => (
<Link key={d.slug} to={`/catalog/${d.slug}`}>
<Card
className="rounded-2xl bg-white p-4 shadow-sm h-full transition-transform hover:scale-[1.03] hover:shadow-md cursor-pointer"
>
<CardContent className="h-full p-4 flex flex-col justify-between">
<div className="flex items-start gap-3">
<div
className="mt-1 h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: d.color }}
/> />
<div>
{diagnoses.length > 0 && ( <div className="text-sm font-bold text-[#214B11]">
<Card> {d.name}
<CardHeader> </div>
<CardTitle className="flex items-center gap-2"> <div className="text-xs text-slate-400 italic mt-1">
<History className="h-5 w-5" /> {d.sci}
Riwayat Diagnosis </div>
</CardTitle> </div>
<CardDescription> </div>
{diagnoses.length} diagnosis yang telah dibuat </CardContent>
</CardDescription> </Card>
</CardHeader> </Link>
<CardContent>
<div className="space-y-3">
{diagnoses.slice(0, 6).map((diagnosis) => (
<DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} />
))} ))}
</div> </div>
</CardContent>
</Card>
)} )}
</section>
{/* Scan quick access */}
<div className="mt-12 flex flex-col sm:flex-row items-center gap-4 sm:gap-6 bg-[#1E8A2A] rounded-3xl p-5 sm:p-6">
<div className="flex-1 text-white">
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
<p className="text-sm text-[#9AD872] font-normal mt-2">
Unggah foto daun jagung Anda dan dapatkan hasil analisis AI
dalam hitungan detik.
</p>
</div>
<Button
asChild
variant="outline"
className="bg-white hover:bg-[#1E8A2A]/90 hover:text-white px-6 py-6 text-lg font-bold text-[#214B11] shrink-0"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Mulai Scan Sekarang
</Link>
</Button>
</div>
</> </>
)} )}
</div> </div>
</main>
); );
} }
+197
View File
@@ -0,0 +1,197 @@
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { RiskBadge } from "@/components/risk-badge";
import { apiClient } from "@/lib/api-client";
import type { DiagnosisStatus, RiskLevel } from "@zeavis/shared";
const STATUS_OPTIONS: { value: "all" | DiagnosisStatus; label: string }[] = [
{ value: "all", label: "Semua" },
{ value: "ai_verified", label: "Terverifikasi AI" },
{ value: "needs_review", label: "Menunggu Review" },
{ value: "expert_verified", label: "Diverifikasi Pakar" },
{ value: "expert_corrected", label: "Dikoreksi Pakar" },
{ value: "failed", label: "Gagal" },
];
const RISK_OPTIONS: { value: "all" | RiskLevel; label: string }[] = [
{ value: "all", label: "Semua Risiko" },
{ value: "high", label: "Risiko Tinggi" },
{ value: "medium", label: "Risiko Sedang" },
{ value: "low", label: "Risiko Rendah" },
];
export function DiagnosesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialStatus = searchParams.get("status") as DiagnosisStatus | null;
const initialRisk = searchParams.get("risk") as RiskLevel | null;
const [statusFilter, setStatusFilter] = useState<
"all" | DiagnosisStatus
>(initialStatus ?? "all");
const [riskFilter, setRiskFilter] = useState<
"all" | RiskLevel
>(initialRisk ?? "all");
const query = useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
});
const diagnoses = query.data ?? [];
const filtered = useMemo(() => {
return diagnoses.filter((d) => {
if (statusFilter !== "all" && d.status !== statusFilter) return false;
if (riskFilter !== "all") {
const level = d.disease?.riskLevel ?? "low";
if (level !== riskFilter) return false;
}
return true;
});
}, [diagnoses, statusFilter, riskFilter]);
const handleStatusChange = (value: string) => {
const v = value as "all" | DiagnosisStatus;
setStatusFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("status");
else next.set("status", v);
setSearchParams(next);
};
const handleRiskChange = (value: string) => {
const v = value as "all" | RiskLevel;
setRiskFilter(v);
const next = new URLSearchParams(searchParams);
if (v === "all") next.delete("risk");
else next.set("risk", v);
setSearchParams(next);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
<p className="text-gray-500 mt-1 text-md">
Lihat hasil diagnosa dari scan yang telah dilakukan
</p>
</div>
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link to="/scan">Scan Baru</Link>
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label>
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<label className="text-sm font-medium">Risiko</label>
<select
value={riskFilter}
onChange={(e) => handleRiskChange(e.target.value)}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
{RISK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="w-full sm:w-auto sm:ml-auto text-sm text-muted-foreground">
Total: {filtered.length}
</div>
</div>
{query.isLoading ? (
<div className="py-12 text-center text-muted-foreground">
Memuat diagnosis...
</div>
) : query.isError ? (
<div className="py-12 text-center text-red-600">
Gagal memuat diagnosis
</div>
) : filtered.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Tidak ada diagnosis sesuai filter
</p>
{diagnoses.length === 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Mulai dengan{" "}
<Link to="/scan" className="text-primary underline">
melakukan scan daun
</Link>
.
</p>
)}
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{filtered.map((d) => (
<Card key={d.id} className="h-full">
<CardContent className="flex gap-4 p-4 items-start">
<img
src={d.imageUrl}
alt="Daun"
className="h-16 md:h-24 w-16 md:w-24 rounded-md object-cover bg-muted shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-lg font-semibold truncate">
{d.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<p className="text-sm text-muted-foreground">
{d.disease?.label ?? d.predictedDiseaseSlug}
</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<DiagnosisStatusBadge status={d.status} />
<RiskBadge level={d.disease?.riskLevel ?? "low"} />
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
{d.confidence !== null && (
<span className="ml-2">
{(d.confidence * 100).toFixed(0)}%
</span>
)}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold text-sm shrink-0"
>
Lihat detail
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
export default DiagnosesPage;
+5 -7
View File
@@ -14,23 +14,22 @@ export function DiagnosisDetailPage() {
enabled: Boolean(id), enabled: Boolean(id),
}); });
if (query.isLoading) return <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>; if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>;
if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>; if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>;
const diagnosis = query.data; const diagnosis = query.data;
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<p className="text-sm font-medium text-primary">Detail Diagnosis</p> <p className="text-sm font-medium text-primary">Detail Diagnosis</p>
<h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1> <h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
</div> </div>
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button> <Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
</div> </div>
<section className="grid gap-6 lg:grid-cols-[360px_1fr]"> <section className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" /> <img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
@@ -99,6 +98,5 @@ export function DiagnosisDetailPage() {
</Card> </Card>
)} )}
</div> </div>
</main>
); );
} }
+12 -14
View File
@@ -1,7 +1,7 @@
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isDiseaseSlug } from '@zeavis/shared'; import { isDiseaseSlug } from '@zeavis/shared';
import { ArrowLeft, BookOpen, AlertCircle } from 'lucide-react'; import { ArrowLeft, AlertCircle, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge'; import { RiskBadge } from '@/components/risk-badge';
@@ -20,37 +20,37 @@ export function DiseaseDetailPage() {
if (!validatedSlug || error || (!isLoading && !disease)) { if (!validatedSlug || error || (!isLoading && !disease)) {
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl"> <div className="flex items-center gap-4">
<Button asChild variant="ghost" className="mb-8"> <Button asChild variant="ghost">
<Link to="/catalog"> <Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog Kembali ke Katalog
</Link> </Link>
</Button> </Button>
</div>
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p> <p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card> </Card>
</div> </div>
</main>
); );
} }
if (isLoading) { if (isLoading) {
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl"> <div className="flex items-center gap-4">
<Button asChild variant="ghost" className="mb-8"> <Button asChild variant="ghost">
<Link to="/catalog"> <Link to="/catalog">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Kembali ke Katalog Kembali ke Katalog
</Link> </Link>
</Button> </Button>
</div>
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p> <p className="text-muted-foreground">Memuat materi...</p>
</Card> </Card>
</div> </div>
</main>
); );
} }
@@ -59,8 +59,7 @@ export function DiseaseDetailPage() {
} }
return ( return (
<main className="min-h-screen px-6 py-8"> <div className="space-y-6">
<div className="mx-auto max-w-4xl space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button asChild variant="ghost"> <Button asChild variant="ghost">
<Link to="/catalog"> <Link to="/catalog">
@@ -79,7 +78,7 @@ export function DiseaseDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1> <h1 className="text-2xl md:text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p> <p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
</div> </div>
<RiskBadge level={disease.riskLevel} /> <RiskBadge level={disease.riskLevel} />
@@ -132,7 +131,7 @@ export function DiseaseDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex gap-3 pt-4"> <div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button asChild className="flex-1"> <Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link> <Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button> </Button>
@@ -141,6 +140,5 @@ export function DiseaseDetailPage() {
</Button> </Button>
</div> </div>
</div> </div>
</main>
); );
} }
+151 -85
View File
@@ -1,43 +1,61 @@
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from "react";
import { Link, useSearchParams } from 'react-router-dom'; import { useSearchParams } from "react-router-dom";
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DiseaseSlug } from '@zeavis/shared'; import type { DiseaseSlug } from "@zeavis/shared";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; Card,
import { apiClient } from '@/lib/api-client'; CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { apiClient } from "@/lib/api-client";
export function ExpertReviewsPage() { export function ExpertReviewsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('diagnosis'); const selectedId = searchParams.get("diagnosis");
const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified'); const [verdict, setVerdict] = useState<"verified" | "corrected">("verified");
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<DiseaseSlug | ''>(''); const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<
const [notes, setNotes] = useState(''); DiseaseSlug | ""
>("");
const [notes, setNotes] = useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [reviewsQuery, diseasesQuery] = useQueries({ const [reviewsQuery, diseasesQuery] = useQueries({
queries: [ queries: [
{ queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() }, {
{ queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() }, queryKey: ["expert-reviews"],
queryFn: () => apiClient.getExpertReviews(),
},
{ queryKey: ["diseases"], queryFn: () => apiClient.getDiseases() },
], ],
}); });
const reviews = reviewsQuery.data || []; const reviews = reviewsQuery.data || [];
const diseases = diseasesQuery.data || []; const diseases = diseasesQuery.data || [];
const selected = useMemo(() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null, [reviews, selectedId]); const selected = useMemo(
() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null,
[reviews, selectedId],
);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => apiClient.reviewDiagnosis(selected!.id, { mutationFn: () =>
apiClient.reviewDiagnosis(selected!.id, {
verdict, verdict,
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined, correctedDiseaseSlug:
verdict === "corrected"
? correctedDiseaseSlug || undefined
: undefined,
notes, notes,
}), }),
onSuccess: () => { onSuccess: () => {
setNotes(''); setNotes("");
setVerdict('verified'); setVerdict("verified");
setCorrectedDiseaseSlug(''); setCorrectedDiseaseSlug("");
queryClient.invalidateQueries({ queryKey: ['expert-reviews'] }); queryClient.invalidateQueries({ queryKey: ["expert-reviews"] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
}, },
}); });
@@ -47,31 +65,39 @@ export function ExpertReviewsPage() {
await mutation.mutateAsync(); await mutation.mutateAsync();
} }
const isLoading = reviewsQuery.isPending || diseasesQuery.isPending;
return ( return (
<main className="min-h-screen bg-background"> <div className="space-y-6">
<header className="border-b bg-card"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="mx-auto flex max-w-7xl items-center justify-between p-6"> <div>
<h1 className="text-2xl font-bold">Expert Reviews</h1> <h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
<Link to="/dashboard"> <p className="text-gray-500 mt-1 text-md">
<Button variant="outline">Dashboard</Button> Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
</Link> feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
</p>
</div> </div>
</header> </div>
<div className="mx-auto max-w-7xl p-6">
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Left: List of reviews */} {/* Left: List of reviews */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-semibold">Diagnoses to Review</h2> <h2 className="font-semibold">Daftar Diagnosis</h2>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span> <span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
{reviews.length}
</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{reviewsQuery.isPending || diseasesQuery.isPending ? ( {isLoading ? (
<p className="text-sm text-muted-foreground">Loading reviews and diseases...</p> <p className="text-sm text-muted-foreground">
Memuat diagnosis...
</p>
) : reviews.length === 0 ? ( ) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">No diagnoses pending review</p> <p className="text-sm text-muted-foreground">
Tidak ada diagnosis yang menunggu review
</p>
) : ( ) : (
reviews.map((review) => ( reviews.map((review) => (
<button <button
@@ -80,26 +106,35 @@ export function ExpertReviewsPage() {
onClick={() => setSearchParams({ diagnosis: review.id })} onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${ className={`w-full text-left transition ${
selected?.id === review.id selected?.id === review.id
? 'ring-2 ring-primary' ? "ring-2 ring-primary"
: 'hover:border-primary' : "hover:border-primary"
}`} }`}
> >
<Card className="transition hover:border-primary"> <Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3"> <CardContent className="flex gap-3 p-3 items-start">
<img <img
src={review.imageUrl} src={review.imageUrl}
alt="Daun jagung" alt="Daun jagung"
className="h-20 w-20 rounded-lg object-cover" className="h-14 md:h-20 w-14 md:w-20 rounded-lg object-cover shrink-0"
/> />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1"> <div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3> <h3 className="text-sm font-semibold">
<DiagnosisStatusBadge status={review.status} className="text-xs" /> {review.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<DiagnosisStatusBadge
status={review.status}
className="text-xs"
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`} {review.confidence === null
? "Tidak ada confidence"
: `${(review.confidence * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleString("id-ID")}
</p> </p>
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -111,10 +146,10 @@ export function ExpertReviewsPage() {
{/* Right: Detail and form */} {/* Right: Detail and form */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{reviewsQuery.isPending || diseasesQuery.isPending ? ( {isLoading ? (
<Card> <Card>
<CardContent className="p-6 text-center text-muted-foreground"> <CardContent className="p-6 text-center text-muted-foreground">
Loading reviews and diseases... Memuat diagnosis...
</CardContent> </CardContent>
</Card> </Card>
) : selected ? ( ) : selected ? (
@@ -122,16 +157,18 @@ export function ExpertReviewsPage() {
{/* Detail card */} {/* Detail card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle> <CardTitle>
{selected.disease?.commonName ?? "Diagnosis gagal"}
</CardTitle>
<CardDescription> <CardDescription>
{new Date(selected.createdAt).toLocaleString('id-ID')} {new Date(selected.createdAt).toLocaleString("id-ID")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<img <img
src={selected.imageUrl} src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`} alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
className="h-64 w-full rounded-lg object-cover" className="h-48 md:h-64 w-full rounded-lg object-cover"
/> />
<div className="space-y-2"> <div className="space-y-2">
@@ -142,20 +179,28 @@ export function ExpertReviewsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span> <span className="text-sm font-medium">Confidence</span>
<span className="text-sm"> <span className="text-sm">
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`} {selected.confidence === null
? "N/A"
: `${(selected.confidence * 100).toFixed(1)}%`}
</span> </span>
</div> </div>
</div> </div>
{/* Top predictions */}
{selected.predictions.length > 0 && ( {selected.predictions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium">Top Predictions</h4> <h4 className="text-sm font-medium">Prediksi Teratas</h4>
<div className="space-y-1"> <div className="space-y-1">
{selected.predictions.map((pred) => ( {selected.predictions.map((pred) => (
<div key={pred.id} className="flex items-center justify-between text-sm"> <div
<span className="text-muted-foreground">{pred.modelLabel}</span> key={pred.id}
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span> className="flex items-center justify-between text-sm"
>
<span className="text-muted-foreground">
{pred.modelLabel}
</span>
<span className="font-medium">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div> </div>
))} ))}
</div> </div>
@@ -167,58 +212,75 @@ export function ExpertReviewsPage() {
{/* Review form */} {/* Review form */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Expert Review</CardTitle> <CardTitle className="text-lg">Review Pakar</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Verdict selection */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Verdict</label> <label className="text-sm font-medium">Keputusan</label>
<div className="flex gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2"> <label
htmlFor="verdict-verified"
className="flex items-center gap-2"
>
<input <input
id="verdict-verified" id="verdict-verified"
type="radio" type="radio"
name="verdict" name="verdict"
value="verified" value="verified"
checked={verdict === 'verified'} checked={verdict === "verified"}
onChange={(e) => { onChange={(e) => {
setVerdict(e.target.value as 'verified' | 'corrected'); setVerdict(
setCorrectedDiseaseSlug(''); e.target.value as "verified" | "corrected",
);
setCorrectedDiseaseSlug("");
}} }}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-sm">Verified</span> <span className="text-sm">Terverifikasi</span>
</label> </label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2"> <label
htmlFor="verdict-corrected"
className="flex items-center gap-2"
>
<input <input
id="verdict-corrected" id="verdict-corrected"
type="radio" type="radio"
name="verdict" name="verdict"
value="corrected" value="corrected"
checked={verdict === 'corrected'} checked={verdict === "corrected"}
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')} onChange={(e) =>
setVerdict(
e.target.value as "verified" | "corrected",
)
}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-sm">Corrected</span> <span className="text-sm">Dikoreksi</span>
</label> </label>
</div> </div>
</div> </div>
{/* Disease selection (only if corrected) */} {verdict === "corrected" && (
{verdict === 'corrected' && (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium"> <label
Correct Disease htmlFor="disease"
className="text-sm font-medium"
>
Penyakit yang Benar
</label> </label>
<select <select
id="disease" id="disease"
value={correctedDiseaseSlug} value={correctedDiseaseSlug}
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')} onChange={(e) =>
required={verdict === 'corrected'} setCorrectedDiseaseSlug(
e.target.value as DiseaseSlug | "",
)
}
required={verdict === "corrected"}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<option value="">Select a disease...</option> <option value="">Pilih penyakit...</option>
{diseases.map((disease) => ( {diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}> <option key={disease.slug} value={disease.slug}>
{disease.commonName} {disease.commonName}
@@ -228,34 +290,39 @@ export function ExpertReviewsPage() {
</div> </div>
)} )}
{/* Notes */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium"> <label htmlFor="notes" className="text-sm font-medium">
Notes <span className="text-red-500">*</span> Catatan <span className="text-red-500">*</span>
</label> </label>
<textarea <textarea
id="notes" id="notes"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Enter your review notes..." placeholder="Tulis catatan review Anda..."
required required
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
rows={4} rows={4}
/> />
</div> </div>
{/* Submit button */}
<Button <Button
type="submit" type="submit"
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()} disabled={
mutation.isPending ||
(verdict === "corrected" && !correctedDiseaseSlug) ||
!notes.trim()
}
className="w-full" className="w-full"
> >
{mutation.isPending ? 'Submitting...' : 'Submit Review'} {mutation.isPending ? "Mengirim..." : "Kirim Review"}
</Button> </Button>
{mutation.isError && ( {mutation.isError && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Unknown error'} Error:{" "}
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan"}
</p> </p>
)} )}
</form> </form>
@@ -265,13 +332,12 @@ export function ExpertReviewsPage() {
) : ( ) : (
<Card> <Card>
<CardContent className="p-6 text-center text-muted-foreground"> <CardContent className="p-6 text-center text-muted-foreground">
No diagnoses available for review Tidak ada diagnosis yang tersedia untuk review
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </div>
</div> </div>
</div> </div>
</main>
); );
} }
+32 -19
View File
@@ -1,23 +1,31 @@
import { ArrowRight, Leaf, ShieldCheck, Sprout } from 'lucide-react'; import { ArrowRight, Leaf, ShieldCheck, Sprout } from "lucide-react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const features = [ const features = [
{ {
icon: Leaf, icon: Leaf,
title: 'Katalog Penyakit Lengkap', title: "Katalog Penyakit Lengkap",
description: 'Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.', description:
"Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.",
}, },
{ {
icon: ShieldCheck, icon: ShieldCheck,
title: 'Pantau Risiko Penyakit', title: "Pantau Risiko Penyakit",
description: 'Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.', description:
"Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.",
}, },
{ {
icon: Sprout, icon: Sprout,
title: 'Laporkan Pengamatan', title: "Laporkan Pengamatan",
description: 'Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.', description:
"Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.",
}, },
]; ];
@@ -44,23 +52,26 @@ export function LandingPage() {
</div> </div>
<div className="space-y-5"> <div className="space-y-5">
<h1 className="max-w-3xl text-5xl font-bold tracking-tight sm:text-6xl"> <h1 className="max-w-3xl text-5xl font-bold tracking-tight sm:text-6xl">
Belajar mengenali penyakit daun jagung dan pantau pengamatan Anda. Belajar mengenali penyakit daun jagung dan pantau pengamatan
Anda.
</h1> </h1>
<p className="max-w-2xl text-lg leading-8 text-muted-foreground"> <p className="max-w-2xl text-lg leading-8 text-muted-foreground">
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara penanganannya, Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara
serta laporkan pengamatan Anda untuk membantu penelitian dan edukasi. penanganannya, serta laporkan pengamatan Anda untuk membantu
penelitian dan edukasi.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg"> <Button asChild size="lg">
<Link to="/dashboard"> <Link to="/scan">
Buka Dashboard <ArrowRight className="ml-2 h-4 w-4" /> Mulai Scan Sekarang <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
<Button asChild size="lg" variant="outline"> <Button asChild size="lg" variant="outline">
<Link to="/catalog"> <Link to="/library">Pustaka Penyakit</Link>
Lihat Katalog Penyakit </Button>
</Link> <Button asChild size="lg" variant="ghost">
<Link to="/dashboard">Dashboard</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -74,7 +85,9 @@ export function LandingPage() {
</div> </div>
<div> <div>
<CardTitle className="text-xl">{feature.title}</CardTitle> <CardTitle className="text-xl">{feature.title}</CardTitle>
<CardDescription className="mt-2 leading-6">{feature.description}</CardDescription> <CardDescription className="mt-2 leading-6">
{feature.description}
</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { DiseaseCard } from "@/components/disease-card";
import { apiClient } from "@/lib/api-client";
import type { RiskLevel } from "@zeavis/shared";
export function LibraryPage() {
const [searchQuery, setSearchQuery] = useState("");
const [riskFilter, setRiskFilter] = useState<RiskLevel | "all">("all");
const { data: diseases, isLoading, error } = useQuery({
queryKey: ["diseases"],
queryFn: () => apiClient.getDiseases(),
});
const filteredDiseases = (diseases || []).filter((disease) => {
const matchesSearch =
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.summary.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRisk = riskFilter === "all" || disease.riskLevel === riskFilter;
return matchesSearch && matchesRisk;
});
return (
<div className="space-y-6">
<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">Pustaka Penyakit</h1>
<p className="text-gray-500 mt-1 text-md">
Referensi lengkap penyakit daun jagung yang dapat dideteksi oleh sistem AI
</p>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1">
<label htmlFor="search" className="block text-sm font-medium mb-2">
Cari penyakit
</label>
<input
id="search"
type="text"
placeholder="Cari berdasarkan nama atau gejala..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
Filter risiko
</label>
<select
id="risk-filter"
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | "all")}
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="all">Semua Risiko</option>
<option value="low">Risiko Rendah</option>
<option value="medium">Risiko Sedang</option>
<option value="high">Risiko Tinggi</option>
</select>
</div>
</div>
{isLoading && (
<div className="py-12 text-center text-muted-foreground">
Memuat pustaka penyakit...
</div>
)}
{error && (
<div className="py-12 text-center text-red-600">
Gagal memuat pustaka penyakit
</div>
)}
{!isLoading && !error && filteredDiseases.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
Tidak ada penyakit yang cocok dengan filter ini.
</div>
)}
{!isLoading && !error && filteredDiseases.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredDiseases.map((disease) => (
<DiseaseCard key={disease.slug} disease={disease} />
))}
</div>
)}
</div>
);
}
+22 -13
View File
@@ -1,35 +1,41 @@
import { useState } from 'react'; import { useState } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from '@/components/auth-form'; import { AuthForm } from "@/components/auth-form";
import { apiClient } from '@/lib/api-client'; import { apiClient } from "@/lib/api-client";
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from "@/store/auth-store";
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: () => apiClient.getMe(),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: apiClient.login, mutationFn: apiClient.login,
onSuccess: (response) => { onSuccess: (response) => {
setUser(response.user); setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response); queryClient.setQueryData(["auth", "me"], response);
navigate('/dashboard'); navigate("/dashboard");
}, },
onError: (err) => setError(err instanceof Error ? err.message : 'Login gagal'), onError: (err) =>
setError(err instanceof Error ? err.message : "Login gagal"),
}); });
return ( return (
<main className="flex min-h-screen items-center justify-center px-6 py-12"> <main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4"> <div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm <AuthForm
mode="login" mode="login"
isSubmitting={mutation.isPending} isSubmitting={mutation.isPending}
error={error} error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)} googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ email, password }) => { onSubmit={async ({ email, password }) => {
setError(null); setError(null);
return mutation.mutateAsync({ email, password }); return mutation.mutateAsync({ email, password });
@@ -37,7 +43,10 @@ export function LoginPage() {
onFieldChange={() => setError(null)} onFieldChange={() => setError(null)}
/> />
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Belum punya akun? <Link className="text-primary" to="/register">Daftar</Link> Belum punya akun?{" "}
<Link className="text-primary" to="/register">
Daftar
</Link>
</p> </p>
</div> </div>
</main> </main>
+23 -14
View File
@@ -1,43 +1,52 @@
import { useState } from 'react'; import { useState } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from '@/components/auth-form'; import { AuthForm } from "@/components/auth-form";
import { apiClient } from '@/lib/api-client'; import { apiClient } from "@/lib/api-client";
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from "@/store/auth-store";
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: () => apiClient.getMe(),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: apiClient.register, mutationFn: apiClient.register,
onSuccess: (response) => { onSuccess: (response) => {
setUser(response.user); setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response); queryClient.setQueryData(["auth", "me"], response);
navigate('/dashboard'); navigate("/dashboard");
}, },
onError: (err) => setError(err instanceof Error ? err.message : 'Registrasi gagal'), onError: (err) =>
setError(err instanceof Error ? err.message : "Registrasi gagal"),
}); });
return ( return (
<main className="flex min-h-screen items-center justify-center px-6 py-12"> <main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full space-y-4"> <div className="w-full max-w-sm md:max-w-md space-y-4">
<AuthForm <AuthForm
mode="register" mode="register"
isSubmitting={mutation.isPending} isSubmitting={mutation.isPending}
error={error} error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)} googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ name, email, password }) => { onSubmit={async ({ name, email, password }) => {
setError(null); setError(null);
return mutation.mutateAsync({ name: name ?? '', email, password }); return mutation.mutateAsync({ name: name ?? "", email, password });
}} }}
onFieldChange={() => setError(null)} onFieldChange={() => setError(null)}
/> />
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Sudah punya akun? <Link className="text-primary" to="/login">Masuk</Link> Sudah punya akun?{" "}
<Link className="text-primary" to="/login">
Masuk
</Link>
</p> </p>
</div> </div>
</main> </main>
+488
View File
@@ -0,0 +1,488 @@
import { useRef, useState } 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 { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import type { DiagnosisRecord } from "@zeavis/shared";
import { apiClient } from "@/lib/api-client";
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 mutation = useMutation({
mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
setDiagnosisPreview(diagnosis);
setPreviewOpen(true);
setFileName(null);
setPreviewUrl(null);
if (inputRef.current) inputRef.current.value = "";
},
});
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);
// Detect image dimensions
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;
mutation.mutate(file);
};
const [previewOpen, setPreviewOpen] = useState(false);
const [diagnosisPreview, setDiagnosisPreview] =
useState<DiagnosisRecord | null>(null);
const diagnosesQuery = useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
enabled: previewOpen,
});
return (
<div className="space-y-6">
<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">Scan Tanaman</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>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card className="w-full lg:h-117 py-3">
<CardContent className="px-6 py-4 h-full flex flex-col">
{/* Left Sidebar */}
<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>
{/* Upload area */}
{!previewUrl ? (
<div className="space-y-3">
<div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<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
</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,
});
}}
/>
{/* Scanner area detection overlay */}
{imageDimensions && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{/* Outer dark overlay */}
<div className="absolute inset-0 bg-black/20" />
{/* Scan area frame - responsive to image */}
<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`,
}}
>
{/* Corner markers */}
<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" />
{/* Center text */}
<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"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
{/* Error state */}
{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 Sidebar */}
<aside className="lg:col-span-1 flex flex-col gap-5">
{/* Card 1: Panduan Pengambilan Foto */}
<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-lg bg-emerald-50 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-lg bg-emerald-50 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-lg bg-emerald-50 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-lg bg-emerald-50 flex items-center justify-center shrink-0">
<Camera className="text-emerald-600" size={20} />
</div>
<div className="text-sm text-slate-700">
Hindari bayangan atau pantulan pada daun & blur pada gambar
</div>
</li>
</ul>
<div className="mt-4">
<div className="rounded-lg overflow-hidden mt-4">
<div className="relative h-36 overflow-hidden bg-[url('/src/assets/images/scan-guide-bg.png')] bg-cover bg-center text-white md:h-44">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-0 bg-linear-to-b from-black/5 via-transparent to-black/35" />
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-5 text-center">
<div className="relative flex h-full w-30 items-center justify-center mt-8">
<span className="absolute left-0 top-0 h-4 w-4 border-l-2 border-t-2 border-l-lime-300 border-t-lime-300" />
<span className="absolute right-0 top-0 h-4 w-4 border-r-2 border-t-2 border-r-lime-300 border-t-lime-300" />
<span className="absolute bottom-0 left-0 h-4 w-4 border-b-2 border-l-2 border-b-lime-300 border-l-lime-300" />
<span className="absolute bottom-0 right-0 h-4 w-4 border-b-2 border-r-2 border-b-lime-300 border-r-lime-300" />
<span className="relative z-10 text-xs font-semibold tracking-[0.28em] text-white">
AREA SCAN
</span>
</div>
<div className="text-[11px] font-medium text-lime-100 bg-black/30 w-full py-1 rounded">
Posisi ideal: daun memenuhi bingkai
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Card 2: Persyaratan Berkas */}
<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" },
{ 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"
style={{ fontSize: "0.75rem" }}
>
{r.label}
</span>
<span className="text-sm font-medium text-gray-700">
{r.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
{/* Result Modal */}
<Modal
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
title={diagnosisPreview?.disease?.commonName ?? "Hasil Diagnosis"}
size="md"
footer={
diagnosisPreview && (
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
>
Tutup
</Button>
<Button
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
className="bg-green-600 hover:bg-green-700"
>
Lihat Detail Lengkap
</Button>
</div>
)
}
>
{diagnosisPreview ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<DiagnosisStatusBadge status={diagnosisPreview.status} />
{diagnosisPreview.confidence !== null && (
<span className="text-sm font-medium">
Confidence: {(diagnosisPreview.confidence * 100).toFixed(1)}%
</span>
)}
</div>
{diagnosisPreview.disease && (
<div>
<h3 className="font-semibold text-lg">
{diagnosisPreview.disease.commonName}
</h3>
<p className="text-sm text-muted-foreground">
{diagnosisPreview.disease.summary}
</p>
</div>
)}
{diagnosisPreview.failureReason && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{diagnosisPreview.failureReason}
</div>
)}
{diagnosisPreview.predictions.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Semua Prediksi</h4>
<div className="space-y-2">
{diagnosisPreview.predictions
.sort((a, b) => a.rank - b.rank)
.map((pred) => (
<div
key={pred.id}
className="flex items-center justify-between rounded-lg border p-2 text-sm"
>
<span>{pred.modelLabel}</span>
<span className="font-semibold">
{(pred.confidence * 100).toFixed(1)}%
</span>
</div>
))}
</div>
</div>
)}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">
Riwayat Diagnosis Terbaru
</h4>
{diagnosesQuery.isLoading && (
<div className="text-sm text-muted-foreground">
Memuat riwayat...
</div>
)}
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
<div className="space-y-2">
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => (
<div
key={d.id}
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
>
<div className="flex items-center gap-3">
<img
src={d.imageUrl}
alt="thumb"
className="h-10 w-10 rounded object-cover bg-muted"
/>
<div className="text-sm">
<div className="font-medium">
{d.disease?.commonName ?? "Unknown"}
</div>
<div className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
</div>
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 text-sm font-semibold"
>
Lihat
</Link>
</div>
))}
</div>
)}
</div>
</div>
) : null}
</Modal>
</div>
);
}
-31
View File
@@ -1,31 +0,0 @@
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
+2 -1
View File
@@ -1,4 +1,5 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import path from 'node:path'; import path from 'node:path';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
@@ -7,7 +8,7 @@ export default defineConfig(({ mode }) => {
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000'; const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
return { return {
plugins: [react()], plugins: [react(), tsconfigPaths()],
server: { server: {
proxy: { proxy: {
'/api': apiProxyTarget, '/api': apiProxyTarget,
+7
View File
@@ -50,6 +50,7 @@
"postcss": "^8.5.15", "postcss": "^8.5.15",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.14", "vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1",
}, },
}, },
"packages/shared": { "packages/shared": {
@@ -297,6 +298,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -381,6 +384,8 @@
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="],
@@ -393,6 +398,8 @@
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],