Compare commits

...
12 Commits
Author SHA1 Message Date
Taufik Pathurrohman bd5aa81988 Update validate_onnx_parity.py
Build and Deploy / build (map[dockerfile:apps/api/Dockerfile name:api]) (push) Failing after 3m54s
Build and Deploy / build (map[dockerfile:apps/ml-service/Dockerfile name:ml]) (push) Failing after 41s
Build and Deploy / build (map[dockerfile:apps/web/Dockerfile name:web]) (push) Failing after 21s
Build and Deploy / deploy (push) Skipped
update code & comment
2026-06-18 21:31:03 +07:00
Luhung Pandyaska Suyi 7877892f9b Add multiple dataset sources to README 2026-06-18 21:27:14 +07:00
Taufik Pathurrohman f8f36bcdb8 Update README.md
Penambahan penjelasan lengkap mengenai Sumber dataset
2026-06-18 21:15:40 +07:00
Selly Supriyatin d1c014d9b3 Merge pull request #45 from ATLAS-PJK-GM007/selly/frontend
feat(auth): add placeholders and helper text to improve form UX
2026-06-16 22:55:34 +07:00
seriouselly 1db8eee8ea feat(auth): add placeholders and helper text to improve form UX
- Add descriptive placeholders to name, email, and password input fields.
- Display a helper text in register mode to guide users on password length requirements.
- Adjust password `minLength` validation in the frontend.
2026-06-16 22:46:06 +07:00
Selly Supriyatin 74e17386ee Merge pull request #44 from ATLAS-PJK-GM007/selly/frontend
feat(ui): add green leaf favicon using SVG data URI
2026-06-16 22:00:36 +07:00
seriouselly a9ef795c90 feat(ui): add green leaf favicon using SVG data URI
- Update index.html to include a Lucide leaf icon as the tab favicon.
- Use URL-encoded SVG data URI to apply the ZeaVis Edu green brand color (#22C55E) directly without needing external image files.
2026-06-16 21:59:43 +07:00
Selly Supriyatin 153abf4352 Merge pull request #43 from ATLAS-PJK-GM007/selly/frontend
feat(scan): implement drag and drop functionality for image upload
2026-06-16 19:20:20 +07:00
seriouselly da5c7c1cfa feat(scan): implement drag and drop functionality for image upload
- Add `onDragOver`, `onDragLeave`, and `onDrop` event handlers to capture dragged files.
- Introduce `isDragging` state to provide visual UI feedback when a file is hovered over the drop zone.
- Wire the dropped file data to the existing `handleFile` processing logic.
2026-06-16 19:12:44 +07:00
MythEclipseandClaude 58d4cc0164 chore: update telemetry submodule and fix Makefile comment
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:40:23 +07:00
MythEclipseandClaude a3105200e3 docs(claude): document Android Google OAuth fixes and design rules
Record the three bugs found during Android OAuth debugging,
their root causes, and the fix patterns to follow for future
Tauri deep-link handlers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:37:58 +07:00
MythEclipseandClaude c75cba214e fix(android): resolve Google OAuth login flow on Tauri Android
Three interrelated fixes for the Android Google sign-in flow:

1. API base URL mismatch (404 error):
   - auth-form.tsx used 'window.location.origin || VITE_API_BASE_URL',
     which fell back to 'http://tauri.localhost' in Android WebView
     instead of the actual API server.
   - Fix: import shared 'apiBaseUrl' from api-client.ts (already had
     the correct fallback: 'https://zeavisedu.asepharyana.my.id').
   - Added .env with VITE_API_BASE_URL for dev mode resilience.

2. Deep-link caused IPC callback errors:
   - 'processDeepLinkUrl()' used window.location.href = target,
     triggering a full page reload that orphaned pending Tauri IPC
     promises, causing 'Cannot read properties of undefined (reading
     'runCallback')' errors.
   - Cold-start: keep get_current but use window.location.href (safe
     at boot — no SPA state to lose).
   - Warm-start: use sessionStorage + custom DOM event + React Router
     navigate() via new <DeepLinkRouterHandler /> layout route,
     avoiding any page reload.

3. SPA navigation did not trigger OAuth token handler:
   - LoginPage's useEffect for ?token=xxx depended only on
     [setUser, queryClient, navigate] — location.search changes
     from a SPA navigate() call were ignored.
   - Fix: added location.search and location to deps.
   - Added visibilitychange + focus listeners so returning from the
     Google auth browser always re-checks URL params.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:36:31 +07:00
