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,
|
Navigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { AuthInitializer } from "@/components/auth-initializer";
|
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 { DashboardPage } from "@/pages/dashboard-page";
|
||||||
import { ScanPage } from "@/pages/scan-page";
|
import { ScanPage } from "@/pages/scan-page";
|
||||||
import { LibraryPage } from "@/pages/library-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 { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page";
|
||||||
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
|
import { ExpertReviewsPage } from "@/pages/expert-reviews-page";
|
||||||
import { DiagnosesPage } from "@/pages/diagnoses-page";
|
import { DiagnosesPage } from "@/pages/diagnoses-page";
|
||||||
import { LoginPage } from "@/pages/login-page";
|
// import { LoginPage } from "@/pages/login-page";
|
||||||
import { RegisterPage } from "@/pages/register-page";
|
// import { RegisterPage } from "@/pages/register-page";
|
||||||
import { MainLayout } from "@/components/layout/main-layout";
|
import { MainLayout } from "@/components/layout/main-layout";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: "/login", element: <LoginPage /> },
|
// { path: "/login", element: <LoginPage /> },
|
||||||
{ path: "/register", element: <RegisterPage /> },
|
// { path: "/register", element: <RegisterPage /> },
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard>
|
<MainLayout>
|
||||||
<MainLayout>
|
<DashboardPage />
|
||||||
<DashboardPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/scan",
|
path: "/scan",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard>
|
<MainLayout>
|
||||||
<MainLayout>
|
<ScanPage />
|
||||||
<ScanPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/library",
|
path: "/library",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard>
|
<MainLayout>
|
||||||
<MainLayout>
|
<LibraryPage />
|
||||||
<LibraryPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/diagnoses",
|
path: "/diagnoses",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard>
|
<MainLayout>
|
||||||
<MainLayout>
|
<DiagnosesPage />
|
||||||
<DiagnosesPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/diagnoses/:id",
|
path: "/diagnoses/:id",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard>
|
<MainLayout>
|
||||||
<MainLayout>
|
<DiagnosisDetailPage />
|
||||||
<DiagnosisDetailPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/expert/reviews",
|
path: "/expert/reviews",
|
||||||
element: (
|
element: (
|
||||||
<AuthGuard requireExpert>
|
<MainLayout>
|
||||||
<MainLayout>
|
<ExpertReviewsPage />
|
||||||
<ExpertReviewsPage />
|
</MainLayout>
|
||||||
</MainLayout>
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
@@ -8,7 +8,7 @@ export function MainLayout({ children }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-[#ECF4E8]">
|
<div className="min-h-screen flex flex-col bg-[#ECF4E8]">
|
||||||
<Navbar />
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<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 { useState } from "react";
|
||||||
import { Leaf } from "lucide-react";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Leaf, Menu } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { MobileNav } from "./mobile-nav";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const location = useLocation();
|
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;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
@@ -22,17 +18,6 @@ export function Navbar() {
|
|||||||
: "text-white/85 hover:bg-white/10 hover:text-white",
|
: "text-white/85 hover:bg-white/10 hover:text-white",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await apiClient.logout();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setUser(null);
|
|
||||||
queryClient.clear();
|
|
||||||
navigate("/login");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 bg-[#306D29] text-white shadow-sm backdrop-blur">
|
<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="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
|
||||||
@@ -50,38 +35,50 @@ export function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="hidden lg:flex items-center gap-2">
|
||||||
<Link to="/dashboard" className={navLinkClassName(isActive("/dashboard"))}>
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className={navLinkClassName(isActive("/dashboard"))}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
|
<Link to="/scan" className={navLinkClassName(isActive("/scan"))}>
|
||||||
Scan
|
Scan Tanaman
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/diagnoses" className={navLinkClassName(isActive("/diagnoses"))}>
|
<Link
|
||||||
|
to="/diagnoses"
|
||||||
|
className={navLinkClassName(isActive("/diagnoses"))}
|
||||||
|
>
|
||||||
Diagnosa
|
Diagnosa
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/catalog" className={navLinkClassName(isActive("/catalog") || isActive("/library"))}>
|
<Link
|
||||||
|
to="/catalog"
|
||||||
|
className={navLinkClassName(
|
||||||
|
isActive("/catalog") || isActive("/library"),
|
||||||
|
)}
|
||||||
|
>
|
||||||
Pustaka
|
Pustaka
|
||||||
</Link>
|
</Link>
|
||||||
{user?.role === "expert" && (
|
<Link
|
||||||
<Link
|
to="/expert/reviews"
|
||||||
to="/expert/reviews"
|
className={navLinkClassName(isActive("/expert/reviews"))}
|
||||||
className={navLinkClassName(isActive("/expert/reviews"))}
|
>
|
||||||
>
|
Review
|
||||||
Review
|
</Link>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function Modal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
ref={containerRef}
|
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}>
|
<ModalHeader right={headerRight}>
|
||||||
{title ? <h2 className="text-lg font-semibold">{title}</h2> : null}
|
{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 ?? '';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
|
|
||||||
|
export interface ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
source?: 'uploader' | 'model-service' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
const url = `${apiBaseUrl}${endpoint}`;
|
const url = `${apiBaseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -25,14 +30,21 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = `HTTP ${response.status}`;
|
let errorMessage = `HTTP ${response.status}`;
|
||||||
|
let source: 'uploader' | 'model-service' | 'unknown' | undefined;
|
||||||
try {
|
try {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
if (errorData.error) {
|
if (errorData.error) {
|
||||||
errorMessage = errorData.error;
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
|
if (errorData.source) {
|
||||||
|
source = errorData.source;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
const error = new Error(errorMessage) as ApiError;
|
||||||
|
error.status = response.status;
|
||||||
|
error.source = source;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App } from './app';
|
import { App } from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import type { RiskLevel } from '@zeavis/shared';
|
import type { RiskLevel } from '@zeavis/shared';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { RiskBadge } from '@/components/risk-badge';
|
import { RiskBadge } from '@/components/risk-badge';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
@@ -43,10 +42,12 @@ export function CatalogPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
|
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Katalog 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ export function DashboardPage() {
|
|||||||
style={{ backgroundImage: `url(${bg})` }}
|
style={{ backgroundImage: `url(${bg})` }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
|
<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">
|
<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">
|
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
|
||||||
AI FOR SMART EDUCATION
|
AI FOR SMART EDUCATION
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold">Selamat Datang di</h1>
|
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
|
||||||
<h2 className="text-4xl font-extrabold tracking-tight text-[#9AD872]">
|
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
|
||||||
ZeaVis Edu
|
ZeaVis Edu
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 max-w-xl text-white/90">
|
<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
|
Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan
|
||||||
akurat.
|
akurat.
|
||||||
</p>
|
</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
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="outline"
|
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">
|
<Link to="/scan" className="inline-flex items-center gap-2">
|
||||||
<Scan className="h-5 w-6" />
|
<Scan className="h-5 w-6" />
|
||||||
@@ -105,7 +105,7 @@ export function DashboardPage() {
|
|||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="outline"
|
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
|
<Link
|
||||||
to="/catalog"
|
to="/catalog"
|
||||||
@@ -149,7 +149,7 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="md:col-span-4 text-xl font-bold">
|
<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
|
Proyek Urgensi
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[15px] font-normal text-muted-foreground">
|
<p className="text-[15px] font-normal text-muted-foreground">
|
||||||
@@ -231,7 +231,7 @@ export function DashboardPage() {
|
|||||||
{/* Mission section */}
|
{/* Mission section */}
|
||||||
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
|
<section className="space-y-5 rounded-4xl bg-[#EEF4E8] py-6 md:py-8">
|
||||||
<div className="space-y-1">
|
<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
|
Misi Platform
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[15px] font-normal text-muted-foreground">
|
<p className="text-[15px] font-normal text-muted-foreground">
|
||||||
@@ -272,7 +272,7 @@ export function DashboardPage() {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="mb-2 flex items-start justify-between">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[24px] font-extrabold text-[#214B11]">
|
<h3 className="text-lg md:text-[24px] font-extrabold text-[#214B11]">
|
||||||
Penyakit yang Dapat Dideteksi
|
Penyakit yang Dapat Dideteksi
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[15px] font-normal text-muted-foreground">
|
<p className="text-[15px] font-normal text-muted-foreground">
|
||||||
@@ -322,7 +322,7 @@ export function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Scan quick access */}
|
{/* 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">
|
<div className="flex-1 text-white">
|
||||||
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
|
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
|
||||||
<p className="text-sm text-[#9AD872] font-normal mt-2">
|
<p className="text-sm text-[#9AD872] font-normal mt-2">
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ export function DiagnosesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">
|
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
|
||||||
Manajemen Diagnosis
|
<p className="text-gray-500 mt-1 text-md">
|
||||||
|
Lihat hasil diagnosa dari scan yang telah dilakukan
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-3xl font-bold">Daftar Diagnosis</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
@@ -87,72 +87,70 @@ export function DiagnosesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<CardContent className="flex flex-col gap-4 p-4">
|
<label className="text-sm font-medium">Status</label>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<select
|
||||||
<label className="text-sm font-medium">Status</label>
|
value={statusFilter}
|
||||||
<select
|
onChange={(e) => handleStatusChange(e.target.value)}
|
||||||
value={statusFilter}
|
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
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}>
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
{opt.label}
|
||||||
<option key={opt.value} value={opt.value}>
|
</option>
|
||||||
{opt.label}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label className="text-sm font-medium">Risiko</label>
|
<label className="text-sm font-medium">Risiko</label>
|
||||||
<select
|
<select
|
||||||
value={riskFilter}
|
value={riskFilter}
|
||||||
onChange={(e) => handleRiskChange(e.target.value)}
|
onChange={(e) => handleRiskChange(e.target.value)}
|
||||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
className="rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
{RISK_OPTIONS.map((opt) => (
|
{RISK_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className="ml-auto text-sm text-muted-foreground">
|
<div className="w-full sm:w-auto sm:ml-auto text-sm text-muted-foreground">
|
||||||
Total: {filtered.length}
|
Total: {filtered.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
Memuat diagnosis...
|
Memuat diagnosis...
|
||||||
</div>
|
</div>
|
||||||
) : query.isError ? (
|
) : query.isError ? (
|
||||||
<div className="py-12 text-center text-red-600">
|
<div className="py-12 text-center text-red-600">
|
||||||
Gagal memuat diagnosis
|
Gagal memuat diagnosis
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Tidak ada diagnosis sesuai filter
|
Tidak ada diagnosis sesuai filter
|
||||||
</p>
|
</p>
|
||||||
{diagnoses.length === 0 && (
|
{diagnoses.length === 0 && (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Mulai dengan{" "}
|
Mulai dengan{" "}
|
||||||
<Link to="/scan" className="text-primary underline">
|
<Link to="/scan" className="text-primary underline">
|
||||||
melakukan scan daun
|
melakukan scan daun
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{filtered.map((d) => (
|
{filtered.map((d) => (
|
||||||
<Card key={d.id} className="h-full">
|
<Card key={d.id} className="h-full">
|
||||||
<CardContent className="flex gap-4 p-4 items-start">
|
<CardContent className="flex gap-4 p-4 items-start">
|
||||||
<img
|
<img
|
||||||
src={d.imageUrl}
|
src={d.imageUrl}
|
||||||
alt="Daun"
|
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-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -192,8 +190,6 @@ export function DiagnosesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,23 +14,22 @@ export function DiagnosisDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (query.isLoading) return <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>;
|
if (query.isLoading) return <div className="p-8 text-center text-muted-foreground">Memuat diagnosis...</div>;
|
||||||
if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>;
|
if (query.error || !query.data) return <div className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</div>;
|
||||||
|
|
||||||
const diagnosis = query.data;
|
const diagnosis = query.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-8">
|
<div className="space-y-6">
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">Detail Diagnosis</p>
|
<p className="text-sm font-medium text-primary">Detail Diagnosis</p>
|
||||||
<h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
|
<h1 className="text-xl md:text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
|
<Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
|
<img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
|
||||||
@@ -99,6 +98,5 @@ export function DiagnosisDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function DiseaseDetailPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight">{disease.commonName}</h1>
|
<h1 className="text-2xl md:text-4xl font-bold tracking-tight">{disease.commonName}</h1>
|
||||||
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
|
<p className="mt-2 text-lg text-muted-foreground">{disease.label}</p>
|
||||||
</div>
|
</div>
|
||||||
<RiskBadge level={disease.riskLevel} />
|
<RiskBadge level={disease.riskLevel} />
|
||||||
@@ -131,7 +131,7 @@ export function DiseaseDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||||
<Button asChild className="flex-1">
|
<Button asChild className="flex-1">
|
||||||
<Link to="/catalog">Lihat Katalog Lengkap</Link>
|
<Link to="/catalog">Lihat Katalog Lengkap</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import { FormEvent, useMemo, useState } from 'react';
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DiseaseSlug } from '@zeavis/shared';
|
import type { DiseaseSlug } from "@zeavis/shared";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import {
|
||||||
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
|
Card,
|
||||||
import { apiClient } from '@/lib/api-client';
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge";
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
|
||||||
export function ExpertReviewsPage() {
|
export function ExpertReviewsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const selectedId = searchParams.get('diagnosis');
|
const selectedId = searchParams.get("diagnosis");
|
||||||
const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified');
|
const [verdict, setVerdict] = useState<"verified" | "corrected">("verified");
|
||||||
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<DiseaseSlug | ''>('');
|
const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState<
|
||||||
const [notes, setNotes] = useState('');
|
DiseaseSlug | ""
|
||||||
|
>("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [reviewsQuery, diseasesQuery] = useQueries({
|
const [reviewsQuery, diseasesQuery] = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
{ queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() },
|
{
|
||||||
{ queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() },
|
queryKey: ["expert-reviews"],
|
||||||
|
queryFn: () => apiClient.getExpertReviews(),
|
||||||
|
},
|
||||||
|
{ queryKey: ["diseases"], queryFn: () => apiClient.getDiseases() },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,15 +44,18 @@ export function ExpertReviewsPage() {
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiClient.reviewDiagnosis(selected!.id, {
|
apiClient.reviewDiagnosis(selected!.id, {
|
||||||
verdict,
|
verdict,
|
||||||
correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined,
|
correctedDiseaseSlug:
|
||||||
|
verdict === "corrected"
|
||||||
|
? correctedDiseaseSlug || undefined
|
||||||
|
: undefined,
|
||||||
notes,
|
notes,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNotes('');
|
setNotes("");
|
||||||
setVerdict('verified');
|
setVerdict("verified");
|
||||||
setCorrectedDiseaseSlug('');
|
setCorrectedDiseaseSlug("");
|
||||||
queryClient.invalidateQueries({ queryKey: ['expert-reviews'] });
|
queryClient.invalidateQueries({ queryKey: ["expert-reviews"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,10 +69,13 @@ export function ExpertReviewsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">Review Pakar</p>
|
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
|
||||||
<h1 className="text-3xl font-bold">Diagnosis Menunggu Review</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,14 +84,20 @@ export function ExpertReviewsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold">Daftar Diagnosis</h2>
|
<h2 className="font-semibold">Daftar Diagnosis</h2>
|
||||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span>
|
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
|
||||||
|
{reviews.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Memuat diagnosis...</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Memuat diagnosis...
|
||||||
|
</p>
|
||||||
) : reviews.length === 0 ? (
|
) : 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) => (
|
reviews.map((review) => (
|
||||||
<button
|
<button
|
||||||
@@ -83,26 +106,35 @@ export function ExpertReviewsPage() {
|
|||||||
onClick={() => setSearchParams({ diagnosis: review.id })}
|
onClick={() => setSearchParams({ diagnosis: review.id })}
|
||||||
className={`w-full text-left transition ${
|
className={`w-full text-left transition ${
|
||||||
selected?.id === review.id
|
selected?.id === review.id
|
||||||
? 'ring-2 ring-primary'
|
? "ring-2 ring-primary"
|
||||||
: 'hover:border-primary'
|
: "hover:border-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Card className="transition hover:border-primary">
|
<Card className="transition hover:border-primary">
|
||||||
<CardContent className="flex gap-3 p-3">
|
<CardContent className="flex gap-3 p-3 items-start">
|
||||||
<img
|
<img
|
||||||
src={review.imageUrl}
|
src={review.imageUrl}
|
||||||
alt="Daun jagung"
|
alt="Daun jagung"
|
||||||
className="h-20 w-20 rounded-lg object-cover"
|
className="h-14 md:h-20 w-14 md:w-20 rounded-lg object-cover shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-1">
|
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||||
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
|
<h3 className="text-sm font-semibold">
|
||||||
<DiagnosisStatusBadge status={review.status} className="text-xs" />
|
{review.disease?.commonName ?? "Diagnosis gagal"}
|
||||||
|
</h3>
|
||||||
|
<DiagnosisStatusBadge
|
||||||
|
status={review.status}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`}
|
{review.confidence === null
|
||||||
|
? "Tidak ada confidence"
|
||||||
|
: `${(review.confidence * 100).toFixed(1)}%`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(review.createdAt).toLocaleString("id-ID")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -125,16 +157,18 @@ export function ExpertReviewsPage() {
|
|||||||
{/* Detail card */}
|
{/* Detail card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
|
<CardTitle>
|
||||||
|
{selected.disease?.commonName ?? "Diagnosis gagal"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{new Date(selected.createdAt).toLocaleString('id-ID')}
|
{new Date(selected.createdAt).toLocaleString("id-ID")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<img
|
<img
|
||||||
src={selected.imageUrl}
|
src={selected.imageUrl}
|
||||||
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
|
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? "diagnosis"}`}
|
||||||
className="h-64 w-full rounded-lg object-cover"
|
className="h-48 md:h-64 w-full rounded-lg object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -145,7 +179,9 @@ export function ExpertReviewsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Confidence</span>
|
<span className="text-sm font-medium">Confidence</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
|
{selected.confidence === null
|
||||||
|
? "N/A"
|
||||||
|
: `${(selected.confidence * 100).toFixed(1)}%`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,9 +191,16 @@ export function ExpertReviewsPage() {
|
|||||||
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
|
<h4 className="text-sm font-medium">Prediksi Teratas</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{selected.predictions.map((pred) => (
|
{selected.predictions.map((pred) => (
|
||||||
<div key={pred.id} className="flex items-center justify-between text-sm">
|
<div
|
||||||
<span className="text-muted-foreground">{pred.modelLabel}</span>
|
key={pred.id}
|
||||||
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span>
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{pred.modelLabel}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{(pred.confidence * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,30 +218,42 @@ export function ExpertReviewsPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Keputusan</label>
|
<label className="text-sm font-medium">Keputusan</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<label htmlFor="verdict-verified" className="flex items-center gap-2">
|
<label
|
||||||
|
htmlFor="verdict-verified"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="verdict-verified"
|
id="verdict-verified"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="verdict"
|
name="verdict"
|
||||||
value="verified"
|
value="verified"
|
||||||
checked={verdict === 'verified'}
|
checked={verdict === "verified"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setVerdict(e.target.value as 'verified' | 'corrected');
|
setVerdict(
|
||||||
setCorrectedDiseaseSlug('');
|
e.target.value as "verified" | "corrected",
|
||||||
|
);
|
||||||
|
setCorrectedDiseaseSlug("");
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Terverifikasi</span>
|
<span className="text-sm">Terverifikasi</span>
|
||||||
</label>
|
</label>
|
||||||
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
|
<label
|
||||||
|
htmlFor="verdict-corrected"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="verdict-corrected"
|
id="verdict-corrected"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="verdict"
|
name="verdict"
|
||||||
value="corrected"
|
value="corrected"
|
||||||
checked={verdict === 'corrected'}
|
checked={verdict === "corrected"}
|
||||||
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
|
onChange={(e) =>
|
||||||
|
setVerdict(
|
||||||
|
e.target.value as "verified" | "corrected",
|
||||||
|
)
|
||||||
|
}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Dikoreksi</span>
|
<span className="text-sm">Dikoreksi</span>
|
||||||
@@ -206,16 +261,23 @@ export function ExpertReviewsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{verdict === 'corrected' && (
|
{verdict === "corrected" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="disease" className="text-sm font-medium">
|
<label
|
||||||
|
htmlFor="disease"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
Penyakit yang Benar
|
Penyakit yang Benar
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="disease"
|
id="disease"
|
||||||
value={correctedDiseaseSlug}
|
value={correctedDiseaseSlug}
|
||||||
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')}
|
onChange={(e) =>
|
||||||
required={verdict === 'corrected'}
|
setCorrectedDiseaseSlug(
|
||||||
|
e.target.value as DiseaseSlug | "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required={verdict === "corrected"}
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Pilih penyakit...</option>
|
<option value="">Pilih penyakit...</option>
|
||||||
@@ -245,15 +307,22 @@ export function ExpertReviewsPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
|
disabled={
|
||||||
|
mutation.isPending ||
|
||||||
|
(verdict === "corrected" && !correctedDiseaseSlug) ||
|
||||||
|
!notes.trim()
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{mutation.isPending ? 'Mengirim...' : 'Kirim Review'}
|
{mutation.isPending ? "Mengirim..." : "Kirim Review"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
Error: {mutation.error instanceof Error ? mutation.error.message : 'Terjadi kesalahan'}
|
Error:{" "}
|
||||||
|
{mutation.error instanceof Error
|
||||||
|
? mutation.error.message
|
||||||
|
: "Terjadi kesalahan"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">Edukasi Penyakit</p>
|
<h1 className="text-2xl font-bold text-emerald-800">Pustaka Penyakit</h1>
|
||||||
<h1 className="text-3xl font-bold">Pustaka Penyakit</h1>
|
<p className="text-gray-500 mt-1 text-md">
|
||||||
|
Referensi lengkap penyakit daun jagung yang dapat dideteksi oleh sistem AI
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||||
<AuthForm
|
<AuthForm
|
||||||
mode="login"
|
mode="login"
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||||
<AuthForm
|
<AuthForm
|
||||||
mode="register"
|
mode="register"
|
||||||
isSubmitting={mutation.isPending}
|
isSubmitting={mutation.isPending}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
@@ -12,7 +22,9 @@ import { apiClient } from "@/lib/api-client";
|
|||||||
export function ScanPage() {
|
export function ScanPage() {
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = 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 inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -35,7 +47,15 @@ export function ScanPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFileName(f.name);
|
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 = () => {
|
const handleUpload = () => {
|
||||||
@@ -56,10 +76,13 @@ export function ScanPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-medium text-primary">Deteksi Penyakit</p>
|
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
|
||||||
<h1 className="text-3xl font-bold">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>
|
</div>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link to="/diagnoses">Riwayat Diagnosis</Link>
|
<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="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
<Card>
|
<Card className="w-full lg:h-117 py-3">
|
||||||
<CardContent className="p-6">
|
<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 */}
|
{/* Upload area */}
|
||||||
{!previewUrl ? (
|
{!previewUrl ? (
|
||||||
<button
|
<div className="space-y-3">
|
||||||
type="button"
|
<div
|
||||||
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"
|
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<Camera className="mx-auto h-12 w-12 text-green-400 group-hover:text-green-500 transition-colors" />
|
<Upload className="mx-auto text-green-500 mb-3" size={48} />
|
||||||
<div className="mt-4 text-green-600 text-lg font-medium">
|
<h3 className="font-semibold text-base text-gray-800 mb-1">
|
||||||
Unggah Gambar Daun
|
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>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Seret & Lepas atau klik untuk memilih file (PNG/JPG, maks 5MB)
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</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="space-y-4">
|
||||||
<div className="relative rounded-xl overflow-hidden bg-muted">
|
<div className="relative rounded-xl overflow-hidden bg-muted w-full">
|
||||||
<img
|
<img
|
||||||
|
ref={imageRef}
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
className="w-full max-h-80 object-contain"
|
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
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
|
setImageDimensions(null);
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -109,12 +198,13 @@ export function ScanPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
File: <span className="font-medium">{fileName}</span>
|
File: <span className="font-medium">{fileName}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
|
setImageDimensions(null);
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -157,28 +247,112 @@ export function ScanPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-4">
|
{/* Right Sidebar */}
|
||||||
<Card>
|
<aside className="lg:col-span-1 flex flex-col gap-5">
|
||||||
<CardContent className="p-4">
|
{/* Card 1: Panduan Pengambilan Foto */}
|
||||||
<h2 className="font-medium mb-3">Panduan Pengambilan Foto</h2>
|
<Card className="w-full h-max">
|
||||||
<ul className="text-sm space-y-3 text-muted-foreground">
|
<CardContent className="px-6 py-4">
|
||||||
<li className="flex items-start gap-2">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
<div className="inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||||
Jarak 15–30 cm dari daun
|
<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>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-center gap-3">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||||
Pencahayaan cukup, hindari blur
|
<Sun className="text-emerald-600" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700">
|
||||||
|
Pencahayaan cukup merata
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-center gap-3">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||||
Daun memenuhi bingkai
|
<AlignCenter className="text-emerald-600" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700">
|
||||||
|
Posisi daun mengisi bingkai dengan jelas
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-center gap-3">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 mt-0.5 shrink-0" />
|
<div className="h-9 w-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
|
||||||
Hindari bayangan dan background berantakan
|
<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>
|
</li>
|
||||||
</ul>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -275,36 +449,34 @@ export function ScanPage() {
|
|||||||
)}
|
)}
|
||||||
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
|
{!diagnosesQuery.isLoading && !diagnosesQuery.isError && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(diagnosesQuery.data ?? [])
|
{(diagnosesQuery.data ?? []).slice(0, 5).map((d) => (
|
||||||
.slice(0, 5)
|
<div
|
||||||
.map((d) => (
|
key={d.id}
|
||||||
<div
|
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
|
||||||
key={d.id}
|
>
|
||||||
className="flex items-center justify-between rounded-md p-2 hover:bg-muted"
|
<div className="flex items-center gap-3">
|
||||||
>
|
<img
|
||||||
<div className="flex items-center gap-3">
|
src={d.imageUrl}
|
||||||
<img
|
alt="thumb"
|
||||||
src={d.imageUrl}
|
className="h-10 w-10 rounded object-cover bg-muted"
|
||||||
alt="thumb"
|
/>
|
||||||
className="h-10 w-10 rounded object-cover bg-muted"
|
<div className="text-sm">
|
||||||
/>
|
<div className="font-medium">
|
||||||
<div className="text-sm">
|
{d.disease?.commonName ?? "Unknown"}
|
||||||
<div className="font-medium">
|
</div>
|
||||||
{d.disease?.commonName ?? "Unknown"}
|
<div className="text-xs text-muted-foreground">
|
||||||
</div>
|
{new Date(d.createdAt).toLocaleString("id-ID")}
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{new Date(d.createdAt).toLocaleString("id-ID")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
to={`/diagnoses/${d.id}`}
|
|
||||||
className="text-emerald-600 text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Lihat
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Link
|
||||||
|
to={`/diagnoses/${d.id}`}
|
||||||
|
className="text-emerald-600 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Lihat
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user