Compare commits
5
Commits
v0.1.31
...
bd5aa81988
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd5aa81988 | ||
|
|
7877892f9b | ||
|
|
f8f36bcdb8 | ||
|
|
d1c014d9b3 | ||
|
|
1db8eee8ea |
@@ -92,7 +92,7 @@ Proyek ini menggabungkan **3 dataset** dari sumber berbeda untuk menghasilkan da
|
|||||||
### Dataset 1 — Kaggle (Corn Leaf Disease - Indonesia)
|
### Dataset 1 — Kaggle (Corn Leaf Disease - Indonesia)
|
||||||
> 🔗 https://www.kaggle.com/datasets/ndisan/corn-leaf-disease
|
> 🔗 https://www.kaggle.com/datasets/ndisan/corn-leaf-disease
|
||||||
|
|
||||||
Berisi gambar penyakit daun jagung dengan label dalam Bahasa Indonesia. Dataset ini memiliki **4 folder**, namun label **"Karat Daun" tidak digunakan** karena gambar di dalamnya tidak merepresentasikan penyakit karat yang sebenarnya.
|
Dataset ini berisi 4.000 citra RGB daun jagung yang terbagi ke dalam empat kelas, yaitu daun sehat, hawar daun, bercak daun, dan karat daun. Data dikumpulkan dari lahan jagung di Kabupaten Sampang menggunakan kamera ponsel 16 MP dengan teknik pengambilan gambar yang terkontrol untuk mendukung proses klasifikasi. Pelabelan dan validasi data dilakukan oleh pihak Dinas Pertanian dan POPT Kabupaten Sampang guna menjamin kualitas serta keakuratan dataset.
|
||||||
|
|
||||||
| Folder di Dataset 1 | Tindakan |
|
| Folder di Dataset 1 | Tindakan |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -104,6 +104,7 @@ Berisi gambar penyakit daun jagung dengan label dalam Bahasa Indonesia. Dataset
|
|||||||
### Dataset 2 — Kaggle (Corn or Maize Leaf Disease)
|
### Dataset 2 — Kaggle (Corn or Maize Leaf Disease)
|
||||||
> 🔗 https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset
|
> 🔗 https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset
|
||||||
|
|
||||||
|
Dataset Corn or Maize Leaf Disease Dataset berisi 4.188 citra RGB daun jagung yang terbagi ke dalam empat kelas, yaitu Common Rust, Gray Leaf Spot, Blight, dan Healthy. Dataset ini merupakan hasil penggabungan PlantVillage dan PlantDoc, sehingga cocok digunakan untuk penelitian klasifikasi penyakit daun jagung menggunakan metode Machine Learning maupun Deep Learning.
|
||||||
Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah variasi gambar Daun Sehat.
|
Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah variasi gambar Daun Sehat.
|
||||||
|
|
||||||
| Folder di Dataset 2 | Dipetakan ke Label |
|
| Folder di Dataset 2 | Dipetakan ke Label |
|
||||||
@@ -116,7 +117,7 @@ Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah var
|
|||||||
### Dataset 3 — SciDB (China Agricultural Dataset)
|
### Dataset 3 — SciDB (China Agricultural Dataset)
|
||||||
> 🔗 https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3
|
> 🔗 https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3
|
||||||
|
|
||||||
Dataset dengan label berbahasa Mandarin. Digunakan untuk **menambah variasi data** pada tiga kelas utama. Pemetaan label dilakukan menggunakan file `desc.json` yang disertakan dalam dataset.
|
Dataset dengan label berbahasa Mandarin. Digunakan untuk **menambah variasi data** pada tiga kelas utama. Pemetaan label dilakukan menggunakan file `desc.json` yang disertakan dalam dataset. Dataset ini terdiri dari 1.653 pasangan data gambar dan deskripsi teks penyakit daun tanaman. Data gambar dikumpulkan dari berbagai sumber terbuka dan sumber internal, mencakup sembilan jenis penyakit daun. Sementara itu, data teks dibuat melalui anotasi manual berdasarkan literatur dan sumber ilmiah, yang memuat informasi mengenai jenis penyakit, ciri patologis, serta tingkat keparahannya.
|
||||||
|
|
||||||
| Label Mandarin | Dipetakan ke Label |
|
| Label Mandarin | Dipetakan ke Label |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import onnxruntime as ort
|
|||||||
import tensorflow as tf
|
import tensorflow as tf
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
# Definisi label kelas sesuai urutan output model klasifikasi
|
||||||
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||||
|
|
||||||
|
# Kelas eksepsi kustom untuk menangani ketidaksesuaian akurasi prediksi
|
||||||
class ParityError(RuntimeError):
|
class ParityError(RuntimeError):
|
||||||
"""Raised when Keras and ONNX predictions do not match."""
|
"""Raised when Keras and ONNX predictions do not match."""
|
||||||
pass
|
pass
|
||||||
@@ -33,16 +33,19 @@ def preprocess_image(image_path, input_size):
|
|||||||
Raises:
|
Raises:
|
||||||
ParityError: If image cannot be loaded or processed.
|
ParityError: If image cannot be loaded or processed.
|
||||||
"""
|
"""
|
||||||
|
# Penanganan error secara aman saat memuat gambar ke format RGB
|
||||||
try:
|
try:
|
||||||
img = Image.open(image_path).convert("RGB")
|
img = Image.open(image_path).convert("RGB")
|
||||||
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
||||||
raise ParityError(f"Failed to load image {image_path}: {e}")
|
raise ParityError(f"Failed to load image {image_path}: {e}")
|
||||||
|
|
||||||
|
# Penyesuaian resolusi gambar menggunakan metode interpolasi Bilinear
|
||||||
try:
|
try:
|
||||||
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ParityError(f"Failed to resize image {image_path}: {e}")
|
raise ParityError(f"Failed to resize image {image_path}: {e}")
|
||||||
|
|
||||||
|
# Konversi ke matriks float32 dan penambahan dimensi batch (1, H, W, C)
|
||||||
img_array = np.array(img, dtype=np.float32)
|
img_array = np.array(img, dtype=np.float32)
|
||||||
img_batch = np.expand_dims(img_array, axis=0)
|
img_batch = np.expand_dims(img_array, axis=0)
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ def predict_keras(model, image_batch):
|
|||||||
Returns:
|
Returns:
|
||||||
Predictions array (1, num_classes).
|
Predictions array (1, num_classes).
|
||||||
"""
|
"""
|
||||||
|
# Eksekusi inferensi pada model TensorFlow/Keras tanpa log proses
|
||||||
predictions = model.predict(image_batch, verbose=0)
|
predictions = model.predict(image_batch, verbose=0)
|
||||||
return predictions
|
return predictions
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ def predict_onnx(session, image_batch):
|
|||||||
Returns:
|
Returns:
|
||||||
Predictions array (1, num_classes).
|
Predictions array (1, num_classes).
|
||||||
"""
|
"""
|
||||||
|
# Eksekusi inferensi secara dinamis pada model ONNX menggunakan sesi runtime
|
||||||
input_name = session.get_inputs()[0].name
|
input_name = session.get_inputs()[0].name
|
||||||
predictions = session.run(None, {input_name: image_batch})
|
predictions = session.run(None, {input_name: image_batch})
|
||||||
return predictions[0]
|
return predictions[0]
|
||||||
@@ -94,14 +99,18 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
Raises:
|
Raises:
|
||||||
ParityError: If predictions do not match or image cannot be processed.
|
ParityError: If predictions do not match or image cannot be processed.
|
||||||
"""
|
"""
|
||||||
|
# Menyiapkan tensor gambar untuk pengujian
|
||||||
img_batch = preprocess_image(image_path, input_size)
|
img_batch = preprocess_image(image_path, input_size)
|
||||||
|
|
||||||
|
# Mengekstrak matriks probabilitas dari kedua format model
|
||||||
keras_pred = predict_keras(keras_model, img_batch)
|
keras_pred = predict_keras(keras_model, img_batch)
|
||||||
onnx_pred = predict_onnx(onnx_session, img_batch)
|
onnx_pred = predict_onnx(onnx_session, img_batch)
|
||||||
|
|
||||||
|
# Mendapatkan indeks kelas dengan probabilitas tertinggi (Top-1)
|
||||||
keras_label_idx = np.argmax(keras_pred[0])
|
keras_label_idx = np.argmax(keras_pred[0])
|
||||||
onnx_label_idx = np.argmax(onnx_pred[0])
|
onnx_label_idx = np.argmax(onnx_pred[0])
|
||||||
|
|
||||||
|
# Validasi keselarasan keputusan klasifikasi utama
|
||||||
if keras_label_idx != onnx_label_idx:
|
if keras_label_idx != onnx_label_idx:
|
||||||
keras_label = LABELS[keras_label_idx]
|
keras_label = LABELS[keras_label_idx]
|
||||||
onnx_label = LABELS[onnx_label_idx]
|
onnx_label = LABELS[onnx_label_idx]
|
||||||
@@ -110,6 +119,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
f"Keras={keras_label}, ONNX={onnx_label}"
|
f"Keras={keras_label}, ONNX={onnx_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validasi selisih nilai desimal probabilitas menggunakan toleransi absolut
|
||||||
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
||||||
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
||||||
raise ParityError(
|
raise ParityError(
|
||||||
@@ -117,6 +127,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
f"max difference={max_diff:.6e} (atol={atol})"
|
f"max difference={max_diff:.6e} (atol={atol})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pencatatan log sistem jika kedua model presisi 100%
|
||||||
label = LABELS[keras_label_idx]
|
label = LABELS[keras_label_idx]
|
||||||
logging.info(f"PASS: {image_path} -> {label}")
|
logging.info(f"PASS: {image_path} -> {label}")
|
||||||
|
|
||||||
@@ -125,6 +136,7 @@ def main():
|
|||||||
"""Validate parity between Keras and ONNX models."""
|
"""Validate parity between Keras and ONNX models."""
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
# Inisialisasi parser argumen untuk antarmuka CLI (Command Line Interface)
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Validate parity between Keras and ONNX models"
|
description="Validate parity between Keras and ONNX models"
|
||||||
)
|
)
|
||||||
@@ -161,6 +173,7 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Pengecekan eksistensi berkas model sebelum memuat memori
|
||||||
if not args.keras_model.exists():
|
if not args.keras_model.exists():
|
||||||
msg = f"Keras model not found at {args.keras_model}"
|
msg = f"Keras model not found at {args.keras_model}"
|
||||||
logging.error(msg)
|
logging.error(msg)
|
||||||
@@ -171,15 +184,18 @@ def main():
|
|||||||
logging.error(msg)
|
logging.error(msg)
|
||||||
raise FileNotFoundError(msg)
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
# Memuat model Keras (tanpa kompilasi agar lebih hemat beban komputasi)
|
||||||
logging.info(f"Loading Keras model from {args.keras_model}...")
|
logging.info(f"Loading Keras model from {args.keras_model}...")
|
||||||
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
||||||
|
|
||||||
|
# Memuat sesi ONNX dengan penyedia eksekusi CPU murni
|
||||||
logging.info(f"Loading ONNX model from {args.onnx_model}...")
|
logging.info(f"Loading ONNX model from {args.onnx_model}...")
|
||||||
onnx_session = ort.InferenceSession(
|
onnx_session = ort.InferenceSession(
|
||||||
str(args.onnx_model),
|
str(args.onnx_model),
|
||||||
providers=["CPUExecutionProvider"],
|
providers=["CPUExecutionProvider"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Iterasi pengujian paritas (kesetaraan performa) untuk setiap gambar
|
||||||
logging.info(f"Validating {len(args.images)} image(s)...")
|
logging.info(f"Validating {len(args.images)} image(s)...")
|
||||||
for image_path in args.images:
|
for image_path in args.images:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
|
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
|
||||||
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
|
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
|
||||||
| Sumber Dataset | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
|
| Sumber Dataset 1 | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
|
||||||
|
| Sumber Dataset 2 | Kaggle — [Corn or Maize Leaf Disease Dataset](https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset) |
|
||||||
|
| Sumber Dataset 3 | scidb — [Dataset of Corn Leaf Diseases based on Manual Annotation and Contrast Generation Model](https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3) |
|
||||||
|
|
||||||
| Deployment | VPS dengan Docker, ONNX Runtime untuk inferensi real-time |
|
| Deployment | VPS dengan Docker, ONNX Runtime untuk inferensi real-time |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { FormEvent, useState, useCallback } from 'react';
|
import { FormEvent, useState, useCallback } from "react";
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import {
|
||||||
import { Input } from '@/components/ui/input';
|
Card,
|
||||||
import { Label } from '@/components/ui/label';
|
CardContent,
|
||||||
import { apiBaseUrl } from '@/lib/api-client';
|
CardDescription,
|
||||||
import { isTauri, openUrl } from '@/lib/tauri';
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { apiBaseUrl } from "@/lib/api-client";
|
||||||
|
import { isTauri, openUrl } from "@/lib/tauri";
|
||||||
|
|
||||||
type AuthFormProps = {
|
type AuthFormProps = {
|
||||||
mode: 'login' | 'register';
|
mode: "login" | "register";
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
googleOAuthEnabled: boolean;
|
googleOAuthEnabled: boolean;
|
||||||
onSubmit: (payload: { name?: string; email: string; password: string }) => Promise<unknown>;
|
onSubmit: (payload: {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
onFieldChange?: () => void;
|
onFieldChange?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit, onFieldChange }: AuthFormProps) {
|
export function AuthForm({
|
||||||
const [name, setName] = useState('');
|
mode,
|
||||||
const [email, setEmail] = useState('');
|
isSubmitting,
|
||||||
const [password, setPassword] = useState('');
|
error,
|
||||||
|
googleOAuthEnabled,
|
||||||
|
onSubmit,
|
||||||
|
onFieldChange,
|
||||||
|
}: AuthFormProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
@@ -29,7 +46,7 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
|
|||||||
|
|
||||||
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const platform = isTauri() ? 'tauri' : 'web';
|
const platform = isTauri() ? "tauri" : "web";
|
||||||
const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
|
const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
|
||||||
await openUrl(googleUrl);
|
await openUrl(googleUrl);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -37,59 +54,111 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
|
|||||||
return (
|
return (
|
||||||
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
|
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
|
||||||
<CardHeader className="text-center space-y-2">
|
<CardHeader className="text-center space-y-2">
|
||||||
<CardTitle className="text-2xl font-bold text-emerald-900">{mode === 'login' ? 'Masuk Akun ZeaVis Edu' : 'Buat akun ZeaVis Edu'}</CardTitle>
|
<CardTitle className="text-2xl font-bold text-emerald-900">
|
||||||
|
{mode === "login" ? "Masuk Akun ZeaVis Edu" : "Buat akun ZeaVis Edu"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-emerald-800/80">
|
<CardDescription className="text-sm text-emerald-800/80">
|
||||||
{mode === 'login'
|
{mode === "login"
|
||||||
? 'Masuk untuk menyimpan diagnosis dan mengikuti review pakar.'
|
? "Masuk untuk menyimpan diagnosis dan mengikuti review pakar."
|
||||||
: 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'}
|
: "Daftar untuk menyimpan diagnosis dan mengikuti review pakar."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
{mode === 'register' && (
|
{mode === "register" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Nama</Label>
|
<Label htmlFor="name">Nama</Label>
|
||||||
<Input id="name" value={name} onChange={(event) => {
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Masukkan nama Anda"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
setName(event.target.value);
|
setName(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" type="email" value={email} onChange={(event) => {
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Masukkan email Anda"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => {
|
||||||
setEmail(event.target.value);
|
setEmail(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input id="password" type={showPassword ? "text" : "password"} minLength={8} value={password} onChange={(event) => {
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Password minimal 8 karakter"
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => {
|
||||||
setPassword(event.target.value);
|
setPassword(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600" role="alert">{error}</p>}
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'}
|
{isSubmitting
|
||||||
|
? "Memproses..."
|
||||||
|
: mode === "login"
|
||||||
|
? "Masuk"
|
||||||
|
: "Daftar"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{googleOAuthEnabled && (
|
{googleOAuthEnabled && (
|
||||||
<Button className="mt-3 w-full flex items-center justify-center gap-2.5" variant="outline" onClick={handleGoogleLogin} type="button">
|
<Button
|
||||||
|
className="mt-3 w-full flex items-center justify-center gap-2.5"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
<path
|
||||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
fill="#4285F4"
|
||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
<path fill="none" d="M1 1h22v22H1z" />
|
<path fill="none" d="M1 1h22v22H1z" />
|
||||||
</svg>
|
</svg>
|
||||||
Masuk dengan Google
|
Masuk dengan Google
|
||||||
|
|||||||
Reference in New Issue
Block a user