13 changed files with 436 additions and 177 deletions
+45 -5
View File
@@ -142,19 +142,31 @@ make telemetry-down
## Telemetry architecture ## Telemetry architecture
The repository includes a full Prometheus → ClickHouse metric pipeline as a git submodule at `telemetry/`. Each ZeaVis Edu service exposes a `GET /metrics` endpoint: The telemetry stack lives as a git submodule at `telemetry/` (repo `MythEclipse/Telemetry`). Architecture:
| Layer | Service | Role |
|-------|---------|------|
| Collector & Storage | **Prometheus** | Metric scraping & TSDB storage |
| System metrics | **Node Exporter** | CPU, memory, disk per host |
| Query | **Query Proxy** | REST API over Prometheus HTTP API |
| Visualization | **Grafana** | OSS dashboard & PromQL |
| Entry point | **Telemetry UI** | nginx + Vue 3 SPA |
Data flow: Node Exporter → Prometheus scrape (every 15s) → Grafana (PromQL) / Query Proxy (/api/metrics).
Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`. - **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`. - **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`. - **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
All three share the `zeavis_` metric prefix and are scraped by the Telemetry Prometheus instance via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`). All three share the `zeavis_` metric prefix and are scraped by Prometheus via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file `telemetry/prometheus/targets/zeavis-edu.json` has `__CHANGE_ME__` placeholders — before deploying, replace with the actual Tailscale IPs of the app VPS. **IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file has `__CHANGE_ME__` placeholders — replace with actual Tailscale IPs before deploying.
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). The Docker Compose files in `telemetry/deploy/` define 6 services (Prometheus, Metric Ingester, Vector, ClickHouse, Query Proxy, Telemetry UI). The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). Docker Compose defines 5 services (Prometheus, Node Exporter, Query Proxy, Grafana, Telemetry UI).
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode — it includes the `docker-compose.telemetry.yml` override. For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode.
## Fullstack application architecture ## Fullstack application architecture
@@ -191,6 +203,34 @@ The following files/directories are generated or externally supplied during the
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive. - `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports. - `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
## Android Google OAuth (Tauri) — known issues & fixes
The Tauri Android app uses Chrome's `intent://` protocol to bounce back from Google's OAuth browser page. Three bugs were found and fixed in commit `c75cba2`:
### 1. API base URL falls back to `http://tauri.localhost`
**Symptom:** Google login button navigates to `http://tauri.localhost/api/v1/auth/google` → 404.
**Root cause:** `auth-form.tsx` used `import.meta.env.VITE_API_BASE_URL || window.location.origin`. In Android WebView the origin is `http://tauri.localhost` (Vite dev server), not the API server.
**Fix:** Import shared `apiBaseUrl` from `api-client.ts` which already has the correct fallback: `import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'`.
### 2. `deep-link:get_current` IPC promise orphaned on SPA navigation
**Symptom:** `Cannot read properties of undefined (reading 'runCallback')` floods log; OAuth never completes.
**Root cause:** `plugin:deep-link|get_current` returns a JS promise that stays pending. When React Router's `navigate()` changes the URL (SPA, no page reload), the Tauri IPC bridge invalidates the pending callback reference — but the promise never resolves or rejects cleanly, so `.runCallback` is undefined.
**Fix (cold start):** `get_current` resolves via `window.location.href = target` (full reload). At boot there is no SPA state to lose, so a hard redirect is safe.
**Fix (warm start / `deep-link://new-url` event):** Store target in `sessionStorage` + dispatch a custom DOM event. A `<DeepLinkRouterHandler>` root layout route listens for the event and calls React Router's `navigate()`, keeping SPA state alive.
### 3. LoginPage `?token=` effect does not re-run on SPA navigation
**Symptom:** App navigates to `/login?token=xxx` but stays on the login form.
**Root cause:** The `useEffect` that reads `?token` and exchanges it for a session only listed `[setUser, queryClient, navigate]` as deps. React Router SPA navigation changes `location.search` but does not remount the component — so the effect never re-runs.
**Fix:** Added `location.search` to the effect's dependency array. Also added `visibilitychange` and `focus` event listeners as a backup — when the user returns from the Google OAuth browser tab, the app picks up the token from the URL even if the deep-link plugin's event was missed.
### Design rule for Tauri deep-link handlers
- **Cold start** (app was not running) → safe to use `window.location.href` (full reload). The React app has just booted, no state to lose.
- **Warm start** (app was running, user returns from system browser) → use React Router `navigate()` via custom events / sessionStorage. Do NOT use `window.location.href` — it triggers a full page unload which orphan Tauri IPC promises.
## Notes for future changes ## Notes for future changes
- Keep README command examples and this file in sync when changing the ML pipeline. - Keep README command examples and this file in sync when changing the ML pipeline.
+3 -2
View File
@@ -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 |
|---|---| |---|---|
+18 -2
View File
@@ -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:
+1 -1
View File
@@ -2,7 +2,7 @@
# ZeaVis Edu — Root Makefile # ZeaVis Edu — Root Makefile
# #
# Orchestrates the application stack (web, api, ml) and the telemetry # Orchestrates the application stack (web, api, ml) and the telemetry
# metric pipeline (Prometheus → Ingester → Vector → ClickHouse). # metric pipeline (Prometheus → Grafana).
# #
# Telemetry commands operate on the submodule at telemetry/. # Telemetry commands operate on the submodule at telemetry/.
# ============================================================================= # =============================================================================
+4 -1
View File
@@ -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 |
--- ---
+5
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ZeaVis Edu</title> <title>ZeaVis Edu</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 22l10-10'/%3E%3C/svg%3E"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+133 -98
View File
@@ -3,6 +3,8 @@ import {
createBrowserRouter, createBrowserRouter,
RouterProvider, RouterProvider,
Navigate, Navigate,
Outlet,
useNavigate,
} 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";
@@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-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";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useAuthStore } from "@/store/auth-store"; import { useAuthStore } from "@/store/auth-store";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { setupDeepLinkHandler } from "@/lib/tauri"; import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
function LogoutProses() { function LogoutProses() {
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
@@ -32,7 +34,7 @@ function LogoutProses() {
}).catch((error) => { }).catch((error) => {
console.error("Oops, gagal logout dari server:", error); console.error("Oops, gagal logout dari server:", error);
setUser(null); setUser(null);
}); });
}, [setUser]); }, [setUser]);
@@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/login" replace /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ {
path: "/dashboard", element: <DeepLinkRouterHandler />,
element: ( children: [
<AuthGuard> { path: "/", element: <Navigate to="/login" replace /> },
<MainLayout> { path: "/login", element: <LoginPage /> },
<DashboardPage /> { path: "/register", element: <RegisterPage /> },
</MainLayout> {
</AuthGuard> path: "/dashboard",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/scan", <DashboardPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<ScanPage /> },
</MainLayout> {
</AuthGuard> path: "/scan",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/library", <ScanPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<LibraryPage /> },
</MainLayout> {
</AuthGuard> path: "/library",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/diagnoses", <LibraryPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiagnosesPage /> },
</MainLayout> {
</AuthGuard> path: "/diagnoses",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/diagnoses/:id", <DiagnosesPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiagnosisDetailPage /> },
</MainLayout> {
</AuthGuard> path: "/diagnoses/:id",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/expert/reviews", <DiagnosisDetailPage />
element: ( </MainLayout>
<AuthGuard requireExpert={true}> </AuthGuard>
<MainLayout> ),
<ExpertReviewsPage /> },
</MainLayout> {
</AuthGuard> path: "/expert/reviews",
), element: (
}, <AuthGuard requireExpert={true}>
{ <MainLayout>
path: "/catalog", <ExpertReviewsPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<CatalogPage /> },
</MainLayout> {
</AuthGuard> path: "/catalog",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/catalog/:slug", <CatalogPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiseaseDetailPage /> },
</MainLayout> {
</AuthGuard> path: "/catalog/:slug",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/logout", <DiseaseDetailPage />
element: ( </MainLayout>
<LogoutProses /> </AuthGuard>
), ),
}, },
{ {
path: "/telemetry", path: "/logout",
element: ( element: <LogoutProses />,
<MainLayout> },
<TelemetryPage /> {
</MainLayout> path: "/telemetry",
), element: (
<MainLayout>
<TelemetryPage />
</MainLayout>
),
},
],
}, },
]); ]);
/**
* Listens for deep-link custom events and routes via React Router's navigate(),
* avoiding full page reloads that break the Tauri IPC bridge.
*/
function DeepLinkRouterHandler() {
const navigate = useNavigate();
const handled = useRef(new Set<string>());
useEffect(() => {
// Check for cold-start pending deep link
const pending = consumeDeepLinkTarget();
if (pending && !handled.current.has(pending)) {
handled.current.add(pending);
navigate(pending, { replace: true });
}
// Listen for warm-start deep links
const handler = (e: CustomEvent<string>) => {
const target = e.detail;
if (handled.current.has(target)) return;
handled.current.add(target);
navigate(target, { replace: true });
};
window.addEventListener('zeavis:deeplink', handler as EventListener);
return () => window.removeEventListener('zeavis:deeplink', handler as EventListener);
}, [navigate]);
return <Outlet />;
}
function PageViewTracker() { function PageViewTracker() {
const location = window.location; const location = window.location;
useEffect(() => { useEffect(() => {
+110 -43
View File
@@ -1,24 +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 { isTauri, openUrl } from '@/lib/tauri'; CardDescription,
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>) {
@@ -28,70 +46,119 @@ 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";
// Use API base URL, not window.location.origin — on Tauri Android const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
// the origin is http://tauri.localhost which is not the API server.
const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`;
await openUrl(googleUrl); await openUrl(googleUrl);
}, []); }, []);
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
setName(event.target.value); id="name"
onFieldChange?.(); placeholder="Masukkan nama Anda"
}} required /> value={name}
onChange={(event) => {
setName(event.target.value);
onFieldChange?.();
}}
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
setEmail(event.target.value); id="email"
onFieldChange?.(); type="email"
}} required /> placeholder="Masukkan email Anda"
value={email}
onChange={(event) => {
setEmail(event.target.value);
onFieldChange?.();
}}
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
setPassword(event.target.value); id="password"
onFieldChange?.(); type={showPassword ? "text" : "password"}
}} required /> placeholder="Password minimal 8 karakter"
minLength={8}
value={password}
onChange={(event) => {
setPassword(event.target.value);
onFieldChange?.();
}}
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
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
} from '@zeavis/shared'; } from '@zeavis/shared';
import { recordApiCall } from './telemetry'; import { recordApiCall } from './telemetry';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
const AUTH_TOKEN_KEY = 'zeavis_auth_token'; const AUTH_TOKEN_KEY = 'zeavis_auth_token';
+55 -20
View File
@@ -1,6 +1,13 @@
/** /**
* Lightweight Tauri environment detection and utilities. * Lightweight Tauri environment detection and utilities.
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android. * Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
*
* Deep-link flow (no full page reloads uses React Router navigate()):
* 1. Tauri deep-link plugin receives URL via intent/custom-scheme.
* 2. processDeepLinkUrl stores the target path in sessionStorage +
* dispatches a custom DOM event.
* 3. <DeepLinkRouterHandler /> inside <RouterProvider> picks it up and
* calls navigate(), keeping the React app alive.
*/ */
let _isTauri: boolean | null = null; let _isTauri: boolean | null = null;
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise
export async function openUrl(url: string): Promise<void> { export async function openUrl(url: string): Promise<void> {
if (!isTauri()) { if (!isTauri()) {
// Not in Tauri — normal browser navigation
window.location.href = url; window.location.href = url;
return; return;
} }
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise<void> {
await invoke('plugin:opener|open_url', { url }); await invoke('plugin:opener|open_url', { url });
} catch (err) { } catch (err) {
console.error('Tauri openUrl failed, trying fallback:', err); console.error('Tauri openUrl failed, trying fallback:', err);
// Fallback: navigate the WebView (Google will block, but best effort)
window.location.href = url; window.location.href = url;
} }
} }
function processDeepLinkUrl(url: string): void { // ── Deep link handling (no full reload) ─────────────────────────────────
try {
const u = new URL(url);
const target = u.pathname + u.search + u.hash;
if (target && target !== '/') {
window.location.href = target;
return;
}
} catch { /* fall through */ }
// Fallback: handle both :// and :/ custom schemes const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/); const DEEP_LINK_EVENT = 'zeavis:deeplink';
if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
if (match?.[1]) window.location.href = match[1]; /** Store a target path for the React Router to pick up without page reload. */
function storeDeepLinkTarget(target: string): void {
try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ }
} }
/** Read and clear the stored deep link target. */
export function consumeDeepLinkTarget(): string | null {
try {
const v = sessionStorage.getItem(DEEP_LINK_KEY);
if (v) sessionStorage.removeItem(DEEP_LINK_KEY);
return v;
} catch { return null; }
}
export async function setupDeepLinkHandler(): Promise<void> { export async function setupDeepLinkHandler(): Promise<void> {
if (!isTauri()) return; if (!isTauri()) return;
try { try {
const invoke = tauriInvoke(); const invoke = tauriInvoke();
// Cold-start: app just opened via intent:// or custom scheme // Cold-start: app opened via intent:// (e.g. from Google OAuth callback).
// Use window.location.href for this (full page reload) — at cold start there
// is no SPA state to lose, so redirecting via location.href avoids orphaned
// IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')".
invoke('plugin:deep-link|get_current') invoke('plugin:deep-link|get_current')
.then((urls: any) => { .then((urls: any) => {
if (urls?.[0]) processDeepLinkUrl(urls[0]); if (!urls?.[0]) return;
const target = extractDeepLinkTarget(urls[0]);
if (target && target !== window.location.pathname + window.location.search + window.location.hash) {
window.location.href = target;
}
}) })
.catch(() => { /* plugin may not be registered yet */ }); .catch(() => {});
// Warm-start: listen for new URLs while app is running // Warm-start: listen for new URLs (already running app).
// Use React Router navigate() here since we have SPA state.
const { listen } = await import('@tauri-apps/api/event'); const { listen } = await import('@tauri-apps/api/event');
listen('deep-link://new-url', (event: any) => { listen('deep-link://new-url', (event: any) => {
const urls = event.payload as string[]; const urls = event.payload as string[];
for (const url of urls) processDeepLinkUrl(url); for (const url of urls) {
const target = extractDeepLinkTarget(url);
if (target) {
storeDeepLinkTarget(target);
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
}
}
}); });
} catch (err) { } catch (err) {
console.error('Tauri deep-link setup failed:', err); console.error('Tauri deep-link setup failed:', err);
} }
} }
/** Extract path+query+hash from a deep-link URL. */
function extractDeepLinkTarget(url: string): string {
try {
const u = new URL(url);
return u.pathname + u.search + u.hash;
} catch {
let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
return m?.[1] ?? '';
}
}
+41 -2
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate, useLocation } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
import { apiClient, setAuthToken } from "@/lib/api-client"; import { apiClient, setAuthToken } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store"; import { useAuthStore } from "@/store/auth-store";
import { isTauri } from "@/lib/tauri";
function getUrlParam(name: string): string | null { function getUrlParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name); return new URLSearchParams(window.location.search).get(name);
@@ -13,11 +14,13 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const location = useLocation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const oauthTokenConsumed = useRef(false); const oauthTokenConsumed = useRef(false);
const [oauthProcessing, setOauthProcessing] = useState(false); const [oauthProcessing, setOauthProcessing] = useState(false);
// Handle OAuth callback: the API redirects to /login?token=<session_token> // Handle OAuth callback: the API redirects to /login?token=<session_token>
// Must re-run on location.search change (SPA navigates to /login?token=xxx)
useEffect(() => { useEffect(() => {
const token = getUrlParam("token"); const token = getUrlParam("token");
if (!token || oauthTokenConsumed.current) return; if (!token || oauthTokenConsumed.current) return;
@@ -39,7 +42,36 @@ export function LoginPage() {
setOauthProcessing(false); setOauthProcessing(false);
setError(err instanceof Error ? err.message : "Google login gagal"); setError(err instanceof Error ? err.message : "Google login gagal");
}); });
}, [setUser, queryClient, navigate]); }, [setUser, queryClient, navigate, location.search]);
// Backup: when app returns from background (e.g. after Google OAuth browser)
// re-check URL params — the deep-link event may have been missed.
useEffect(() => {
if (!isTauri()) return;
if (getUrlParam("token") || oauthTokenConsumed.current) return;
const onVisibility = () => {
if (document.visibilityState !== "visible") return;
const token = getUrlParam("token");
if (token && !oauthTokenConsumed.current) {
setOauthProcessing(true);
}
};
const onFocus = () => {
const token = getUrlParam("token");
if (token && !oauthTokenConsumed.current) {
setOauthProcessing(true);
}
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("focus", onFocus);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("focus", onFocus);
};
}, []);
// Show OAuth error from query param // Show OAuth error from query param
const oauthError = getUrlParam("error"); const oauthError = getUrlParam("error");
@@ -48,6 +80,13 @@ export function LoginPage() {
queryFn: () => apiClient.getMe(), queryFn: () => apiClient.getMe(),
}); });
// Already authenticated — redirect to dashboard
useEffect(() => {
if (!meQuery.isLoading && meQuery.data?.user) {
navigate("/dashboard", { replace: true });
}
}, [meQuery.data, meQuery.isLoading, navigate]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: apiClient.login, mutationFn: apiClient.login,
onSuccess: (response) => { onSuccess: (response) => {
+19 -1
View File
@@ -33,6 +33,7 @@ export function ScanPage() {
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isDragging, setIsDragging] = useState(false);
// Camera mode state // Camera mode state
const [useCamera, setUseCamera] = useState(false); const [useCamera, setUseCamera] = useState(false);
@@ -164,8 +165,25 @@ export function ScanPage() {
) : ( ) : (
<> <>
<div <div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer" className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
isDragging
? "border-blue-500 bg-blue-100"
: "border-green-300"
}`}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFile(droppedFile);
}
}}
> >
<Upload <Upload
className="mx-auto text-green-500 mb-3" className="mx-auto text-green-500 mb-3"