feat: update landing page and add new library and scan pages

- Refactor landing page content and links for better user experience.
- Introduce a new library page to display disease information with filtering and modal details.
- Add a scan page for users to upload images for diagnosis with a preview modal.
- Enhance login and registration pages with automatic navigation to the dashboard.
- Integrate vite-tsconfig-paths for improved path resolution in Vite configuration.
This commit is contained in:
seriouselly
2026-06-02 02:42:07 +07:00
parent 73772f5f56
commit 0745dd903d
20 changed files with 1029 additions and 287 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"
} }
} }
+56 -31
View File
@@ -1,47 +1,72 @@
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 { DashboardPage } from "@/pages/dashboard-page";
import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page'; // import { LandingPage } from "@/pages/landing-page";
import { ExpertReviewsPage } from '@/pages/expert-reviews-page'; import { ScanPage } from "@/pages/scan-page";
import { LoginPage } from '@/pages/login-page'; import { LibraryPage } from "@/pages/library-page";
import { RegisterPage } from '@/pages/register-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 { MainLayout } from "@/components/layout/main-layout";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: '/', element: <LandingPage /> }, // {
{ path: '/login', element: <LoginPage /> }, // path: "/",
{ path: '/register', element: <RegisterPage /> }, // element: (
// <MainLayout>
// <LandingPage />
// </MainLayout>
// ),
// },
{ {
path: '/dashboard', path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
path: "/scan",
element: ( element: (
<AuthGuard> <MainLayout>
<ScanPage />
</MainLayout>
),
},
{
path: "/library",
element: (
<MainLayout>
<LibraryPage />
</MainLayout>
),
},
// { path: "/login", element: <LoginPage /> },
// { path: "/register", element: <RegisterPage /> },
{
path: "/dashboard",
element: (
<MainLayout>
<DashboardPage /> <DashboardPage />
</AuthGuard> </MainLayout>
), ),
}, },
{ {
path: '/diagnoses/:id', path: "/diagnoses/:id",
element: ( element: <DiagnosisDetailPage />,
<AuthGuard>
<DiagnosisDetailPage />
</AuthGuard>
),
}, },
{ {
path: '/expert/reviews', path: "/expert/reviews",
element: ( element: <ExpertReviewsPage />,
<AuthGuard requireExpert>
<ExpertReviewsPage />
</AuthGuard>
),
}, },
{ path: '/catalog', element: <CatalogPage /> }, { path: "/catalog", element: <CatalogPage /> },
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> }, { path: "/catalog/:slug", element: <DiseaseDetailPage /> },
]); ]);
export function App() { 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 { 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]} 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";
+229 -180
View File
@@ -1,118 +1,116 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from 'react-router-dom'; import { Link } from "react-router-dom";
import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react'; import { BookOpen, ChevronRight, Pill, Shield, Scan } 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, CardHeader, CardTitle } from "@/components/ui/card";
import { ManualClassificationForm } from '@/components/manual-classification-form'; import { useUiStore } from "@/store/ui-store";
import { ImageClassificationForm } from '@/components/image-classification-form'; import { apiClient } from "@/lib/api-client";
import { DiagnosisCard } from '@/components/diagnosis-card'; import bg from "@/assets/images/dashboard-bg.png";
import { useAuthStore } from '@/store/auth-store';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
export function DashboardPage() { export function DashboardPage() {
const { user } = useAuthStore(); const { dashboardCompact } = useUiStore();
const setUser = useAuthStore((state) => state.setUser); const summaryQuery = useQuery({
const { dashboardCompact, toggleDashboardCompact } = useUiStore(); queryKey: ["dashboard-summary"],
const queryClient = useQueryClient();
const navigate = useNavigate();
const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
queryFn: () => apiClient.getDiseases(),
},
{
queryKey: ['dashboard-summary'],
queryFn: () => apiClient.getDashboardSummary(), queryFn: () => apiClient.getDashboardSummary(),
},
{
queryKey: ['diagnoses'],
queryFn: () => apiClient.getDiagnoses(),
},
{
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
],
}); });
const createDiagnosisMutation = useMutation({
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 summary = summaryQuery.data;
const diagnoses = diagnosesQuery.data || [];
const classifications = classificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading; const missionCards = [
const hasError = diseasesQuery.error || summaryQuery.error; {
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 ( return (
<main className="min-h-screen px-6 py-8"> <main className="p-6">
<div className="mx-auto max-w-6xl space-y-8"> <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"> {/* Hero header */}
<div className="space-y-2"> <header
<div className="flex items-center gap-2 text-sm font-medium text-primary"> className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
<LayoutDashboard className="h-4 w-4" /> Dashboard style={{ backgroundImage: `url(${bg})` }}
</div> >
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1> <div className="absolute inset-0 bg-linear-to-b from-[#2F6E1A]/60 to-black/30" />
<p className="text-muted-foreground"> <div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-10">
{user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'} <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> </p>
</div> <div className="mt-6 flex items-center 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-6 py-6 text-lg font-semibold text-white"
<CheckCircle2 className="h-4 w-4 mr-2" /> >
Review Pakar <Link to="/scan" className="inline-flex items-center gap-2">
<Scan className="h-5 w-6" />
Scan Daun Jagung
</Link> </Link>
</Button> </Button>
)}
<Button variant="outline" onClick={toggleDashboardCompact}>
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}
</Button>
<Button <Button
asChild
variant="outline" variant="outline"
onClick={() => logoutMutation.mutate()} className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
disabled={logoutMutation.isPending}
> >
<LogOut className="h-4 w-4 mr-2" /> <Link
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'} to="/library"
className="inline-flex items-center gap-2"
>
Pustaka Penyakit
<ChevronRight className="h-5 w-6" />
</Link>
</Button> </Button>
</div> </div>
</div>
<div className="w-full md:w-1/3" />
</div>
</header> </header>
{/* Loading / Error states */}
{isLoadingData && ( {isLoadingData && (
<Card className="p-8 text-center text-muted-foreground"> <Card className="p-8 text-center text-muted-foreground">
Memuat data dashboard... Memuat data dashboard...
@@ -121,43 +119,66 @@ export function DashboardPage() {
{hasError && ( {hasError && (
<Card className="p-8 text-center text-red-600"> <Card className="p-8 text-center text-red-600">
Gagal memuat data dashboard <div>Gagal memuat data dashboard</div>
<div className="mt-2 text-sm text-red-500">
{String(summaryQuery.error?.message)}
</div>
</Card> </Card>
)} )}
{/* Main dashboard content */}
{!isLoadingData && !hasError && ( {!isLoadingData && !hasError && (
<> <>
{summary && ( {summary && (
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}> <section
className={
dashboardCompact
? "grid gap-4 md:grid-cols-4 items-stretch"
: "grid gap-6 md:grid-cols-4 items-stretch"
}
>
<div className="md:col-span-4 text-xl font-bold">
<h3 className="text-[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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Total Penyakit Total Penyakit
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">{summary.diseaseCount}</div> <div className="text-3xl font-bold">
{summary.diseaseCount}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Total Diagnosis Total Diagnosis
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold">{summary.imageClassificationCount}</div> <div className="text-3xl font-bold">
{summary.imageClassificationCount}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Menunggu Review Menunggu Review
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-amber-600"> <div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount} {summary.needsReviewCount}
</div> </div>
@@ -165,12 +186,12 @@ export function DashboardPage() {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Tinggi Risiko Tinggi
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="h-full flex flex-col justify-start pt-2">
<div className="text-3xl font-bold text-red-600"> <div className="text-3xl font-bold text-red-600">
{summary.riskDistribution.high} {summary.riskDistribution.high}
</div> </div>
@@ -179,91 +200,119 @@ export function DashboardPage() {
</section> </section>
)} )}
<section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}> {/* Mission section */}
<Card> <section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
<CardHeader> <div className="space-y-1">
<CardTitle className="flex items-center gap-2"> <h3 className="text-[24px] font-extrabold text-[#214B11]">
<BookOpen className="h-5 w-5" /> Misi Platform
Katalog Penyakit </h3>
</CardTitle> <p className="text-[15px] font-normal text-muted-foreground">
<CardDescription> Fitur inti yang kami sediakan untuk mendukung petani jagung
Pelajari tentang {diseases.length} penyakit daun jagung Indonesia
</CardDescription> </p>
</CardHeader> </div>
<CardContent>
<Button asChild className="w-full">
<Link to="/catalog">Buka Katalog</Link>
</Button>
</CardContent>
</Card>
<Card> <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<CardHeader> {missionCards.map((card) => {
<CardTitle className="flex items-center gap-2"> const Icon = card.icon;
<TrendingUp className="h-5 w-5" />
Distribusi Risiko return (
</CardTitle> <Card
<CardDescription> key={card.title}
Penyakit berdasarkan tingkat risiko className="rounded-3xl border-white/70 bg-white/95 shadow-[0_8px_24px_rgba(16,24,40,0.08)] h-full"
</CardDescription> >
</CardHeader> <CardContent className="space-y-5 p-6 h-full flex flex-col justify-between">
<CardContent> <div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#EFF6E8]">
{summary && ( <Icon className={`h-7 w-7 ${card.accent}`} />
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <h4 className="text-lg font-bold text-[#214B11]">
<span className="text-muted-foreground">Risiko Tinggi</span> {card.title}
<span className="font-semibold">{summary.riskDistribution.high}</span> </h4>
<p className="text-sm leading-6 text-slate-500">
{card.description}
</p>
</div> </div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Sedang</span>
<span className="font-semibold">{summary.riskDistribution.medium}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Risiko Rendah</span>
<span className="font-semibold">{summary.riskDistribution.low}</span>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
);
})}
</div>
</section> </section>
<ImageClassificationForm {/* Diseases quick access */}
onSubmit={async (file) => { <section className="space-y-4">
await createDiagnosisMutation.mutateAsync(file); <div className="mb-2 flex items-start justify-between">
}} <div>
isSubmitting={createDiagnosisMutation.isPending} <h3 className="text-[24px] font-extrabold text-[#214B11]">
latestResult={diagnoses[0] ?? null} 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 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
diseases={diseases} {diseasesQuick.map((d) => (
onSubmit={async (payload) => { <Card
await createClassificationMutation.mutateAsync(payload); key={d.name}
}} className="rounded-2xl bg-white p-4 shadow-sm h-full"
isSubmitting={createClassificationMutation.isPending} >
<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>
{diagnoses.length > 0 && ( <div className="text-sm font-bold text-[#214B11]">
<Card> {d.name}
<CardHeader> </div>
<CardTitle className="flex items-center gap-2"> <div className="text-xs text-slate-400 italic mt-1">
<History className="h-5 w-5" /> {d.sci}
Riwayat Diagnosis </div>
</CardTitle> </div>
<CardDescription>
{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> </div>
</CardContent> </CardContent>
</Card> </Card>
)} ))}
</div>
</section>
{/* 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> </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>
+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 { useEffect, 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();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
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 (
@@ -29,7 +36,9 @@ export function LoginPage() {
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 +46,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>
+25 -13
View File
@@ -1,25 +1,32 @@
import { useState } from 'react'; import { useEffect, 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();
useEffect(() => {
navigate("/dashboard");
}, [navigate]);
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 (
@@ -29,15 +36,20 @@ export function RegisterPage() {
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>
+143
View File
@@ -0,0 +1,143 @@
import React, { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } 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 as DiagnosisRecord);
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);
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>
) : null}
</Modal>
</main>
);
}
+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 -1
View File
@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"devDependencies": { "devDependencies": {
@@ -50,6 +49,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 +297,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 +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=="], "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 +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": ["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=="],