Merge pull request #19 from ATLAS-PJK-GM007/selly/frontend

feat: update landing page and add new library and scan pages
This commit is contained in:
Asep Haryana Saputra
2026-06-02 09:18:20 +07:00
committed by GitHub
21 changed files with 1243 additions and 289 deletions
+2 -1
View File
@@ -29,6 +29,7 @@
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.15",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1"
}
}
+55 -29
View File
@@ -1,47 +1,73 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthGuard } from '@/components/auth-guard';
import { DashboardPage } from '@/pages/dashboard-page';
import { LandingPage } from '@/pages/landing-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 { LoginPage } from '@/pages/login-page';
import { RegisterPage } from '@/pages/register-page';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from "react-router-dom";
import { DashboardPage } from "@/pages/dashboard-page";
import { ScanPage } from "@/pages/scan-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 { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: '/', element: <LandingPage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{
path: '/dashboard',
path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
path: "/scan",
element: (
<AuthGuard>
<MainLayout>
<ScanPage />
</MainLayout>
),
},
{
path: "/library",
element: (
<MainLayout>
<LibraryPage />
</MainLayout>
),
},
{
path: "/dashboard",
element: (
<MainLayout>
<DashboardPage />
</AuthGuard>
</MainLayout>
),
},
{
path: '/diagnoses/:id',
path: "/diagnoses/:id",
element: <DiagnosisDetailPage />,
},
{
path: "/diagnoses",
element: (
<AuthGuard>
<DiagnosisDetailPage />
</AuthGuard>
<MainLayout>
<DiagnosesPage />
</MainLayout>
),
},
{
path: '/expert/reviews',
element: (
<AuthGuard requireExpert>
<ExpertReviewsPage />
</AuthGuard>
),
path: "/expert/reviews",
element: <ExpertReviewsPage />,
},
{ path: '/catalog', element: <CatalogPage /> },
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> },
{ path: "/catalog", element: <CatalogPage /> },
{ path: "/catalog/:slug", element: <DiseaseDetailPage /> },
]);
export function App() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

