diff --git a/apps/web/package.json b/apps/web/package.json index 03f9120..51a996d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@types/react-dom": "^19.2.3", "postcss": "^8.5.15", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.14", + "vite-tsconfig-paths": "6.1.1" } } diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 29a38cc..cd597a9 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,47 +1,72 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { AuthGuard } from '@/components/auth-guard'; -import { DashboardPage } from '@/pages/dashboard-page'; -import { LandingPage } from '@/pages/landing-page'; -import { CatalogPage } from '@/pages/catalog-page'; -import { DiseaseDetailPage } from '@/pages/disease-detail-page'; -import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page'; -import { ExpertReviewsPage } from '@/pages/expert-reviews-page'; -import { LoginPage } from '@/pages/login-page'; -import { RegisterPage } from '@/pages/register-page'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createBrowserRouter, + RouterProvider, + Navigate, +} from "react-router-dom"; +import { DashboardPage } from "@/pages/dashboard-page"; +// import { LandingPage } from "@/pages/landing-page"; +import { ScanPage } from "@/pages/scan-page"; +import { LibraryPage } from "@/pages/library-page"; +import { CatalogPage } from "@/pages/catalog-page"; +import { DiseaseDetailPage } from "@/pages/disease-detail-page"; +import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page"; +import { ExpertReviewsPage } from "@/pages/expert-reviews-page"; +// import { 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: }, - { path: '/login', element: }, - { path: '/register', element: }, + // { + // path: "/", + // element: ( + // + // + // + // ), + // }, { - path: '/dashboard', + path: "/", + element: , + }, + { + path: "/scan", element: ( - + + + + ), + }, + { + path: "/library", + element: ( + + + + ), + }, + // { path: "/login", element: }, + // { path: "/register", element: }, + { + path: "/dashboard", + element: ( + - + ), }, { - path: '/diagnoses/:id', - element: ( - - - - ), + path: "/diagnoses/:id", + element: , }, { - path: '/expert/reviews', - element: ( - - - - ), + path: "/expert/reviews", + element: , }, - { path: '/catalog', element: }, - { path: '/catalog/:slug', element: }, + { path: "/catalog", element: }, + { path: "/catalog/:slug", element: }, ]); export function App() { diff --git a/apps/web/src/assets/images/dashboard-bg.png b/apps/web/src/assets/images/dashboard-bg.png new file mode 100644 index 0000000..613383d Binary files /dev/null and b/apps/web/src/assets/images/dashboard-bg.png differ diff --git a/apps/web/src/components/layout/footer.tsx b/apps/web/src/components/layout/footer.tsx new file mode 100644 index 0000000..6995856 --- /dev/null +++ b/apps/web/src/components/layout/footer.tsx @@ -0,0 +1,9 @@ +export function Footer() { + return ( +
+
+ © 2026 ZeaVis Edu - AI for Smart Education +
+
+ ); +} diff --git a/apps/web/src/components/layout/main-layout.tsx b/apps/web/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..193181c --- /dev/null +++ b/apps/web/src/components/layout/main-layout.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; +import { Navbar } from "./navbar"; +import { Footer } from "./footer"; + +type Props = { children: ReactNode }; + +export function MainLayout({ children }: Props) { + return ( +
+ +
+ {children} +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/navbar.tsx b/apps/web/src/components/layout/navbar.tsx new file mode 100644 index 0000000..352d43a --- /dev/null +++ b/apps/web/src/components/layout/navbar.tsx @@ -0,0 +1,84 @@ +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Leaf } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/store/auth-store"; +import { apiClient } from "@/lib/api-client"; + +export function Navbar() { + const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const setUser = useAuthStore((state) => state.setUser); + const isDashboard = location.pathname === "/dashboard"; + const isScan = location.pathname === "/scan"; + const isLibrary = location.pathname === "/library"; + + const navLinkClassName = (isActive: boolean) => + [ + "inline-flex items-center rounded-full px-4 py-2 text-[18px] font-medium transition-colors", + isActive + ? "bg-[#48A111] text-white shadow-sm" + : "text-white/85 hover:bg-white/10 hover:text-white", + ].join(" "); + + const logoutMutation = useMutation({ + mutationFn: async () => { + await apiClient.logout(); + }, + onSuccess: () => { + setUser(null); + queryClient.clear(); + navigate("/"); + }, + }); + + const user = useAuthStore((state) => state.user); + + return ( +
+
+
+
+ + + +
+
+
ZeaVis Edu
+
+ Smart AI for Corn Disease Detection +
+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index b0ac053..4b88389 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,25 +1,25 @@ -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; -import type { ButtonHTMLAttributes } from 'react'; -import { cn } from '@/lib/utils'; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50', + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - outline: 'border border-border bg-transparent hover:bg-muted', - ghost: 'hover:bg-muted', + default: "bg-primary text-primary-foreground hover:bg-primary/90", + outline: "border border-border bg-transparent hover:bg-muted", + ghost: "hover:bg-[#48A111] hover:text-primary-foreground hover:rounded-[50px]", }, size: { - default: 'h-10 px-4 py-2', - lg: 'h-12 rounded-lg px-6', + default: "h-10 px-4 py-2", + lg: "h-12 rounded-lg px-6", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, }, ); @@ -29,8 +29,19 @@ type ButtonProps = ButtonHTMLAttributes & asChild?: boolean; }; -export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { - const Comp = asChild ? Slot : 'button'; +export function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; - return ; + return ( + + ); } diff --git a/apps/web/src/components/ui/modal-footer.tsx b/apps/web/src/components/ui/modal-footer.tsx new file mode 100644 index 0000000..dec1aea --- /dev/null +++ b/apps/web/src/components/ui/modal-footer.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from 'react'; + +type Props = { + children?: ReactNode; + className?: string; +}; + +export function ModalFooter({ children, className }: Props) { + return
{children}
; +} + +export default ModalFooter; diff --git a/apps/web/src/components/ui/modal-header.tsx b/apps/web/src/components/ui/modal-header.tsx new file mode 100644 index 0000000..fe8b551 --- /dev/null +++ b/apps/web/src/components/ui/modal-header.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from 'react'; + +type Props = { + children?: ReactNode; + right?: ReactNode; + className?: string; +}; + +export function ModalHeader({ children, right, className }: Props) { + return ( +
+
{children}
+ {right &&
{right}
} +
+ ); +} + +export default ModalHeader; diff --git a/apps/web/src/components/ui/modal.tsx b/apps/web/src/components/ui/modal.tsx new file mode 100644 index 0000000..e536f04 --- /dev/null +++ b/apps/web/src/components/ui/modal.tsx @@ -0,0 +1,82 @@ +import React, { ReactNode, useEffect, useRef } from "react"; +import { ModalHeader } from "./modal-header"; +import { ModalFooter } from "./modal-footer"; + +type Size = "sm" | "md" | "lg" | "full"; + +const sizeClass: Record = { + sm: "max-w-xl", + md: "max-w-3xl", + lg: "max-w-5xl", + full: "max-w-full h-full", +}; + +type Props = { + open: boolean; + onClose: () => void; + children: ReactNode; + title?: string; + footer?: ReactNode; + headerRight?: ReactNode; + size?: Size; + closeOnBackdrop?: boolean; + className?: string; +}; + +export function Modal({ + open, + onClose, + children, + title, + footer, + headerRight, + size = "md", + closeOnBackdrop = true, + className, +}: Props) { + const containerRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (open) window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + useEffect(() => { + if (open) { + containerRef.current?.focus(); + } + }, [open]); + + if (!open) return null; + + return ( +
+
closeOnBackdrop && onClose()} + /> +
+ + {title ?

{title}

: null} +
+
{children}
+ + {footer ?? ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/data/mock-diseases.ts b/apps/web/src/data/mock-diseases.ts new file mode 100644 index 0000000..06d0ea4 --- /dev/null +++ b/apps/web/src/data/mock-diseases.ts @@ -0,0 +1,61 @@ +export const mockDiseases = [ + { + id: "nlb", + name: "Hawar Daun", + slug: "hawar-daun", + severity: "Tinggi", + imageUrl: "https://via.placeholder.com/320x180?text=Hawar+Daun", + pathogen: "Exserohilum turcicum", + description: + "Penyakit jamur yang menyebabkan lesi pada daun dan dapat menurunkan hasil panen.", + symptoms: [ + "Lesi lonjong berbentuk cerutu, 2.5 - 15 cm", + "Warna abu-kehijauan berkembang menjadi coklat -abu", + "Nekrosis parah pada seluruh permukaan daun", + ], + prevention: + "Gunakan varietas tahan, rotasi tanaman, dan hapus sisa tanaman terinfeksi.", + }, + { + id: "rust", + name: "Karat Daun", + slug: "karat-daun", + imageUrl: "https://via.placeholder.com/320x180?text=Karat+Daun", + pathogen: "Puccinia spp.", + severity: "Sedang", + description: + "Pustulan oranye pada permukaan daun yang dapat mengurangi fotosintesis.", + symptoms: [ + "Pustula oranye pada permukaan daun", + "Daun menguning dan rontok pada serangan berat", + ], + prevention: + "Hindari kelembapan tinggi, gunakan fungisida bila perlu, dan perbaiki sirkulasi udara.", + }, + { + id: "spot", + name: "Bercak Abu-abu", + slug: "bercak-abu-abu", + imageUrl: "https://via.placeholder.com/320x180?text=Bercak+Abu-abu", + pathogen: "Cercospora spp.", + severity: "Rendah", + description: + "Bercak kecil berwarna abu-abu yang umumnya tidak menyebabkan kematian tanaman.", + symptoms: [ + "Bercak kecil bundar hingga tidak beraturan", + "Daun kering pada area bercak", + ], + prevention: + "Praktek sanitasi, buang daun yang berat terinfeksi, dan pemantauan rutin.", + }, + { + id: "healthy", + name: "Daun Sehat", + slug: "daun-sehat", + imageUrl: "https://via.placeholder.com/320x180?text=Daun+Sehat", + severity: "Sehat", + description: "Daun tanpa tanda penyakit.", + symptoms: [], + prevention: "-", + }, +]; diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts new file mode 100644 index 0000000..21056aa --- /dev/null +++ b/apps/web/src/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.jpg"; +declare module "*.jpeg"; +declare module "*.png"; +declare module "*.svg"; diff --git a/apps/web/src/pages/dashboard-page.tsx b/apps/web/src/pages/dashboard-page.tsx index 68748ab..aadf457 100644 --- a/apps/web/src/pages/dashboard-page.tsx +++ b/apps/web/src/pages/dashboard-page.tsx @@ -1,118 +1,116 @@ -import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Link, useNavigate } from 'react-router-dom'; -import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { ManualClassificationForm } from '@/components/manual-classification-form'; -import { ImageClassificationForm } from '@/components/image-classification-form'; -import { DiagnosisCard } from '@/components/diagnosis-card'; -import { useAuthStore } from '@/store/auth-store'; -import { useUiStore } from '@/store/ui-store'; -import { apiClient } from '@/lib/api-client'; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { BookOpen, ChevronRight, Pill, Shield, Scan } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useUiStore } from "@/store/ui-store"; +import { apiClient } from "@/lib/api-client"; +import bg from "@/assets/images/dashboard-bg.png"; export function DashboardPage() { - const { user } = useAuthStore(); - const setUser = useAuthStore((state) => state.setUser); - const { dashboardCompact, toggleDashboardCompact } = useUiStore(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - - const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({ - queries: [ - { - queryKey: ['diseases'], - queryFn: () => apiClient.getDiseases(), - }, - { - queryKey: ['dashboard-summary'], - queryFn: () => apiClient.getDashboardSummary(), - }, - { - queryKey: ['diagnoses'], - queryFn: () => apiClient.getDiagnoses(), - }, - { - queryKey: ['manual-classifications'], - queryFn: () => apiClient.getManualClassifications(), - }, - ], + const { dashboardCompact } = useUiStore(); + const summaryQuery = useQuery({ + queryKey: ["dashboard-summary"], + queryFn: () => apiClient.getDashboardSummary(), }); - const createDiagnosisMutation = useMutation({ - mutationFn: async (file: File) => { - return await apiClient.createDiagnosis(file); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['diagnoses'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); - }, - }); - - const createClassificationMutation = useMutation({ - mutationFn: async (payload: Parameters[0]) => { - await apiClient.createManualClassification(payload); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); - queryClient.invalidateQueries({ queryKey: ['manual-classifications'] }); - }, - }); - - const logoutMutation = useMutation({ - mutationFn: async () => { - await apiClient.logout(); - }, - onSuccess: () => { - setUser(null); - queryClient.clear(); - navigate('/login'); - }, - }); - - const diseases = diseasesQuery.data || []; const summary = summaryQuery.data; - const diagnoses = diagnosesQuery.data || []; - const classifications = classificationsQuery.data || []; - const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading; - const hasError = diseasesQuery.error || summaryQuery.error; + const missionCards = [ + { + icon: Scan, + title: "Deteksi Otomatis", + description: + "Upload foto daun jagung dan AI kami akan mengidentifikasi penyakit secara instan.", + accent: "text-lime-600", + }, + { + icon: BookOpen, + title: "Modul Edukasi", + description: + "Informasi detail tentang gejala, penyebab, dan dampak setiap penyakit daun jagung.", + accent: "text-amber-600", + }, + { + icon: Shield, + title: "Panduan Pencegahan", + description: + "Strategi pencegahan berbasis sains untuk melindungi tanaman Anda dari infeksi.", + accent: "text-blue-600", + }, + { + icon: Pill, + title: "Rekomendasi Obat", + description: + "Saran fungisida dan perawatan mandiri yang tepat sesuai jenis penyakit.", + accent: "text-violet-600", + }, + ]; + + const diseasesQuick = [ + { name: "Hawar Daun", sci: "Northern Leaf Blight", color: "#b91c1c" }, + { name: "Karat Daun", sci: "Common Rust", color: "#d97706" }, + { name: "Bercak Abu-abu", sci: "Gray Leaf Spot", color: "#6b7280" }, + { name: "Daun Sehat", sci: "Healthy", color: "#16a34a" }, + ]; + + const isLoadingData = summaryQuery.isLoading; + const hasError = Boolean(summaryQuery.error); return ( -
+
-
-
-
- Dashboard + {/* Hero header */} +
+
+
+
+ + AI FOR SMART EDUCATION + +

Selamat Datang di

+

+ ZeaVis Edu +

+

+ Platform edukasi berbasis AI untuk membantu petani jagung + Indonesia mendeteksi penyakit daun secara mandiri, cepat, dan + akurat. +

+
+ + +
-

ZeaVis Edu Workspace

-

- {user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'} -

-
-
- {user?.role === 'expert' && ( - - )} - - +
+ {/* Loading / Error states */} {isLoadingData && ( Memuat data dashboard... @@ -121,43 +119,66 @@ export function DashboardPage() { {hasError && ( - Gagal memuat data dashboard +
Gagal memuat data dashboard
+
+ {String(summaryQuery.error?.message)} +
)} + {/* Main dashboard content */} {!isLoadingData && !hasError && ( <> {summary && ( -
+
+
+

+ Proyek Urgensi +

+

+ Data ringkasan terbaru dari proyek Anda untuk memantau + perkembangan dan hasil deteksi penyakit daun jagung +

+
- + Total Penyakit - -
{summary.diseaseCount}
+ +
+ {summary.diseaseCount} +
- + Total Diagnosis - -
{summary.imageClassificationCount}
+ +
+ {summary.imageClassificationCount} +
- + Menunggu Review - +
{summary.needsReviewCount}
@@ -165,12 +186,12 @@ export function DashboardPage() {
- + Risiko Tinggi - +
{summary.riskDistribution.high}
@@ -179,91 +200,119 @@ export function DashboardPage() {
)} -
- - - - - Katalog Penyakit - - - Pelajari tentang {diseases.length} penyakit daun jagung - - - - - - + {/* Mission section */} +
+
+

+ Misi Platform +

+

+ Fitur inti yang kami sediakan untuk mendukung petani jagung + Indonesia +

+
- - - - - Distribusi Risiko - - - Penyakit berdasarkan tingkat risiko - - - - {summary && ( -
-
- Risiko Tinggi - {summary.riskDistribution.high} -
-
- Risiko Sedang - {summary.riskDistribution.medium} -
-
- Risiko Rendah - {summary.riskDistribution.low} -
-
- )} -
-
+
+ {missionCards.map((card) => { + const Icon = card.icon; + + return ( + + +
+ +
+
+

+ {card.title} +

+

+ {card.description} +

+
+
+
+ ); + })} +
- { - await createDiagnosisMutation.mutateAsync(file); - }} - isSubmitting={createDiagnosisMutation.isPending} - latestResult={diagnoses[0] ?? null} - /> + {/* Diseases quick access */} +
+
+
+

+ Penyakit yang Dapat Dideteksi +

+

+ 4 kelas penyakit dan kondisi daun jagung dalam sistem kami +

+
+ + Lihat Pustaka + +
- { - await createClassificationMutation.mutateAsync(payload); - }} - isSubmitting={createClassificationMutation.isPending} - /> +
+ {diseasesQuick.map((d) => ( + + +
+
+
+
+ {d.name} +
+
+ {d.sci} +
+
+
+ + + ))} +
+
- {diagnoses.length > 0 && ( - - - - - Riwayat Diagnosis - - - {diagnoses.length} diagnosis yang telah dibuat - - - -
- {diagnoses.slice(0, 6).map((diagnosis) => ( - - ))} -
-
-
- )} + {/* Scan quick access */} +
+
+

Siap Mendeteksi Penyakit Daun?

+
+

+ Unggah foto daun jagung Anda dan dapatkan hasil analisis AI + dalam hitungan detik. +

+
+
+ + +
)}
diff --git a/apps/web/src/pages/landing-page.tsx b/apps/web/src/pages/landing-page.tsx index 58a0913..d6eea0a 100644 --- a/apps/web/src/pages/landing-page.tsx +++ b/apps/web/src/pages/landing-page.tsx @@ -1,23 +1,31 @@ -import { ArrowRight, Leaf, ShieldCheck, Sprout } from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ArrowRight, Leaf, ShieldCheck, Sprout } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; const features = [ { icon: Leaf, - title: 'Katalog Penyakit Lengkap', - description: 'Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.', + title: "Katalog Penyakit Lengkap", + description: + "Pelajari tentang empat penyakit daun jagung utama dengan gejala dan rekomendasi penanganan.", }, { icon: ShieldCheck, - title: 'Pantau Risiko Penyakit', - description: 'Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.', + title: "Pantau Risiko Penyakit", + description: + "Lacak distribusi risiko penyakit dan lihat laporan pengamatan terbaru di dashboard.", }, { icon: Sprout, - title: 'Laporkan Pengamatan', - description: 'Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.', + title: "Laporkan Pengamatan", + description: + "Kirimkan laporan penyakit yang Anda temukan untuk membantu penelitian dan edukasi.", }, ]; @@ -44,23 +52,26 @@ export function LandingPage() {

- Belajar mengenali penyakit daun jagung dan pantau pengamatan Anda. + Belajar mengenali penyakit daun jagung dan pantau pengamatan + Anda.

- Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara penanganannya, - serta laporkan pengamatan Anda untuk membantu penelitian dan edukasi. + Jelajahi katalog penyakit daun jagung, pelajari gejala dan cara + penanganannya, serta laporkan pengamatan Anda untuk membantu + penelitian dan edukasi.

+
@@ -74,7 +85,9 @@ export function LandingPage() {
{feature.title} - {feature.description} + + {feature.description} +
diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx new file mode 100644 index 0000000..7f282cd --- /dev/null +++ b/apps/web/src/pages/library-page.tsx @@ -0,0 +1,182 @@ +import React, { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { BookOpen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/ui/modal"; +import { mockDiseases } from "@/data/mock-diseases"; + +export function LibraryPage() { + type Disease = (typeof mockDiseases)[number]; + + const [filter, setFilter] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const items = useMemo(() => { + if (!filter) return mockDiseases; + return mockDiseases.filter((d) => + d.name.toLowerCase().includes(filter.toLowerCase()), + ); + }, [filter]); + + return ( +
+

Pustaka Penyakit

+
+ setFilter(e.target.value || null)} + className="border px-3 py-2 rounded-md w-full max-w-sm" + /> +
+ +
+ {items.map((d) => ( +
+
+ {d.name} +
+
+
+

+ {d.name}{" "} + + {d.severity} + +

+

+ {d.description} +

+ {d.pathogen && ( +
+ Patogen: {d.pathogen} +
+ )} +
+ + + Lihat halaman katalog + +
+
+
+ +
+
+ + {expandedId === d.id && ( +
+
+

Gejala

+
    + {(d.symptoms || []).length > 0 ? ( + d.symptoms.map((s: string, i: number) => ( +
  • {s}
  • + )) + ) : ( +
  • Tidak ada gejala khusus
  • + )} +
+
+
+

Pencegahan

+

{d.prevention}

+
+
+ )} +
+
+
+ ))} +
+ + { + setModalOpen(false); + setSelected(null); + }} + title={selected?.name} + footer={ + selected && ( +
+ + Buka halaman katalog + + +
+ ) + } + > + {selected ? ( +
+ {selected.name} +
+

+ {selected.description} +

+ {selected.pathogen && ( +

+ Patogen: {selected.pathogen} +

+ )} +

Gejala

+
    + {(selected.symptoms || []).length > 0 ? ( + selected.symptoms.map((s: string, i: number) => ( +
  • {s}
  • + )) + ) : ( +
  • Tidak ada gejala khusus
  • + )} +
+

Pencegahan

+

{selected.prevention}

+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index 94aef67..ae5aa18 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -1,25 +1,32 @@ -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AuthForm } from '@/components/auth-form'; -import { apiClient } from '@/lib/api-client'; -import { useAuthStore } from '@/store/auth-store'; +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AuthForm } from "@/components/auth-form"; +import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/store/auth-store"; export function LoginPage() { const navigate = useNavigate(); + useEffect(() => { + navigate("/dashboard"); + }, [navigate]); const queryClient = useQueryClient(); const setUser = useAuthStore((state) => state.setUser); const [error, setError] = useState(null); - const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); + const meQuery = useQuery({ + queryKey: ["auth", "me"], + queryFn: () => apiClient.getMe(), + }); const mutation = useMutation({ mutationFn: apiClient.login, onSuccess: (response) => { setUser(response.user); - queryClient.setQueryData(['auth', 'me'], response); - navigate('/dashboard'); + queryClient.setQueryData(["auth", "me"], response); + navigate("/dashboard"); }, - onError: (err) => setError(err instanceof Error ? err.message : 'Login gagal'), + onError: (err) => + setError(err instanceof Error ? err.message : "Login gagal"), }); return ( @@ -29,7 +36,9 @@ export function LoginPage() { mode="login" isSubmitting={mutation.isPending} error={error} - googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)} + googleOAuthEnabled={Boolean( + meQuery.data?.features.googleOAuthEnabled, + )} onSubmit={async ({ email, password }) => { setError(null); return mutation.mutateAsync({ email, password }); @@ -37,7 +46,10 @@ export function LoginPage() { onFieldChange={() => setError(null)} />

- Belum punya akun? Daftar + Belum punya akun?{" "} + + Daftar +

diff --git a/apps/web/src/pages/register-page.tsx b/apps/web/src/pages/register-page.tsx index ddf2670..e793b70 100644 --- a/apps/web/src/pages/register-page.tsx +++ b/apps/web/src/pages/register-page.tsx @@ -1,25 +1,32 @@ -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AuthForm } from '@/components/auth-form'; -import { apiClient } from '@/lib/api-client'; -import { useAuthStore } from '@/store/auth-store'; +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AuthForm } from "@/components/auth-form"; +import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/store/auth-store"; export function RegisterPage() { const navigate = useNavigate(); + useEffect(() => { + navigate("/dashboard"); + }, [navigate]); const queryClient = useQueryClient(); const setUser = useAuthStore((state) => state.setUser); const [error, setError] = useState(null); - const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); + const meQuery = useQuery({ + queryKey: ["auth", "me"], + queryFn: () => apiClient.getMe(), + }); const mutation = useMutation({ mutationFn: apiClient.register, onSuccess: (response) => { setUser(response.user); - queryClient.setQueryData(['auth', 'me'], response); - navigate('/dashboard'); + queryClient.setQueryData(["auth", "me"], response); + navigate("/dashboard"); }, - onError: (err) => setError(err instanceof Error ? err.message : 'Registrasi gagal'), + onError: (err) => + setError(err instanceof Error ? err.message : "Registrasi gagal"), }); return ( @@ -29,15 +36,20 @@ export function RegisterPage() { mode="register" isSubmitting={mutation.isPending} error={error} - googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)} + googleOAuthEnabled={Boolean( + meQuery.data?.features.googleOAuthEnabled, + )} onSubmit={async ({ name, email, password }) => { setError(null); - return mutation.mutateAsync({ name: name ?? '', email, password }); + return mutation.mutateAsync({ name: name ?? "", email, password }); }} onFieldChange={() => setError(null)} />

- Sudah punya akun? Masuk + Sudah punya akun?{" "} + + Masuk +

diff --git a/apps/web/src/pages/scan-page.tsx b/apps/web/src/pages/scan-page.tsx new file mode 100644 index 0000000..28fd0aa --- /dev/null +++ b/apps/web/src/pages/scan-page.tsx @@ -0,0 +1,143 @@ +import React, { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { Modal } from "@/components/ui/modal"; +import type { DiagnosisRecord } from "@zeavis/shared"; + +export function ScanPage() { + const [fileName, setFileName] = useState(null); + const inputRef = useRef(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (file: File) => apiClient.createDiagnosis(file), + onSuccess: (diagnosis) => { + queryClient.invalidateQueries({ queryKey: ["diagnoses"] }); + // show preview modal instead of immediate navigation + setDiagnosisPreview(diagnosis as DiagnosisRecord); + setPreviewOpen(true); + }, + }); + + const handleFile = (f?: File) => { + if (!f) return; + setFileName(f.name); + mutation.mutate(f); + }; + + const [previewOpen, setPreviewOpen] = useState(false); + const [diagnosisPreview, setDiagnosisPreview] = + useState(null); + + return ( +
+

Scan Tanaman

+
+
+ + handleFile(e.target.files?.[0])} + /> +
+ + {mutation.isPending && ( +
Mengunggah...
+ )} + {mutation.isError && ( +
+ {mutation.error instanceof Error + ? mutation.error.message + : String(mutation.error) || "Upload gagal"} +
+ )} +
+
+ +
+ { + setPreviewOpen(false); + setDiagnosisPreview(null); + }} + title={diagnosisPreview?.predictedDiseaseSlug ?? "Hasil Diagnosis"} + size="sm" + footer={ + diagnosisPreview && ( +
+ + +
+ ) + } + > + {diagnosisPreview ? ( +
+ {diagnosisPreview.imageUrl && ( + hasil + )} +
+

+ Prediksi:{" "} + {diagnosisPreview.predictedDiseaseSlug} +

+

+ Confidence:{" "} + + {Math.round((diagnosisPreview.confidence ?? 0) * 100)}% + +

+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index eeb89ec..08b0a15 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,4 +1,5 @@ import react from '@vitejs/plugin-react'; +import tsconfigPaths from 'vite-tsconfig-paths'; import path from 'node:path'; import { defineConfig, loadEnv } from 'vite'; @@ -7,7 +8,7 @@ export default defineConfig(({ mode }) => { const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000'; return { - plugins: [react()], + plugins: [react(), tsconfigPaths()], server: { proxy: { '/api': apiProxyTarget, diff --git a/bun.lock b/bun.lock index 00ebf72..62b5cc2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "devDependencies": { @@ -50,6 +49,7 @@ "postcss": "^8.5.15", "typescript": "^6.0.3", "vite": "^8.0.14", + "vite-tsconfig-paths": "6.1.1", }, }, "packages/shared": { @@ -297,6 +297,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -381,6 +383,8 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], @@ -393,6 +397,8 @@ "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],