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>,
+121 -56
View File
@@ -1,80 +1,96 @@
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 || [])
const matchesSearch = .sort((a, b) => a.displayOrder - b.displayOrder)
disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) || .filter((disease) => {
disease.label.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch =
disease.summary.toLowerCase().includes(searchQuery.toLowerCase()); disease.commonName.toLowerCase().includes(searchQuery.toLowerCase()) ||
disease.label.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;
return matchesSearch && matchesRisk; return matchesSearch && matchesRisk;
}); });
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>
<div className="flex items-center justify-between"> <h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
<div> <p className="text-gray-500 mt-1 text-md">
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1> Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu
<p className="mt-2 text-muted-foreground"> </p>
Pelajari tentang penyakit daun jagung dan cara penanganannya </div>
</p> </div>
</div>
<Button asChild variant="outline">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</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>
<label htmlFor="risk-filter" className="block text-sm font-medium mb-2"> <div>
Filter risiko <label htmlFor="risk-filter" className="block text-sm font-medium mb-2">
</label> Filter risiko
<select </label>
id="risk-filter" <select
value={riskFilter} id="risk-filter"
onChange={(e) => setRiskFilter(e.target.value as RiskLevel | 'all')} value={riskFilter}
className="rounded-md border border-border bg-background px-3 py-2 text-sm" onChange={(e) => {
> const v = e.target.value as RiskLevel | 'all';
<option value="all">Semua Risiko</option> setRiskFilter(v);
<option value="low">Risiko Rendah</option> const next = new URLSearchParams(searchParams);
<option value="medium">Risiko Sedang</option> if (v === 'all') next.delete('risk');
<option value="high">Risiko Tinggi</option> else next.set('risk', v);
</select> setSearchParams(next);
</div> }}
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>
</div> </div>
@@ -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>
); );
} }
+312 -236
View File
@@ -1,272 +1,348 @@
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(); queryFn: () => apiClient.getDashboardSummary(),
const navigate = useNavigate();
const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(),
},
{
queryKey: ['dashboard-summary'],
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
<Button variant="outline" onClick={toggleDashboardCompact}> asChild
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'} variant="outline"
</Button> className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
<Button >
variant="outline" <Link
onClick={() => logoutMutation.mutate()} to="/catalog"
disabled={logoutMutation.isPending} className="inline-flex items-center gap-2"
> >
<LogOut className="h-4 w-4 mr-2" /> Pustaka Penyakit
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'} <ChevronRight className="h-5 w-6" />
</Button> </Link>
</Button>
</div>
</div> </div>
</header> <div className="w-full md:w-1/3" />
</div>
</header>
{isLoadingData && ( {/* Loading / Error states */}
<Card className="p-8 text-center text-muted-foreground"> {isLoadingData && (
Memuat data dashboard... <Card className="p-8 text-center text-muted-foreground">
</Card> Memuat data dashboard...
)} </Card>
)}
{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>
</Card> <div className="mt-2 text-sm text-red-500">
)} {String(summaryQuery.error?.message)}
</div>
</Card>
)}
{!isLoadingData && !hasError && ( {/* Main dashboard content */}
<> {!isLoadingData && !hasError && (
{summary && ( <>
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}> {summary && (
<Card> <section
<CardHeader className="pb-3"> className={
<CardTitle className="text-sm font-medium text-muted-foreground"> dashboardCompact
Total Penyakit ? "grid gap-4 md:grid-cols-4 items-stretch"
</CardTitle> : "grid gap-6 md:grid-cols-4 items-stretch"
</CardHeader> }
<CardContent> >
<div className="text-3xl font-bold">{summary.diseaseCount}</div> <div className="md:col-span-4 text-xl font-bold">
</CardContent> <h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
</Card> 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>
<Card> {/* Total Diagnoses — the actual count from diagnoses table */}
<CardHeader className="pb-3">
<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">
Menunggu Review
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Tinggi
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">
{summary.riskDistribution.high}
</div>
</CardContent>
</Card>
</section>
)}
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}>
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-sm font-medium text-muted-foreground">
<BookOpen className="h-5 w-5" /> Total Diagnosa
Katalog Penyakit
</CardTitle> </CardTitle>
<CardDescription>
Pelajari tentang {diseases.length} penyakit daun jagung
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<Button asChild className="w-full"> <div className="text-3xl font-bold">
<Link to="/catalog">Buka Katalog</Link> {summary.imageClassificationCount}
</Button> </div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent> </CardContent>
</Card> </Card>
{/* Menunggu Review */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-sm font-medium text-muted-foreground">
<TrendingUp className="h-5 w-5" /> Menunggu Review
Distribusi Risiko
</CardTitle> </CardTitle>
<CardDescription>
Penyakit berdasarkan tingkat risiko
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
{summary && ( <div className="text-3xl font-bold text-amber-600">
<div className="space-y-2"> {summary.needsReviewCount}
<div className="flex items-center justify-between text-sm"> </div>
<span className="text-muted-foreground">Risiko Tinggi</span> <Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
<span className="font-semibold">{summary.riskDistribution.high}</span> Lihat daftar
</div> </Link>
<div className="flex items-center justify-between text-sm"> </CardContent>
<span className="text-muted-foreground">Risiko Sedang</span> </Card>
<span className="font-semibold">{summary.riskDistribution.medium}</span>
</div> {/* Diagnosa Gagal */}
<div className="flex items-center justify-between text-sm"> <Card>
<span className="text-muted-foreground">Risiko Rendah</span> <CardHeader className="pb-2">
<span className="font-semibold">{summary.riskDistribution.low}</span> <CardTitle className="text-sm font-medium text-muted-foreground">
</div> Gagal
</div> </CardTitle>
)} </CardHeader>
<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">
{summary.riskDistribution.high}
</div>
<Link to="/catalog?risk=high" className="text-red-600 ml-auto hover:underline">
Lihat pustaka
</Link>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
)}
<ImageClassificationForm {/* Mission section */}
onSubmit={async (file) => { <section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
await createDiagnosisMutation.mutateAsync(file); <div className="space-y-1">
}} <h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
isSubmitting={createDiagnosisMutation.isPending} Misi Platform
latestResult={diagnoses[0] ?? null} </h3>
/> <p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung
Indonesia
</p>
</div>
<ManualClassificationForm <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
diseases={diseases} {missionCards.map((card) => {
onSubmit={async (payload) => { const Icon = card.icon;
await createClassificationMutation.mutateAsync(payload);
}}
isSubmitting={createClassificationMutation.isPending}
/>
{diagnoses.length > 0 && ( return (
<Card> <Card
<CardHeader> key={card.title}
<CardTitle className="flex items-center gap-2"> className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full"
<History className="h-5 w-5" /> >
Riwayat Diagnosis <CardContent className="space-y-5 p-6 h-full flex flex-col justify-between">
</CardTitle> <div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]">
<CardDescription> <Icon className={`h-7 w-7 ${card.accent}`} />
{diagnoses.length} diagnosis yang telah dibuat </div>
</CardDescription> <div className="space-y-2">
</CardHeader> <h4 className="text-lg font-bold text-[#214B11]">
<CardContent> {card.title}
<div className="space-y-3"> </h4>
{diagnoses.slice(0, 6).map((diagnosis) => ( <p className="text-sm leading-6 text-slate-500">
<DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} /> {card.description}
))} </p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
);
})}
</div>
</section>
{/* Diseases quick access - now clickable, link to catalog/:slug */}
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
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>
{diseasesQuery.isLoading ? (
<div className="text-center text-muted-foreground py-4">
Memuat data penyakit...
</div>
) : (
<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>
<div className="text-sm font-bold text-[#214B11]">
{d.name}
</div>
<div className="text-xs text-slate-400 italic mt-1">
{d.sci}
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)} )}
</> </section>
)}
</div> {/* Scan quick access */}
</main> <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>
); );
} }
+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;
+8 -10
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> );
); }
}
+95 -97
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>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div> </div>
</main> <Card className="p-8 text-center">
<p className="text-muted-foreground">Materi tidak ditemukan</p>
</Card>
</div>
); );
} }
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>
<Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p>
</Card>
</div> </div>
</main> <Card className="p-8 text-center">
<p className="text-muted-foreground">Memuat materi...</p>
</Card>
</div>
); );
} }
@@ -59,88 +59,86 @@ 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"> <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> <Button asChild variant="outline">
<Button asChild variant="outline"> <Link to="/dashboard">
<Link to="/dashboard"> <BookOpen className="mr-2 h-4 w-4" />
<BookOpen className="mr-2 h-4 w-4" /> Dashboard
Dashboard </Link>
</Link> </Button>
</Button>
</div>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1>
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-lg leading-relaxed">{disease.summary}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Deskripsi</CardTitle>
</CardHeader>
<CardContent>
<p className="leading-relaxed text-muted-foreground">{disease.description}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gejala</CardTitle>
<CardDescription>Tanda-tanda yang perlu diperhatikan</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{disease.symptoms.map((symptom, index) => (
<li key={index} className="flex gap-3">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-primary mt-0.5" />
<span className="text-muted-foreground">{symptom}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Rekomendasi Penanganan</CardTitle>
<CardDescription>Langkah-langkah untuk mengendalikan penyakit</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{disease.recommendations.map((recommendation, index) => (
<li key={index} className="flex gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{index + 1}
</span>
<span className="text-muted-foreground pt-0.5">{recommendation}</span>
</li>
))}
</ol>
</CardContent>
</Card>
<div className="flex gap-3 pt-4">
<Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button>
<Button asChild variant="outline" className="flex-1">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div>
</div> </div>
</main>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<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>
</div>
<RiskBadge level={disease.riskLevel} />
</div>
<p className="text-lg leading-relaxed">{disease.summary}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Deskripsi</CardTitle>
</CardHeader>
<CardContent>
<p className="leading-relaxed text-muted-foreground">{disease.description}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gejala</CardTitle>
<CardDescription>Tanda-tanda yang perlu diperhatikan</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{disease.symptoms.map((symptom, index) => (
<li key={index} className="flex gap-3">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-primary mt-0.5" />
<span className="text-muted-foreground">{symptom}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Rekomendasi Penanganan</CardTitle>
<CardDescription>Langkah-langkah untuk mengendalikan penyakit</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{disease.recommendations.map((recommendation, index) => (
<li key={index} className="flex gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{index + 1}
</span>
<span className="text-muted-foreground pt-0.5">{recommendation}</span>
</li>
))}
</ol>
</CardContent>
</Card>
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button asChild className="flex-1">
<Link to="/catalog">Lihat Katalog Lengkap</Link>
</Button>
<Button asChild variant="outline" className="flex-1">
<Link to="/dashboard">Kembali ke Dashboard</Link>
</Button>
</div>
</div>
); );
} }
+307 -241
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: () =>
verdict, apiClient.reviewDiagnosis(selected!.id, {
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined, verdict,
notes, correctedDiseaseSlug:
}), verdict === "corrected"
? correctedDiseaseSlug || undefined
: undefined,
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,231 +65,279 @@ 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">Daftar Diagnosis</h2>
<h2 className="font-semibold">Diagnoses to Review</h2> <span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span> {reviews.length}
</div> </span>
<div className="space-y-2">
{reviewsQuery.isPending || diseasesQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading reviews and diseases...</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">No diagnoses pending review</p>
) : (
reviews.map((review) => (
<button
key={review.id}
type="button"
onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? 'ring-2 ring-primary'
: 'hover:border-primary'
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3">
<img
src={review.imageUrl}
alt="Daun jagung"
className="h-20 w-20 rounded-lg object-cover"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
<DiagnosisStatusBadge status={review.status} className="text-xs" />
</div>
<p className="text-xs text-muted-foreground">
{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>
</div>
</CardContent>
</Card>
</button>
))
)}
</div>
</div> </div>
{/* Right: Detail and form */} <div className="space-y-2">
<div className="lg:col-span-2 space-y-6"> {isLoading ? (
{reviewsQuery.isPending || diseasesQuery.isPending ? ( <p className="text-sm text-muted-foreground">
<Card> Memuat diagnosis...
<CardContent className="p-6 text-center text-muted-foreground"> </p>
Loading reviews and diseases... ) : reviews.length === 0 ? (
</CardContent> <p className="text-sm text-muted-foreground">
</Card> Tidak ada diagnosis yang menunggu review
) : selected ? ( </p>
<>
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString('id-ID')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
className="h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<DiagnosisStatusBadge status={selected.status} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
{/* Top predictions */}
{selected.predictions.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Top Predictions</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div key={pred.id} 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>
)}
</CardContent>
</Card>
{/* Review form */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Expert Review</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Verdict selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Verdict</label>
<div className="flex gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2">
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === 'verified'}
onChange={(e) => {
setVerdict(e.target.value as 'verified' | 'corrected');
setCorrectedDiseaseSlug('');
}}
className="h-4 w-4"
/>
<span className="text-sm">Verified</span>
</label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === 'corrected'}
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
className="h-4 w-4"
/>
<span className="text-sm">Corrected</span>
</label>
</div>
</div>
{/* Disease selection (only if corrected) */}
{verdict === 'corrected' && (
<div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium">
Correct Disease
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) => 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"
>
<option value="">Select a disease...</option>
{diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName}
</option>
))}
</select>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Notes <span className="text-red-500">*</span>
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Enter your review notes..."
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"
rows={4}
/>
</div>
{/* Submit button */}
<Button
type="submit"
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
className="w-full"
>
{mutation.isPending ? 'Submitting...' : 'Submit Review'}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Unknown error'}
</p>
)}
</form>
</CardContent>
</Card>
</>
) : ( ) : (
<Card> reviews.map((review) => (
<CardContent className="p-6 text-center text-muted-foreground"> <button
No diagnoses available for review key={review.id}
</CardContent> type="button"
</Card> onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? "ring-2 ring-primary"
: "hover:border-primary"
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3 items-start">
<img
src={review.imageUrl}
alt="Daun jagung"
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="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">
{review.disease?.commonName ?? "Diagnosis gagal"}
</h3>
<DiagnosisStatusBadge
status={review.status}
className="text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
{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>
</div>
</CardContent>
</Card>
</button>
))
)} )}
</div> </div>
</div> </div>
{/* Right: Detail and form */}
<div className="lg:col-span-2 space-y-6">
{isLoading ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Memuat diagnosis...
</CardContent>
</Card>
) : selected ? (
<>
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>
{selected.disease?.commonName ?? "Diagnosis gagal"}
</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString("id-ID")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
className="h-48 md:h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<DiagnosisStatusBadge status={selected.status} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null
? "N/A"
: `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
{selected.predictions.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div
key={pred.id}
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>
)}
</CardContent>
</Card>
{/* Review form */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Review Pakar</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Keputusan</label>
<div className="flex flex-col sm:flex-row gap-3">
<label
htmlFor="verdict-verified"
className="flex items-center gap-2"
>
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === "verified"}
onChange={(e) => {
setVerdict(
e.target.value as "verified" | "corrected",
);
setCorrectedDiseaseSlug("");
}}
className="h-4 w-4"
/>
<span className="text-sm">Terverifikasi</span>
</label>
<label
htmlFor="verdict-corrected"
className="flex items-center gap-2"
>
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === "corrected"}
onChange={(e) =>
setVerdict(
e.target.value as "verified" | "corrected",
)
}
className="h-4 w-4"
/>
<span className="text-sm">Dikoreksi</span>
</label>
</div>
</div>
{verdict === "corrected" && (
<div className="space-y-2">
<label
htmlFor="disease"
className="text-sm font-medium"
>
Penyakit yang Benar
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) =>
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"
>
<option value="">Pilih penyakit...</option>
{diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Catatan <span className="text-red-500">*</span>
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Tulis catatan review Anda..."
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"
rows={4}
/>
</div>
<Button
type="submit"
disabled={
mutation.isPending ||
(verdict === "corrected" && !correctedDiseaseSlug) ||
!notes.trim()
}
className="w-full"
>
{mutation.isPending ? "Mengirim..." : "Kirim Review"}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error:{" "}
{mutation.error instanceof Error
? mutation.error.message
: "Terjadi kesalahan"}
</p>
)}
</form>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Tidak ada diagnosis yang tersedia untuk review
</CardContent>
</Card>
)}
</div>
</div> </div>
</main> </div>
); );
} }
+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=="],