@@ -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-6 py-8">
{children}
</main>
<Footer />
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Leaf } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/store/auth-store";
import { apiClient } from "@/lib/api-client";
export function Navbar() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
const isDashboard = location.pathname === "/dashboard";
const isScan = location.pathname === "/scan";
const isLibrary = location.pathname === "/library";
const navLinkClassName = (isActive: boolean) =>
[
"inline-flex items-center rounded-full px-4 py-2 text-[18px] font-medium transition-colors",
isActive
? "bg-[#48A111] text-white shadow-sm"
: "text-white/85 hover:bg-white/10 hover:text-white",
].join(" ");
const logoutMutation = useMutation({
mutationFn: async () => {
await apiClient.logout();
},
onSuccess: () => {
setUser(null);
queryClient.clear();
navigate("/");
},
});
const user = useAuthStore((state) => state.user);
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="flex items-center gap-3">
<Button asChild variant="ghost" className="rounded-full">
<Link to="/dashboard" className={navLinkClassName(isDashboard)}>
Dashboard
</Link>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<Link to="/scan" className={navLinkClassName(isScan)}>
Scan Tanaman
</Link>
</Button>
<Button asChild variant="ghost" className="rounded-full">
<Link to="/library" className={navLinkClassName(isLibrary)}>
Pustaka Penyakit
</Link>
</Button>
{user && (
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
{logoutMutation.isPending ? "Keluar..." : "Keluar"}
</Button>
)}
</nav>
</div>
</header>
);
}
+26 -15
View File
@@ -1,25 +1,25 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
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: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-border bg-transparent hover:bg-muted",
ghost: "hover:bg-[#48A111] hover:text-primary-foreground hover:rounded-[50px]",
},
size: {
default: 'h-10 px-4 py-2',
lg: 'h-12 rounded-lg px-6',
default: "h-10 px-4 py-2",
lg: "h-12 rounded-lg px-6",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
);
@@ -29,8 +29,19 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
asChild?: boolean;
};
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
export function 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]} 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>
);
}
+61
View File
@@ -0,0 +1,61 @@
export const mockDiseases = [
{
id: "nlb",
name: "Hawar Daun",
slug: "hawar-daun",
severity: "Tinggi",
imageUrl: "https://via.placeholder.com/320x180?text=Hawar+Daun",
pathogen: "Exserohilum turcicum",
description:
"Penyakit jamur yang menyebabkan lesi pada daun dan dapat menurunkan hasil panen.",
symptoms: [
"Lesi lonjong berbentuk cerutu, 2.5 - 15 cm",
"Warna abu-kehijauan berkembang menjadi coklat -abu",
"Nekrosis parah pada seluruh permukaan daun",
],
prevention:
"Gunakan varietas tahan, rotasi tanaman, dan hapus sisa tanaman terinfeksi.",
},
{
id: "rust",
name: "Karat Daun",
slug: "karat-daun",
imageUrl: "https://via.placeholder.com/320x180?text=Karat+Daun",
pathogen: "Puccinia spp.",
severity: "Sedang",
description:
"Pustulan oranye pada permukaan daun yang dapat mengurangi fotosintesis.",
symptoms: [
"Pustula oranye pada permukaan daun",
"Daun menguning dan rontok pada serangan berat",
],
prevention:
"Hindari kelembapan tinggi, gunakan fungisida bila perlu, dan perbaiki sirkulasi udara.",
},
{
id: "spot",
name: "Bercak Abu-abu",
slug: "bercak-abu-abu",
imageUrl: "https://via.placeholder.com/320x180?text=Bercak+Abu-abu",
pathogen: "Cercospora spp.",
severity: "Rendah",
description:
"Bercak kecil berwarna abu-abu yang umumnya tidak menyebabkan kematian tanaman.",
symptoms: [
"Bercak kecil bundar hingga tidak beraturan",
"Daun kering pada area bercak",
],
prevention:
"Praktek sanitasi, buang daun yang berat terinfeksi, dan pemantauan rutin.",
},
{
id: "healthy",
name: "Daun Sehat",
slug: "daun-sehat",
imageUrl: "https://via.placeholder.com/320x180?text=Daun+Sehat",
severity: "Sehat",
description: "Daun tanpa tanda penyakit.",
symptoms: [],
prevention: "-",
},
];
+4
View File
@@ -0,0 +1,4 @@
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.png";
declare module "*.svg";
+259 -198
View File
@@ -1,118 +1,116 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ManualClassificationForm } from '@/components/manual-classification-form';
import { ImageClassificationForm } from '@/components/image-classification-form';
import { DiagnosisCard } from '@/components/diagnosis-card';
import { useAuthStore } from '@/store/auth-store';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useUiStore } from "@/store/ui-store";
import { apiClient } from "@/lib/api-client";
import bg from "@/assets/images/dashboard-bg.png";
export function DashboardPage() {
const { user } = useAuthStore();
const setUser = useAuthStore((state) => state.setUser);
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
const queryClient = useQueryClient();
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 { dashboardCompact } = useUiStore();
const summaryQuery = useQuery({
queryKey: ["dashboard-summary"],
queryFn: () => apiClient.getDashboardSummary(),
});
const createDiagnosisMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createDiagnosis(file);
},
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 diagnoses = diagnosesQuery.data || [];
const classifications = classificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
const hasError = diseasesQuery.error || summaryQuery.error;
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 diseasesQuick = [
{ name: "Hawar Daun", sci: "Northern Leaf Blight", color: "#b91c1c" },
{ name: "Karat Daun", sci: "Common Rust", color: "#d97706" },
{ name: "Bercak Abu-abu", sci: "Gray Leaf Spot", color: "#6b7280" },
{ name: "Daun Sehat", sci: "Healthy", color: "#16a34a" },
];
const isLoadingData = summaryQuery.isLoading;
const hasError = Boolean(summaryQuery.error);
return (
<main className="min-h-screen px-6 py-8">
<main className="p-6">
<div className="mx-auto max-w-6xl space-y-8">
<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">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-primary">
<LayoutDashboard className="h-4 w-4" /> Dashboard
{/* Hero header */}
<header
className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
style={{ backgroundImage: `url(${bg})` }}
>
<div className="absolute inset-0 bg-linear-to-b from-[#2F6E1A]/60 to-black/30" />
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-10">
<div className="space-y-3 w-full md:w-2/3 text-white">
<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-4xl font-extrabold">Selamat Datang di</h1>
<h2 className="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>
<div className="mt-6 flex items-center gap-4">
<Button
asChild
variant="outline"
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-6 py-6 text-lg font-semibold text-white"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link>
</Button>
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
>
<Link
to="/library"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button>
</div>
</div>
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1>
<p className="text-muted-foreground">
{user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'}
</p>
</div>
<div className="flex gap-3">
{user?.role === 'expert' && (
<Button asChild variant="outline">
<Link to="/expert/reviews">
<CheckCircle2 className="h-4 w-4 mr-2" />
Review Pakar
</Link>
</Button>
)}
<Button variant="outline" onClick={toggleDashboardCompact}>
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}
</Button>
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="h-4 w-4 mr-2" />
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'}
</Button>
<div className="w-full md:w-1/3" />
</div>
</header>
{/* Loading / Error states */}
{isLoadingData && (
<Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard...
@@ -121,149 +119,212 @@ export function DashboardPage() {
{hasError && (
<Card className="p-8 text-center text-red-600">
Gagal memuat data dashboard
<div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card>
)}
{/* Main dashboard content */}
{!isLoadingData && !hasError && (
<>
{summary && (
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}>
<section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Proyek Urgensi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Data ringkasan terbaru dari proyek Anda untuk memantau
perkembangan dan hasil deteksi penyakit daun jagung
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.diseaseCount}</div>
</CardContent>
</Card>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.diseaseCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.imageClassificationCount}</div>
</CardContent>
</Card>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount}
</div>
<Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Tinggi
</CardTitle>
</CardHeader>
<CardContent>
<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="/diagnoses?risk=high" className="text-red-600 ml-auto hover:underline">
Lihat daftar
</Link>
</CardContent>
</Card>
</section>
)}
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Katalog Penyakit
</CardTitle>
<CardDescription>
Pelajari tentang {diseases.length} penyakit daun jagung
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link to="/catalog">Buka Katalog</Link>
</Button>
</CardContent>
</Card>
{/* Mission section */}
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<div className="space-y-1">
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Misi Platform
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
Fitur inti yang kami sediakan untuk mendukung petani jagung
Indonesia
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Distribusi Risiko
</CardTitle>
<CardDescription>
Penyakit berdasarkan tingkat risiko
</CardDescription>
</CardHeader>
<CardContent>
{summary && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Tinggi</span>
<span className="font-semibold">{summary.riskDistribution.high}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Sedang</span>
<span className="font-semibold">{summary.riskDistribution.medium}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Rendah</span>
<span className="font-semibold">{summary.riskDistribution.low}</span>
</div>
</div>
)}
</CardContent>
</Card>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
{missionCards.map((card) => {
const Icon = card.icon;
return (
<Card
key={card.title}
className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full"
>
<CardContent className="space-y-5 p-6 h-full flex flex-col justify-between">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]">
<Icon className={`h-7 w-7 ${card.accent}`} />
</div>
<div className="space-y-2">
<h4 className="text-lg font-bold text-[#214B11]">
{card.title}
</h4>
<p className="text-sm leading-6 text-slate-500">
{card.description}
</p>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
<ImageClassificationForm
onSubmit={async (file) => {
await createDiagnosisMutation.mutateAsync(file);
}}
isSubmitting={createDiagnosisMutation.isPending}
latestResult={diagnoses[0] ?? null}
/>
{/* Diseases quick access */}
<section className="space-y-4">
<div className="mb-2 flex items-start justify-between">
<div>
<h3 className="text-[24px] font-extrabold text-[#214B11]">
Penyakit yang Dapat Dideteksi
</h3>
<p className="text-[15px] font-normal text-muted-foreground">
4 kelas penyakit dan kondisi daun jagung dalam sistem kami
</p>
</div>
<Link
to="/library"
className="text-emerald-600 font-semibold inline-flex items-center gap-1"
>
Lihat Pustaka <ChevronRight className="w-4 h-4" />
</Link>
</div>
<ManualClassificationForm
diseases={diseases}
onSubmit={async (payload) => {
await createClassificationMutation.mutateAsync(payload);
}}
isSubmitting={createClassificationMutation.isPending}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{diseasesQuick.map((d) => (
<Card
key={d.name}
className="rounded-2xl bg-white p-4 shadow-sm h-full"
>
<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"
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>
))}
</div>
</section>
{diagnoses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Riwayat Diagnosis
</CardTitle>
<CardDescription>
{diagnoses.length} diagnosis yang telah dibuat
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{diagnoses.slice(0, 6).map((diagnosis) => (
<DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} />
))}
</div>
</CardContent>
</Card>
)}
{/* Scan quick access */}
<div className="mt-15 flex items-center gap-57 bg-[#1E8A2A] rounded-3xl p-6">
<div className="text-2xl font-bold text-white">
<h3>Siap Mendeteksi Penyakit Daun?</h3>
<div>
<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>
</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]"
>
<Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Mulai Scan Sekarang
</Link>
</Button>
<Button
asChild
variant="outline"
className="px-6 py-6 text-lg font-bold text-white hover:bg-[#1E8A2A]"
></Button>
</div>
</>
)}
</div>
+146
View File
@@ -0,0 +1,146 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
import { RiskBadge } from "@/components/risk-badge";
import { apiClient } from "@/lib/api-client";
export function DiagnosesPage() {
const [statusFilter, setStatusFilter] = useState<
"all" | "needs_review" | "verified" | "failed"
>("all");
const [riskFilter, setRiskFilter] = useState<
"all" | "high" | "medium" | "low"
>("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]);
return (
<main className="min-h-screen px-6 py-8">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">
Manajemen Diagnosis
</p>
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
</div>
<div className="flex items-center gap-3">
<Link to="/dashboard">
<Button variant="outline">Dashboard</Button>
</Link>
</div>
</div>
<Card>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="rounded-md border border-border bg-background px-2 py-1"
>
<option value="all">All</option>
<option value="needs_review">Needs review</option>
<option value="verified">Verified</option>
<option value="failed">Failed</option>
</select>
<label className="text-sm font-medium">Risiko</label>
<select
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as any)}
className="rounded-md border border-border bg-background px-2 py-1"
>
<option value="all">All</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<div className="ml-auto text-sm text-muted-foreground">
Total: {filtered.length}
</div>
</div>
{query.isLoading ? (
<div className="p-6 text-center text-muted-foreground">
Memuat diagnosis...
</div>
) : query.isError ? (
<div className="p-6 text-center text-red-600">
Gagal memuat diagnosis
</div>
) : filtered.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
Tidak ada diagnosis sesuai filter
</div>
) : (
<div className="grid gap-4 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-24 w-24 rounded-md object-cover"
/>
<div className="flex-1">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-lg font-semibold">
{d.disease?.commonName ?? "Unknown"}
</h3>
<p className="text-sm text-muted-foreground">
{d.predictedDiseaseSlug ?? ""}
</p>
</div>
<div className="flex flex-col items-end gap-1">
<DiagnosisStatusBadge status={d.status} />
<RiskBadge level={d.disease?.riskLevel ?? "low"} />
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{new Date(d.createdAt).toLocaleString("id-ID")}
</div>
<Link
to={`/diagnoses/${d.id}`}
className="text-emerald-600 font-semibold"
>
Lihat detail
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
</main>
);
}
export default DiagnosesPage;
+32 -19
View File
@@ -1,23 +1,31 @@
import { ArrowRight, Leaf, ShieldCheck, Sprout } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowRight, Leaf, ShieldCheck, Sprout } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const features = [
{
icon: Leaf,
title: 'Katalog Penyakit Lengkap',
description: 'Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.',
title: "Katalog Penyakit Lengkap",
description:
"Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.",
},
{
icon: ShieldCheck,
title: 'Pantau Risiko Penyakit',
description: 'Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.',
title: "Pantau Risiko Penyakit",
description:
"Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.",
},
{
icon: Sprout,
title: 'Laporkan Pengamatan',
description: 'Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.',
title: "Laporkan Pengamatan",
description:
"Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.",
},
];
@@ -44,23 +52,26 @@ export function LandingPage() {
</div>
<div className="space-y-5">
<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>
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara penanganannya,
serta laporkan pengamatan Anda untuk membantu penelitian dan edukasi.
Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara
penanganannya, serta laporkan pengamatan Anda untuk membantu
penelitian dan edukasi.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button asChild size="lg">
<Link to="/dashboard">
Buka Dashboard <ArrowRight className="ml-2 h-4 w-4" />
<Link to="/scan">
Mulai Scan Sekarang <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link to="/catalog">
Lihat Katalog Penyakit
</Link>
<Link to="/library">Pustaka Penyakit</Link>
</Button>
<Button asChild size="lg" variant="ghost">
<Link to="/dashboard">Dashboard</Link>
</Button>
</div>
</div>
@@ -74,7 +85,9 @@ export function LandingPage() {
</div>
<div>
<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>
</CardHeader>
</Card>
+182
View File
@@ -0,0 +1,182 @@
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { BookOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { mockDiseases } from "@/data/mock-diseases";
export function LibraryPage() {
type Disease = (typeof mockDiseases)[number];
const [filter, setFilter] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selected, setSelected] = useState<Disease | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const items = useMemo(() => {
if (!filter) return mockDiseases;
return mockDiseases.filter((d) =>
d.name.toLowerCase().includes(filter.toLowerCase()),
);
}, [filter]);
return (
<main className="p-6">
<h1 className="text-2xl font-semibold mb-4">Pustaka Penyakit</h1>
<div className="mb-4">
<input
placeholder="Filter penyakit..."
value={filter ?? ""}
onChange={(e) => setFilter(e.target.value || null)}
className="border px-3 py-2 rounded-md w-full max-w-sm"
/>
</div>
<div className="grid grid-cols-1 gap-4">
{items.map((d) => (
<article key={d.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex gap-4">
<img
src={d.imageUrl}
alt={d.name}
className="h-28 w-48 rounded-md object-cover"
/>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h2 className="font-semibold text-lg">
{d.name}{" "}
<span className="text-sm text-muted-foreground">
{d.severity}
</span>
</h2>
<p className="text-sm text-muted-foreground mt-2">
{d.description}
</p>
{d.pathogen && (
<div className="mt-2 text-sm">
<strong>Patogen:</strong> {d.pathogen}
</div>
)}
<div className="mt-3 flex items-center gap-3">
<Button
variant="default"
onClick={() => {
setSelected(d);
setModalOpen(true);
}}
className="inline-flex items-center gap-2 bg-green-600 text-white px-3 py-1 rounded"
>
<BookOpen className="h-4 w-4" />
<span className="text-sm">Baca lebih lanjut</span>
</Button>
<Link
to={`/catalog/${d.slug}`}
className="text-sm text-muted-foreground"
>
Lihat halaman katalog
</Link>
</div>
</div>
<div>
<button
className="text-sm text-primary underline"
onClick={() =>
setExpandedId(expandedId === d.id ? null : d.id)
}
>
{expandedId === d.id ? "Tutup" : "Detail"}
</button>
</div>
</div>
{expandedId === d.id && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<h3 className="font-medium">Gejala</h3>
<ul className="list-disc list-inside text-sm mt-2">
{(d.symptoms || []).length > 0 ? (
d.symptoms.map((s: string, i: number) => (
<li key={`${d.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
</div>
<div>
<h3 className="font-medium">Pencegahan</h3>
<p className="text-sm mt-2">{d.prevention}</p>
</div>
</div>
)}
</div>
</div>
</article>
))}
</div>
<Modal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setSelected(null);
}}
title={selected?.name}
footer={
selected && (
<div className="flex items-center justify-end gap-3">
<Link
to={`/catalog/${selected.slug}`}
className="px-3 py-2 rounded bg-green-600 text-white text-sm"
>
Buka halaman katalog
</Link>
<button
onClick={() => {
setModalOpen(false);
setSelected(null);
}}
className="px-3 py-2 rounded bg-gray-200 text-sm"
>
Tutup
</button>
</div>
)
}
>
{selected ? (
<div className="grid grid-cols-1 gap-4">
<img
src={selected.imageUrl}
alt={selected.name}
className="w-full rounded-md object-cover"
/>
<div>
<p className="text-sm text-muted-foreground">
{selected.description}
</p>
{selected.pathogen && (
<p className="mt-2">
<strong>Patogen:</strong> {selected.pathogen}
</p>
)}
<h4 className="mt-3 font-medium">Gejala</h4>
<ul className="list-disc list-inside text-sm mt-2">
{(selected.symptoms || []).length > 0 ? (
selected.symptoms.map((s: string, i: number) => (
<li key={`${selected.id}-symptom-${i}`}>{s}</li>
))
) : (
<li>Tidak ada gejala khusus</li>
)}
</ul>
<h4 className="mt-3 font-medium">Pencegahan</h4>
<p className="text-sm mt-2">{selected.prevention}</p>
</div>
</div>
) : null}
</Modal>
</main>
);
}
+24 -12
View File
@@ -1,25 +1,32 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthForm } from '@/components/auth-form';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store";
export function LoginPage() {
const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
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({
mutationFn: apiClient.login,
onSuccess: (response) => {
setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response);
navigate('/dashboard');
queryClient.setQueryData(["auth", "me"], response);
navigate("/dashboard");
},
onError: (err) => setError(err instanceof Error ? err.message : 'Login gagal'),
onError: (err) =>
setError(err instanceof Error ? err.message : "Login gagal"),
});
return (
@@ -29,7 +36,9 @@ export function LoginPage() {
mode="login"
isSubmitting={mutation.isPending}
error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ email, password }) => {
setError(null);
return mutation.mutateAsync({ email, password });
@@ -37,7 +46,10 @@ export function LoginPage() {
onFieldChange={() => setError(null)}
/>
<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>
</div>
</main>
+25 -13
View File
@@ -1,25 +1,32 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthForm } from '@/components/auth-form';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store";
export function RegisterPage() {
const navigate = useNavigate();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser);
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({
mutationFn: apiClient.register,
onSuccess: (response) => {
setUser(response.user);
queryClient.setQueryData(['auth', 'me'], response);
navigate('/dashboard');
queryClient.setQueryData(["auth", "me"], response);
navigate("/dashboard");
},
onError: (err) => setError(err instanceof Error ? err.message : 'Registrasi gagal'),
onError: (err) =>
setError(err instanceof Error ? err.message : "Registrasi gagal"),
});
return (
@@ -29,15 +36,20 @@ export function RegisterPage() {
mode="register"
isSubmitting={mutation.isPending}
error={error}
googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
googleOAuthEnabled={Boolean(
meQuery.data?.features.googleOAuthEnabled,
)}
onSubmit={async ({ name, email, password }) => {
setError(null);
return mutation.mutateAsync({ name: name ?? '', email, password });
return mutation.mutateAsync({ name: name ?? "", email, password });
}}
onFieldChange={() => setError(null)}
/>
<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>
</div>
</main>
+196
View File
@@ -0,0 +1,196 @@
import React, { useRef, useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { Modal } from "@/components/ui/modal";
import type { DiagnosisRecord } from "@zeavis/shared";
export function ScanPage() {
const [fileName, setFileName] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (file: File) => apiClient.createDiagnosis(file),
onSuccess: (diagnosis) => {
queryClient.invalidateQueries({ queryKey: ["diagnoses"] });
// show preview modal instead of immediate navigation
setDiagnosisPreview(diagnosis);
setPreviewOpen(true);
},
});
const handleFile = (f?: File) => {
if (!f) return;
setFileName(f.name);
mutation.mutate(f);
};
const [previewOpen, setPreviewOpen] = useState(false);
const [diagnosisPreview, setDiagnosisPreview] =
useState<DiagnosisRecord | null>(null);
const diagnosesQuery = useQuery({
queryKey: ["diagnoses"],
queryFn: () => apiClient.getDiagnoses(),
enabled: previewOpen,
});
return (
<main className="p-6">
<h1 className="text-2xl font-semibold mb-4">Scan Tanaman</h1>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2 bg-white p-6 rounded-lg shadow">
<button
type="button"
className="w-full border-2 border-dashed border-green-300 rounded-md p-8 text-center cursor-pointer"
onClick={() => inputRef.current?.click()}
>
<div className="text-green-600">Area Unggah Gambar</div>
<div className="mt-4 text-sm text-muted-foreground">
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
</div>
{fileName && (
<div className="mt-3 text-sm">Dipilih: {fileName}</div>
)}
</button>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => handleFile(e.target.files?.[0])}
/>
<div className="mt-6 flex items-center gap-3">
<button
className="bg-green-600 text-white px-4 py-2 rounded-md"
onClick={() => inputRef.current?.click()}
>
Pilih Berkas
</button>
{mutation.isPending && (
<div className="text-sm text-muted-foreground">Mengunggah...</div>
)}
{mutation.isError && (
<div className="text-sm text-red-600">
{mutation.error instanceof Error
? mutation.error.message
: String(mutation.error) || "Upload gagal"}
</div>
)}
</div>
</div>
<aside className="bg-white p-6 rounded-lg shadow">
<h2 className="font-medium mb-2">Panduan Pengambilan Foto</h2>
<ul className="text-sm space-y-2 text-muted-foreground">
<li>Jarak 1530 cm dari daun</li>
<li>Pencahayaan cukup, hindari blur</li>
<li>Daun memenuhi bingkai</li>
</ul>
</aside>
</div>
<Modal
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
title={diagnosisPreview?.predictedDiseaseSlug ?? "Hasil Diagnosis"}
size="sm"
footer={
diagnosisPreview && (
<div className="flex items-center justify-end gap-3">
<button
className="px-3 py-2 rounded bg-green-600 text-white text-sm"
onClick={() => navigate(`/diagnoses/${diagnosisPreview.id}`)}
>
Lihat detail
</button>
<button
className="px-3 py-2 rounded bg-gray-200 text-sm"
onClick={() => {
setPreviewOpen(false);
setDiagnosisPreview(null);
}}
>
Tutup
</button>
</div>
)
}
>
{diagnosisPreview ? (
<div className="grid grid-cols-1 gap-4">
{diagnosisPreview.imageUrl && (
<img
src={diagnosisPreview.imageUrl}
alt="hasil"
className="w-full rounded-md object-cover"
/>
)}
<div>
<p className="text-sm text-muted-foreground">
Prediksi:{" "}
<strong>{diagnosisPreview.predictedDiseaseSlug}</strong>
</p>
<p className="text-sm text-muted-foreground">
Confidence:{" "}
<strong>
{Math.round((diagnosisPreview.confidence ?? 0) * 100)}%
</strong>
</p>
</div>
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2">
Daftar Diagnosis Terbaru
</h4>
{diagnosesQuery.isLoading && (
<div className="text-sm text-muted-foreground">
Memuat daftar...
</div>
)}
{diagnosesQuery.isError && (
<div className="text-sm text-red-600">
Gagal memuat daftar diagnosis
</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"
/>
<div className="text-sm">
<div className="font-medium">
{d.disease?.commonName ?? d.predictedDiseaseSlug}
</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>
</main>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import path from 'node:path';
import { defineConfig, loadEnv } from 'vite';
@@ -7,7 +8,7 @@ export default defineConfig(({ mode }) => {
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
return {
plugins: [react()],
plugins: [react(), tsconfigPaths()],
server: {
proxy: {
'/api': apiProxyTarget,
+7 -1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"devDependencies": {
@@ -50,6 +49,7 @@
"postcss": "^8.5.15",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vite-tsconfig-paths": "6.1.1",
},
},
"packages/shared": {
@@ -297,6 +297,8 @@
"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=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -381,6 +383,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=="],
"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=="],
"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 +397,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-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=="],
"@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=="],