Merge pull request #20 from ATLAS-PJK-GM007/selly/frontend
Selly/frontend
This commit is contained in:
+24
-36
@@ -5,7 +5,7 @@ import {
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { AuthInitializer } from "@/components/auth-initializer";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
// import { AuthGuard } from "@/components/auth-guard";
|
||||
import { DashboardPage } from "@/pages/dashboard-page";
|
||||
import { ScanPage } from "@/pages/scan-page";
|
||||
import { LibraryPage } from "@/pages/library-page";
|
||||
@@ -14,74 +14,62 @@ import { DiseaseDetailPage } from "@/pages/disease-detail-page";
|
||||
import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
|
||||
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
|
||||
import { DiagnosesPage } from "@/pages/diagnoses-page";
|
||||
import { LoginPage } from "@/pages/login-page";
|
||||
import { RegisterPage } from "@/pages/register-page";
|
||||
// import { LoginPage } from "@/pages/login-page";
|
||||
// import { RegisterPage } from "@/pages/register-page";
|
||||
import { MainLayout } from "@/components/layout/main-layout";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||
// { path: "/login", element: <LoginPage /> },
|
||||
// { path: "/register", element: <RegisterPage /> },
|
||||
{
|
||||
path: "/dashboard",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/scan",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses/:id",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/expert/reviews",
|
||||
element: (
|
||||
<AuthGuard requireExpert>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
@@ -8,7 +8,7 @@ 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">
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 sm:px-6 py-6 sm:py-8">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { X, Leaf } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/dashboard", label: "Dashboard" },
|
||||
{ path: "/scan", label: "Scan Tanaman" },
|
||||
{ path: "/diagnoses", label: "Diagnosa" },
|
||||
{ path: "/catalog", label: "Pustaka", altPath: "/library" },
|
||||
];
|
||||
|
||||
export function MobileNav({ open, onClose }: Props) {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const navContent = (
|
||||
<div
|
||||
// 2. Ubah z-50 menjadi z-[999] agar levelnya mentok paling atas
|
||||
className={`fixed inset-0 z-[999] lg:hidden transition-opacity duration-300 ease-in-out ${
|
||||
open ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed inset-y-0 right-0 z-[999] flex w-full max-w-sm flex-col bg-[#306D29] shadow-2xl transition-transform duration-300 ease-in-out ${
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 h-20 border-b border-white/10 shrink-0">
|
||||
<div className="flex items-center gap-3 font-semibold text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#48A111]">
|
||||
<Link to="/dashboard" onClick={onClose}>
|
||||
<Leaf className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-lg font-bold">ZeaVis Edu</div>
|
||||
<div className="text-[13px] font-normal text-[#9AD872]">
|
||||
Smart AI for Corn Disease Detection
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Tutup menu"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex flex-col gap-2 overflow-y-auto px-4 py-6 flex-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active =
|
||||
isActive(item.path) || (item.altPath && isActive(item.altPath));
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={`rounded-full px-4 py-3 text-[16px] font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[#48A111] text-white shadow-sm"
|
||||
: "text-white/85 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== "undefined"
|
||||
? createPortal(navContent, document.body)
|
||||
: navContent;
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
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";
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Leaf, Menu } from "lucide-react";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
export function Navbar() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
//
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
@@ -22,17 +18,6 @@ export function Navbar() {
|
||||
: "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("/login");
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
@@ -50,38 +35,50 @@ export function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link to="/dashboard" className={navLinkClassName(isActive("/dashboard"))}>
|
||||
<nav className="hidden lg:flex items-center gap-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className={navLinkClassName(isActive("/dashboard"))}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
|
||||
Scan
|
||||
Scan Tanaman
|
||||
</Link>
|
||||
<Link to="/diagnoses" className={navLinkClassName(isActive("/diagnoses"))}>
|
||||
<Link
|
||||
to="/diagnoses"
|
||||
className={navLinkClassName(isActive("/diagnoses"))}
|
||||
>
|
||||
Diagnosa
|
||||
</Link>
|
||||
<Link to="/catalog" className={navLinkClassName(isActive("/catalog") || isActive("/library"))}>
|
||||
<Link
|
||||
to="/catalog"
|
||||
className={navLinkClassName(
|
||||
isActive("/catalog") || isActive("/library"),
|
||||
)}
|
||||
>
|
||||
Pustaka
|
||||
</Link>
|
||||
{user?.role === "expert" && (
|
||||
<Link
|
||||
to="/expert/reviews"
|
||||
className={navLinkClassName(isActive("/expert/reviews"))}
|
||||
>
|
||||
Review
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="ml-2 bg-white/10 border-white/20 text-white hover:bg-white/20"
|
||||
>
|
||||
{logoutMutation.isPending ? "Keluar..." : "Keluar"}
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
to="/expert/reviews"
|
||||
className={navLinkClassName(isActive("/expert/reviews"))}
|
||||
>
|
||||
Review
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="lg:hidden rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Buka menu navigasi"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<MobileNav
|
||||
open={mobileMenuOpen}
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ export function Modal({
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
ref={containerRef}
|
||||
className={`relative z-10 w-full ${sizeClass[size]} rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
|
||||
className={`relative z-10 w-full ${sizeClass[size]} mx-4 max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-lg ${className ?? ""}`}
|
||||
>
|
||||
<ModalHeader right={headerRight}>
|
||||
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
|
||||
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
source?: 'uploader' | 'model-service' | 'unknown';
|
||||
}
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${apiBaseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
@@ -25,14 +30,21 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
let source: 'uploader' | 'model-service' | 'unknown' | undefined;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
if (errorData.source) {
|
||||
source = errorData.source;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
const error = new Error(errorMessage) as ApiError;
|
||||
error.status = response.status;
|
||||
error.source = source;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { RiskLevel } from '@zeavis/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { RiskBadge } from '@/components/risk-badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -43,10 +42,12 @@ export function CatalogPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Katalog Penyakit</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Referensi lengkap penyakit dan kondisi daun jagung yang dapat dideteksi oleh sistem AI ZeaVis Edu
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,13 +77,13 @@ export function DashboardPage() {
|
||||
style={{ backgroundImage: `url(${bg})` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-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="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-6 md: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]">
|
||||
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
|
||||
ZeaVis Edu
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-white/90">
|
||||
@@ -91,11 +91,11 @@ export function DashboardPage() {
|
||||
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
|
||||
akurat.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-6 py-6 text-lg font-semibold text-white"
|
||||
className="bg-[#306D29] hover:bg-[#1E8A2A]/90 px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white"
|
||||
>
|
||||
<Link to="/scan" className="inline-flex items-center gap-2">
|
||||
<Scan className="h-5 w-6" />
|
||||
@@ -105,7 +105,7 @@ export function DashboardPage() {
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="px-6 py-6 text-lg font-semibold text-white hover:bg-[#1E8A2A]"
|
||||
className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
|
||||
>
|
||||
<Link
|
||||
to="/catalog"
|
||||
@@ -149,7 +149,7 @@ export function DashboardPage() {
|
||||
}
|
||||
>
|
||||
<div className="md:col-span-4 text-xl font-bold">
|
||||
<h3 className="text-[24px] font-extrabold text-[#214B11]">
|
||||
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
|
||||
Proyek Urgensi
|
||||
</h3>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
@@ -231,7 +231,7 @@ export function DashboardPage() {
|
||||
{/* 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]">
|
||||
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
|
||||
Misi Platform
|
||||
</h3>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
@@ -272,7 +272,7 @@ export function DashboardPage() {
|
||||
<section className="space-y-4">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-[24px] font-extrabold text-[#214B11]">
|
||||
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
|
||||
Penyakit yang Dapat Dideteksi
|
||||
</h3>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
@@ -322,7 +322,7 @@ export function DashboardPage() {
|
||||
</section>
|
||||
|
||||
{/* Scan quick access */}
|
||||
<div className="mt-12 flex items-center gap-6 bg-[#1E8A2A] rounded-3xl p-6">
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center gap-4 sm:gap-6 bg-[#1E8A2A] rounded-3xl p-5 sm:p-6">
|
||||
<div className="flex-1 text-white">
|
||||
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
|
||||
<p className="text-sm text-[#9AD872] font-normal mt-2">
|
||||
|
||||
@@ -75,10 +75,10 @@ export function DiagnosesPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
Manajemen Diagnosis
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Lihat hasil diagnosa dari scan yang telah dilakukan
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="outline">
|
||||
@@ -87,72 +87,70 @@ export function DiagnosesPage() {
|
||||
</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) => handleStatusChange(e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusChange(e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="text-sm font-medium">Risiko</label>
|
||||
<select
|
||||
value={riskFilter}
|
||||
onChange={(e) => handleRiskChange(e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{RISK_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-sm font-medium">Risiko</label>
|
||||
<select
|
||||
value={riskFilter}
|
||||
onChange={(e) => handleRiskChange(e.target.value)}
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{RISK_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
Total: {filtered.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto sm:ml-auto text-sm text-muted-foreground">
|
||||
Total: {filtered.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Memuat diagnosis...
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className="py-12 text-center text-red-600">
|
||||
Gagal memuat diagnosis
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Tidak ada diagnosis sesuai filter
|
||||
</p>
|
||||
{diagnoses.length === 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mulai dengan{" "}
|
||||
<Link to="/scan" className="text-primary underline">
|
||||
melakukan scan daun
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{filtered.map((d) => (
|
||||
<Card key={d.id} className="h-full">
|
||||
<CardContent className="flex gap-4 p-4 items-start">
|
||||
{query.isLoading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Memuat diagnosis...
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className="py-12 text-center text-red-600">
|
||||
Gagal memuat diagnosis
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Tidak ada diagnosis sesuai filter
|
||||
</p>
|
||||
{diagnoses.length === 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mulai dengan{" "}
|
||||
<Link to="/scan" className="text-primary underline">
|
||||
melakukan scan daun
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{filtered.map((d) => (
|
||||
<Card key={d.id} className="h-full">
|
||||
<CardContent className="flex gap-4 p-4 items-start">
|
||||
<img
|
||||
src={d.imageUrl}
|
||||
alt="Daun"
|
||||
className="h-24 w-24 rounded-md object-cover bg-muted"
|
||||
className="h-16 md:h-24 w-16 md:w-24 rounded-md object-cover bg-muted shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -192,8 +190,6 @@ export function DiagnosesPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,23 +14,22 @@ export function DiagnosisDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>;
|
||||
if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>;
|
||||
if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>;
|
||||
if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>;
|
||||
|
||||
const diagnosis = query.data;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Detail Diagnosis</p>
|
||||
<h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
|
||||
<h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
|
||||
</div>
|
||||
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
|
||||
@@ -99,6 +98,5 @@ export function DiagnosisDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function DiseaseDetailPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1>
|
||||
<h1 className="text-2xl md:text-4xl font-bold tracking-tight">{disease.commonName}</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
|
||||
</div>
|
||||
<RiskBadge level={disease.riskLevel} />
|
||||
@@ -131,7 +131,7 @@ export function DiseaseDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<Button asChild className="flex-1">
|
||||
<Link to="/catalog">Lihat Katalog Lengkap</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
|
||||
import type { DiseaseSlug } from '@zeavis/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DiseaseSlug } from "@zeavis/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
|
||||
export function ExpertReviewsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('diagnosis');
|
||||
const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified');
|
||||
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<DiseaseSlug | ''>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const selectedId = searchParams.get("diagnosis");
|
||||
const [verdict, setVerdict] = useState<"verified" | "corrected">("verified");
|
||||
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<
|
||||
DiseaseSlug | ""
|
||||
>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [reviewsQuery, diseasesQuery] = useQueries({
|
||||
queries: [
|
||||
{ queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() },
|
||||
{ queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() },
|
||||
{
|
||||
queryKey: ["expert-reviews"],
|
||||
queryFn: () => apiClient.getExpertReviews(),
|
||||
},
|
||||
{ queryKey: ["diseases"], queryFn: () => apiClient.getDiseases() },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -33,15 +44,18 @@ export function ExpertReviewsPage() {
|
||||
mutationFn: () =>
|
||||
apiClient.reviewDiagnosis(selected!.id, {
|
||||
verdict,
|
||||
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined,
|
||||
correctedDiseaseSlug:
|
||||
verdict === "corrected"
|
||||
? correctedDiseaseSlug || undefined
|
||||
: undefined,
|
||||
notes,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNotes('');
|
||||
setVerdict('verified');
|
||||
setCorrectedDiseaseSlug('');
|
||||
queryClient.invalidateQueries({ queryKey: ['expert-reviews'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
|
||||
setNotes("");
|
||||
setVerdict("verified");
|
||||
setCorrectedDiseaseSlug("");
|
||||
queryClient.invalidateQueries({ queryKey: ["expert-reviews"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,10 +69,13 @@ export function ExpertReviewsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Review Pakar</p>
|
||||
<h1 className="text-3xl font-bold">Diagnosis Menunggu Review</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
|
||||
feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,14 +84,20 @@ export function ExpertReviewsPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Daftar Diagnosis</h2>
|
||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span>
|
||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
|
||||
{reviews.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Memuat diagnosis...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Memuat diagnosis...
|
||||
</p>
|
||||
) : reviews.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Tidak ada diagnosis yang menunggu review</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tidak ada diagnosis yang menunggu review
|
||||
</p>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<button
|
||||
@@ -83,26 +106,35 @@ export function ExpertReviewsPage() {
|
||||
onClick={() => setSearchParams({ diagnosis: review.id })}
|
||||
className={`w-full text-left transition ${
|
||||
selected?.id === review.id
|
||||
? 'ring-2 ring-primary'
|
||||
: 'hover:border-primary'
|
||||
? "ring-2 ring-primary"
|
||||
: "hover:border-primary"
|
||||
}`}
|
||||
>
|
||||
<Card className="transition hover:border-primary">
|
||||
<CardContent className="flex gap-3 p-3">
|
||||
<CardContent className="flex gap-3 p-3 items-start">
|
||||
<img
|
||||
src={review.imageUrl}
|
||||
alt="Daun jagung"
|
||||
className="h-20 w-20 rounded-lg object-cover"
|
||||
className="h-14 md:h-20 w-14 md:w-20 rounded-lg object-cover shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
|
||||
<DiagnosisStatusBadge status={review.status} className="text-xs" />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{review.disease?.commonName ?? "Diagnosis gagal"}
|
||||
</h3>
|
||||
<DiagnosisStatusBadge
|
||||
status={review.status}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`}
|
||||
{review.confidence === null
|
||||
? "Tidak ada confidence"
|
||||
: `${(review.confidence * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleString("id-ID")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -125,16 +157,18 @@ export function ExpertReviewsPage() {
|
||||
{/* Detail card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
|
||||
<CardTitle>
|
||||
{selected.disease?.commonName ?? "Diagnosis gagal"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{new Date(selected.createdAt).toLocaleString('id-ID')}
|
||||
{new Date(selected.createdAt).toLocaleString("id-ID")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<img
|
||||
src={selected.imageUrl}
|
||||
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
|
||||
className="h-64 w-full rounded-lg object-cover"
|
||||
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
|
||||
className="h-48 md:h-64 w-full rounded-lg object-cover"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -145,7 +179,9 @@ export function ExpertReviewsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Confidence</span>
|
||||
<span className="text-sm">
|
||||
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
|
||||
{selected.confidence === null
|
||||
? "N/A"
|
||||
: `${(selected.confidence * 100).toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,9 +191,16 @@ export function ExpertReviewsPage() {
|
||||
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
|
||||
<div className="space-y-1">
|
||||
{selected.predictions.map((pred) => (
|
||||
<div key={pred.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{pred.modelLabel}</span>
|
||||
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span>
|
||||
<div
|
||||
key={pred.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{pred.modelLabel}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(pred.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,30 +218,42 @@ export function ExpertReviewsPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Keputusan</label>
|
||||
<div className="flex gap-3">
|
||||
<label htmlFor="verdict-verified" className="flex items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<label
|
||||
htmlFor="verdict-verified"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
id="verdict-verified"
|
||||
type="radio"
|
||||
name="verdict"
|
||||
value="verified"
|
||||
checked={verdict === 'verified'}
|
||||
checked={verdict === "verified"}
|
||||
onChange={(e) => {
|
||||
setVerdict(e.target.value as 'verified' | 'corrected');
|
||||
setCorrectedDiseaseSlug('');
|
||||
setVerdict(
|
||||
e.target.value as "verified" | "corrected",
|
||||
);
|
||||
setCorrectedDiseaseSlug("");
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">Terverifikasi</span>
|
||||
</label>
|
||||
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="verdict-corrected"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
id="verdict-corrected"
|
||||
type="radio"
|
||||
name="verdict"
|
||||
value="corrected"
|
||||
checked={verdict === 'corrected'}
|
||||
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
|
||||
checked={verdict === "corrected"}
|
||||
onChange={(e) =>
|
||||
setVerdict(
|
||||
e.target.value as "verified" | "corrected",
|
||||
)
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">Dikoreksi</span>
|
||||
@@ -206,16 +261,23 @@ export function ExpertReviewsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verdict === 'corrected' && (
|
||||
{verdict === "corrected" && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="disease" className="text-sm font-medium">
|
||||
<label
|
||||
htmlFor="disease"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Penyakit yang Benar
|
||||
</label>
|
||||
<select
|
||||
id="disease"
|
||||
value={correctedDiseaseSlug}
|
||||
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')}
|
||||
required={verdict === 'corrected'}
|
||||
onChange={(e) =>
|
||||
setCorrectedDiseaseSlug(
|
||||
e.target.value as DiseaseSlug | "",
|
||||
)
|
||||
}
|
||||
required={verdict === "corrected"}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Pilih penyakit...</option>
|
||||
@@ -245,15 +307,22 @@ export function ExpertReviewsPage() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
(verdict === "corrected" && !correctedDiseaseSlug) ||
|
||||
!notes.trim()
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{mutation.isPending ? 'Mengirim...' : 'Kirim Review'}
|
||||
{mutation.isPending ? "Mengirim..." : "Kirim Review"}
|
||||
</Button>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-sm text-red-500">
|
||||
Error: {mutation.error instanceof Error ? mutation.error.message : 'Terjadi kesalahan'}
|
||||
Error:{" "}
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Terjadi kesalahan"}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -26,10 +26,12 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
|
||||
<h1 className="text-3xl font-bold">Pustaka Penyakit</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Referensi lengkap penyakit daun jagung yang dapat dideteksi oleh sistem AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||
<AuthForm
|
||||
mode="login"
|
||||
isSubmitting={mutation.isPending}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function RegisterPage() {
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||
<AuthForm
|
||||
mode="register"
|
||||
isSubmitting={mutation.isPending}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { Camera, Upload, X, CheckCircle, ImageOff } from "lucide-react";
|
||||
import {
|
||||
Camera,
|
||||
Upload,
|
||||
X,
|
||||
ImageOff,
|
||||
CircleAlert,
|
||||
ZoomIn,
|
||||
Sun,
|
||||
AlignCenter,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
@@ -12,7 +22,9 @@ import { apiClient } from "@/lib/api-client";
|
||||
export function ScanPage() {
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -35,7 +47,15 @@ export function ScanPage() {
|
||||
return;
|
||||
}
|
||||
setFileName(f.name);
|
||||
setPreviewUrl(URL.createObjectURL(f));
|
||||
const url = URL.createObjectURL(f);
|
||||
setPreviewUrl(url);
|
||||
|
||||
// Detect image dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
@@ -56,10 +76,13 @@ export function ScanPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Deteksi Penyakit</p>
|
||||
<h1 className="text-3xl font-bold">Scan Tanaman</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara
|
||||
real-time.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/diagnoses">Riwayat Diagnosis</Link>
|
||||
@@ -68,37 +91,103 @@ export function ScanPage() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Card className="w-full lg:h-117 py-3">
|
||||
<CardContent className="px-6 py-4 h-full flex flex-col">
|
||||
{/* Left Sidebar */}
|
||||
<div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
|
||||
<Camera className="text-green-500" size={25} />
|
||||
Area Unggah Gambar
|
||||
</div>
|
||||
|
||||
{/* Upload area */}
|
||||
{!previewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full border-2 border-dashed border-green-300 rounded-xl p-12 text-center cursor-pointer hover:border-green-500 hover:bg-green-50/50 transition-colors group"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Camera className="mx-auto h-12 w-12 text-green-400 group-hover:text-green-500 transition-colors" />
|
||||
<div className="mt-4 text-green-600 text-lg font-medium">
|
||||
Unggah Gambar Daun
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mx-auto text-green-500 mb-3" size={48} />
|
||||
<h3 className="font-semibold text-base text-gray-800 mb-1">
|
||||
Seret & Lepas Foto Daun
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
atau klik untuk memilih file berkas dari perangkat Anda
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} />
|
||||
PNG, JPG, JPEG
|
||||
</div>
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} />
|
||||
Maks. 5 MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload size={18} />
|
||||
Pilih Berkas
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative rounded-xl overflow-hidden bg-muted">
|
||||
<div className="relative rounded-xl overflow-hidden bg-muted w-full">
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-full max-h-80 object-contain"
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* Scanner area detection overlay */}
|
||||
{imageDimensions && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
{/* Outer dark overlay */}
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
{/* Scan area frame - responsive to image */}
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{
|
||||
width: `${Math.min(imageDimensions.width * 0.7, 280)}px`,
|
||||
height: `${Math.min(imageDimensions.height * 0.7, 320)}px`,
|
||||
}}
|
||||
>
|
||||
{/* Corner markers */}
|
||||
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-lime-300" />
|
||||
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-lime-300" />
|
||||
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-lime-300" />
|
||||
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-lime-300" />
|
||||
|
||||
{/* Center text */}
|
||||
<div className="text-white text-center flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold tracking-widest">AREA SCAN</span>
|
||||
<span className="text-[10px] text-lime-200 font-medium">
|
||||
{imageDimensions.width} × {imageDimensions.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors"
|
||||
className="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 hover:bg-black/80 transition-colors z-10"
|
||||
onClick={() => {
|
||||
setPreviewUrl(null);
|
||||
setFileName(null);
|
||||
setImageDimensions(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
@@ -109,12 +198,13 @@ export function ScanPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File: <span className="font-medium">{fileName}</span>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPreviewUrl(null);
|
||||
setFileName(null);
|
||||
setImageDimensions(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
@@ -157,28 +247,112 @@ export function ScanPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="font-medium mb-3">Panduan Pengambilan Foto</h2>
|
||||
<ul className="text-sm space-y-3 text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
Jarak 15–30 cm dari daun
|
||||
{/* Right Sidebar */}
|
||||
<aside className="lg:col-span-1 flex flex-col gap-5">
|
||||
{/* Card 1: Panduan Pengambilan Foto */}
|
||||
<Card className="w-full h-max">
|
||||
<CardContent className="px-6 py-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<CircleAlert className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
<h2 className="font-bold text-emerald-800">
|
||||
Panduan Pengambilan Foto
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||
<ZoomIn className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
Jarak 15–30 cm dari daun
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
Pencahayaan cukup, hindari blur
|
||||
<li className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||
<Sun className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
Pencahayaan cukup merata
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
Daun memenuhi bingkai
|
||||
<li className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||
<AlignCenter className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
Posisi daun mengisi bingkai dengan jelas
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
||||
Hindari bayangan dan background berantakan
|
||||
<li className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||
<Camera className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
Hindari bayangan atau pantulan pada daun & blur pada gambar
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="rounded-lg overflow-hidden mt-4">
|
||||
<div className="relative h-36 overflow-hidden bg-[url('/src/assets/images/scan-guide-bg.png')] bg-cover bg-center text-white md:h-44">
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<div className="absolute inset-0 bg-linear-to-b from-black/5 via-transparent to-black/35" />
|
||||
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="relative flex h-full w-30 items-center justify-center mt-8">
|
||||
<span className="absolute left-0 top-0 h-4 w-4 border-l-2 border-t-2 border-l-lime-300 border-t-lime-300" />
|
||||
<span className="absolute right-0 top-0 h-4 w-4 border-r-2 border-t-2 border-r-lime-300 border-t-lime-300" />
|
||||
<span className="absolute bottom-0 left-0 h-4 w-4 border-b-2 border-l-2 border-b-lime-300 border-l-lime-300" />
|
||||
<span className="absolute bottom-0 right-0 h-4 w-4 border-b-2 border-r-2 border-b-lime-300 border-r-lime-300" />
|
||||
<span className="relative z-10 text-xs font-semibold tracking-[0.28em] text-white">
|
||||
AREA SCAN
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-lime-100 bg-black/30 w-full py-1 rounded">
|
||||
Posisi ideal: daun memenuhi bingkai
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Persyaratan Berkas */}
|
||||
<Card className="w-full h-max">
|
||||
<CardContent className="px-6 py-4">
|
||||
<h3 className="font-bold text-emerald-800 flex items-center gap-3 mb-4">
|
||||
Persyaratan Berkas
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ label: "Format", value: "PNG, JPG, JPEG" },
|
||||
{ label: "Ukuran Maks.", value: "5 MB" },
|
||||
{ label: "Resolusi Min.", value: "512 × 512 px" },
|
||||
{ label: "Objek Foto", value: "Daun jagung tunggal" },
|
||||
].map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
style={{
|
||||
borderBottom: i < 3 ? "1px solid #f0f0f0" : "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-gray-400"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{r.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
@@ -275,36 +449,34 @@ export function ScanPage() {
|
||||
)}
|
||||
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
|
||||
<div className="space-y-2">
|
||||
{(diagnosesQuery.data ?? [])
|
||||
.slice(0, 5)
|
||||
.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={d.imageUrl}
|
||||
alt="thumb"
|
||||
className="h-10 w-10 rounded object-cover bg-muted"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{d.disease?.commonName ?? "Unknown"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(d.createdAt).toLocaleString("id-ID")}
|
||||
</div>
|
||||
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={d.imageUrl}
|
||||
alt="thumb"
|
||||
className="h-10 w-10 rounded object-cover bg-muted"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{d.disease?.commonName ?? "Unknown"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(d.createdAt).toLocaleString("id-ID")}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/diagnoses/${d.id}`}
|
||||
className="text-emerald-600 text-sm font-semibold"
|
||||
>
|
||||
Lihat
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to={`/diagnoses/${d.id}`}
|
||||
className="text-emerald-600 text-sm font-semibold"
|
||||
>
|
||||
Lihat
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user