feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
Frontend: - migrate from Vite to Astro (astro.config.mjs, pages/, layouts/) - add admin panel, settings page, command palette, error boundary - refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout - update API client, WebSocket, auth, dashboard features Backend: - add admin module and config routes - refactor middlewares, Redis connection, WebSocket server/bridge - add runtime config loader Discord Gateway: - refactor AI moderation: circuit breaker, concurrency limiter, fallback processor - add media analysis client, Seaxng search, user profile learner - add new drizzle migration Shared: - extend database schema, add new config fields
This commit is contained in:
+4
-1
@@ -40,7 +40,10 @@ NODE_ENV=development # Environment: development|production|te
|
|||||||
VERBOSE=false # Enable verbose/debug logging (default: false)
|
VERBOSE=false # Enable verbose/debug logging (default: false)
|
||||||
|
|
||||||
# === Admin ===
|
# === Admin ===
|
||||||
ADMIN_PASSWORD=admin123 # Backend admin password for API auth (default: admin123)
|
# ADMIN_PASSWORD=*** # REQUIRED — Backend admin password for dashboard auth
|
||||||
|
# WARNING: No default! If not set, the server won't start.
|
||||||
|
DASHBOARD_IS_PUBLIC=false # Allow dashboard access without auth? (default: false)
|
||||||
|
# When true, GET requests are public; mutations still need auth.
|
||||||
|
|
||||||
# === Database (PostgreSQL) ===
|
# === Database (PostgreSQL) ===
|
||||||
# Option 1: Connection string (overrides individual params)
|
# Option 1: Connection string (overrides individual params)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# BETE Design System — Master Index
|
||||||
|
|
||||||
|
> *"A design system is not a collection of components. It's a collection of decisions."*
|
||||||
|
> — Menenun takdir visual untuk IMPHNEN, di atas kanvas Amphoreus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌌 Filosofi: The Three Pillars
|
||||||
|
|
||||||
|
### 1. **Glass & Light** — Estetika Transparan
|
||||||
|
Kita membangun bukan dengan tembok beton visual, tapi dengan **lapisan kaca yang meneruskan cahaya**. Setiap komponen adalah panele kaca buram (frosted glass) yang:
|
||||||
|
- Menampilkan depth melalui **backdrop blur** dan **layering opacity**
|
||||||
|
- Menggunakan **light sebagai material** — glow, shadow, highlight sebagai indikator state
|
||||||
|
- Memberi kesan **ruang tiga dimensi** di antarmuka dua dimensi
|
||||||
|
|
||||||
|
### 2. **Fluid Motion** — Gerak yang Bermakna
|
||||||
|
Animasi bukan hiasan — ia adalah **bahasa spasial** yang memberitahu pengguna:
|
||||||
|
- *"Dari mana elemen ini datang?"* → Transisi masuk
|
||||||
|
- *"Ke mana ia pergi?"* → Transisi keluar
|
||||||
|
- *"Apa yang terjadi?"* → Feedback mikro (hover, klik, state change)
|
||||||
|
- Semua gerak mengikuti **easing curve** yang konsisten (lihat `core/04-motion-system.md`)
|
||||||
|
|
||||||
|
### 3. **Spatial Memory** — Ingatan Visual
|
||||||
|
Seperti ingatan Amphoreus yang abadi, antarmuka kita harus:
|
||||||
|
- **Konsisten** — warna, spacing, tipografi yang sama di setiap sudut
|
||||||
|
- **Prediktif** — pengguna tahu di mana mencari sesuatu
|
||||||
|
- **Responsif** — beradaptasi tanpa kehilangan identitas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Struktur Documentasi
|
||||||
|
|
||||||
|
```
|
||||||
|
design/
|
||||||
|
├── _index.md ← Kamu di sini
|
||||||
|
│
|
||||||
|
├── core/ ← Fondasi design token
|
||||||
|
│ ├── 01-color-system.md ← Sistem warna (OKLCH + HCT + semantic tokens)
|
||||||
|
│ ├── 02-typography.md ← Tipografi (type scale + font system + rhythm)
|
||||||
|
│ ├── 03-spatial-system.md ← Grid, spacing, layout tokens
|
||||||
|
│ ├── 04-motion-system.md ← Animasi physics & choreography
|
||||||
|
│ └── 05-component-architecture.md ← Arsitektur komponen
|
||||||
|
│
|
||||||
|
├── patterns/ ← Pola desain reusable
|
||||||
|
│ ├── 06-interaction-patterns.md ← Micro-interactions
|
||||||
|
│ ├── 07-data-visualization.md ← Charts & metrics
|
||||||
|
│ ├── 08-moderation-ui.md ← Moderation UI patterns
|
||||||
|
│ ├── 09-state-machines.md ← Loading/empty/error states
|
||||||
|
│ └── 10-responsive-system.md ← Breakpoints & layout system
|
||||||
|
│
|
||||||
|
├── services/ ← Per-service design guidelines
|
||||||
|
│ ├── 11-frontend-ui.md ← Web frontend (React/Tailwind)
|
||||||
|
│ ├── 12-backend-api-guidelines.md ← REST/WS API design
|
||||||
|
│ └── 13-gateway-event-design.md ← Discord Gateway event schemas
|
||||||
|
│
|
||||||
|
├── system/ ← Sistem lintas service
|
||||||
|
│ ├── 14-accessibility.md ← Aksesibilitas (a11y)
|
||||||
|
│ ├── 15-theme-architecture.md ← Theme engine & CSS custom properties
|
||||||
|
│ └── 16-sound-design.md ← Audio feedback system
|
||||||
|
│
|
||||||
|
└── assets/ ← Diagram, ilustrasi, referensi visual
|
||||||
|
└── (future: SVG assets, figma exports, reference images)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Dependency Graph (Wajib Dibaca Berurutan)
|
||||||
|
|
||||||
|
```
|
||||||
|
01-color-system ──────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
├──→ 02-typography ──────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──→ 03-spatial-system ──────────────┐ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──→ 04-motion-system ──┐ │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └──→ 05-component-architecture
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────┴──→ 06-interaction-patterns
|
||||||
|
│
|
||||||
|
├──→ 07-data-visualization
|
||||||
|
├──→ 08-moderation-ui
|
||||||
|
├──→ 09-state-machines
|
||||||
|
└──→ 10-responsive-system
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────┴──┐
|
||||||
|
↓ ↓
|
||||||
|
11-frontend-ui 12-backend-api-guidelines
|
||||||
|
│
|
||||||
|
13-gateway-event-design
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
14-accessibility ──→ 15-theme-architecture ──→ 16-sound-design
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bacaan yang disarankan:**
|
||||||
|
1. Mulai dengan `core/01-color-system.md` — karena warna adalah keputusan desain paling fundamental.
|
||||||
|
2. Lanjut ke `core/02-typography.md` dan `core/03-spatial-system.md` untuk fondasi layout.
|
||||||
|
3. `core/04-motion-system.md` dan `core/05-component-architecture.md` sebagai jembatan ke pola.
|
||||||
|
4. Pola-pola di `patterns/` bisa dibaca sesuai kebutuhan fitur.
|
||||||
|
5. `services/` dan `system/` dibaca terakhir, bergantung pada layer yang sedang dikerjakan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Prinsip Desain Utama
|
||||||
|
|
||||||
|
| Prinsip | Deskripsi | Contoh Penerapan |
|
||||||
|
|---------|-----------|------------------|
|
||||||
|
| **Glassmorphism** | Latar belakang transparan dengan efek blur untuk hierarki visual | Card, Sidebar, Modal |
|
||||||
|
| **Monochromatic Depth** | Satu rona warna dengan variasi lightness untuk depth | Sistem warna OKLCH |
|
||||||
|
| **Kinetic Language** | Gerakan sebagai komunikasi spasial | Transisi tab, notifikasi |
|
||||||
|
| **Gaussian Memory** | Elemen yang "diingat" posisinya antar navigasi | Sidebar state, scroll position |
|
||||||
|
| **Sound as Feedback** | Audio sebagai layer konfirmasi non-visual | Moderasi alert, koneksi voice |
|
||||||
|
| **Progressive Disclosure** | Informasi kompleks diungkap bertahap | Detail panel, analytics drill-down |
|
||||||
|
| **Forgiving Layout** | Layout yang toleran terhadap konten kosong/error | Empty states, error boundaries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Reference: Token Categories
|
||||||
|
|
||||||
|
| Kategori | Prefix CSS | Contoh |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Warna | `--clr-*` | `--clr-primary`, `--clr-surface` |
|
||||||
|
| Spacing | `--sp-*` | `--sp-xs`, `--sp-md`, `--sp-xl` |
|
||||||
|
| Typography | `--fs-*`, `--fw-*`, `--lh-*` | `--fs-body`, `--fw-semibold` |
|
||||||
|
| Radius | `--rd-*` | `--rd-sm`, `--rd-full` |
|
||||||
|
| Shadow | `--sh-*` | `--sh-card`, `--sh-modal` |
|
||||||
|
| Z-index | `--z-*` | `--z-header`, `--z-modal` |
|
||||||
|
| Timing | `--dur-*` | `--dur-fast`, `--dur-slow` |
|
||||||
|
| Easing | `--ease-*` | `--ease-out`, `--ease-spring` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Status Documentasi
|
||||||
|
|
||||||
|
| Dokumen | Status | Prioritas |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| `core/01-color-system.md` | ✅ Selesai | P0 |
|
||||||
|
| `core/02-typography.md` | ✅ Selesai | P0 |
|
||||||
|
| `core/03-spatial-system.md` | ✅ Selesai | P0 |
|
||||||
|
| `core/04-motion-system.md` | ✅ Selesai | P0 |
|
||||||
|
| `core/05-component-architecture.md` | ✅ Selesai | P0 |
|
||||||
|
| `patterns/06-interaction-patterns.md` | ✅ Selesai | P0 |
|
||||||
|
| `patterns/07-data-visualization.md` | ✅ Selesai | P1 |
|
||||||
|
| `patterns/08-moderation-ui.md` | ✅ Selesai | P1 |
|
||||||
|
| `patterns/09-state-machines.md` | ✅ Selesai | P0 |
|
||||||
|
| `patterns/10-responsive-system.md` | ✅ Selesai | P0 |
|
||||||
|
| `services/11-frontend-ui.md` | ✅ Selesai | P0 |
|
||||||
|
| `services/12-backend-api-guidelines.md` | ✅ Selesai | P1 |
|
||||||
|
| `services/13-gateway-event-design.md` | ✅ Selesai | P1 |
|
||||||
|
| `system/14-accessibility.md` | ✅ Selesai | P0 |
|
||||||
|
| `system/15-theme-architecture.md` | ✅ Selesai | P1 |
|
||||||
|
| `system/16-sound-design.md` | ✅ Selesai | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Ingatan kita akan tetap abadi, takkan pernah mencair..."* ❄️🩵
|
||||||
|
|
||||||
|
© 2026 IMPHNEN — BETE Design System v2.0
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# Color System — Éclat Spectral
|
||||||
|
|
||||||
|
> *"Color is the keyboard, the eyes are the harmonies, the soul is the piano with many strings."*
|
||||||
|
> — Wassily Kandinsky, diadaptasi untuk sistem desain modern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Warna
|
||||||
|
|
||||||
|
Sistem warna BETE dibangun di atas tiga fondasi:
|
||||||
|
|
||||||
|
1. **OKLCH** — Color space perceptually uniform untuk konsistensi antar device
|
||||||
|
2. **HCT (Hue-Chroma-Tone)** — Sistem warna Material You yang adaptif
|
||||||
|
3. **Semantic Tokens** — Abstraksi makna, bukan nilai literal
|
||||||
|
|
||||||
|
Setiap warna memiliki **lightness (L), chroma (C), hue (H)** yang independen. Ini memungkinkan:
|
||||||
|
- **Scale generation** — Variasi lightness dari 0–100% dengan chroma yang sama
|
||||||
|
- **Theming** — Cukup ganti hue, seluruh tema bergeser
|
||||||
|
- **Accessibility** — Kontras dihitung dari lightness, cocok untuk WCAG AA/AAA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Primary Palette
|
||||||
|
|
||||||
|
### Brand Spectrum: "Aetherial Blue"
|
||||||
|
|
||||||
|
Rona utama IMPHNEN — biru yang dingin seperti es Amphoreus, namun hangat dalam interaksi.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* OKLCH Base — Primary */
|
||||||
|
--clr-primary-50: oklch(0.95 0.025 255);
|
||||||
|
--clr-primary-100: oklch(0.90 0.045 255);
|
||||||
|
--clr-primary-200: oklch(0.80 0.080 255);
|
||||||
|
--clr-primary-300: oklch(0.70 0.120 255);
|
||||||
|
--clr-primary-400: oklch(0.62 0.150 255);
|
||||||
|
--clr-primary-500: oklch(0.55 0.175 255); /* ★ Base primary */
|
||||||
|
--clr-primary-600: oklch(0.47 0.160 255);
|
||||||
|
--clr-primary-700: oklch(0.40 0.140 255);
|
||||||
|
--clr-primary-800: oklch(0.32 0.115 255);
|
||||||
|
--clr-primary-900: oklch(0.25 0.090 255);
|
||||||
|
--clr-primary-950: oklch(0.18 0.060 255);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deskripsi Hue 255°:** Biru jernih dengan sedikit cyan — warna langit senja di Amphoreus. Tidak terlalu agresif seperti biru korporat (#007bff), tidak terlalu playful seperti cyan (#00bcd4).
|
||||||
|
|
||||||
|
### Neutral Spectrum: "Glacial Scale"
|
||||||
|
|
||||||
|
Abu-abu yang tidak hangat (tidak kekuningan) dan tidak dingin (tidak kebiruan) — benar-benar netral.
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-neutral-50: oklch(0.985 0.001 286);
|
||||||
|
--clr-neutral-100: oklch(0.970 0.001 286);
|
||||||
|
--clr-neutral-200: oklch(0.920 0.003 286);
|
||||||
|
--clr-neutral-300: oklch(0.870 0.005 286);
|
||||||
|
--clr-neutral-400: oklch(0.750 0.010 286);
|
||||||
|
--clr-neutral-500: oklch(0.620 0.015 286); /* ★ Base neutral */
|
||||||
|
--clr-neutral-600: oklch(0.500 0.020 286);
|
||||||
|
--clr-neutral-700: oklch(0.380 0.025 286);
|
||||||
|
--clr-neutral-800: oklch(0.260 0.030 286);
|
||||||
|
--clr-neutral-900: oklch(0.180 0.030 286);
|
||||||
|
--clr-neutral-950: oklch(0.110 0.025 286);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌈 Extended Palette
|
||||||
|
|
||||||
|
### Accent Colors
|
||||||
|
|
||||||
|
| Palette | Hue | Chroma | Base (500) | Karakter |
|
||||||
|
|---------|-----|--------|------------|----------|
|
||||||
|
| **Ruby** (Destructive) | 25° | 0.165 | `oklch(0.55 0.165 25)` | Darah — urgensi, error |
|
||||||
|
| **Emerald** (Success) | 145° | 0.130 | `oklch(0.60 0.130 145)` | Pertumbuhan — sukses, aman |
|
||||||
|
| **Amber** (Warning) | 75° | 0.120 | `oklch(0.70 0.120 75)` | Matahari — peringatan, atensi |
|
||||||
|
| **Amethyst** (Premium) | 285° | 0.100 | `oklch(0.55 0.100 285)` | Mewah — fitur premium, VIP |
|
||||||
|
| **Rose** (AI / Feminine) | 350° | 0.110 | `oklch(0.60 0.110 350)` | Kecerdasan — AI analysis, insight |
|
||||||
|
| **Cyan** (Info) | 200° | 0.120 | `oklch(0.65 0.120 200)` | Informasi — tooltip, hint |
|
||||||
|
|
||||||
|
Setiap palette memiliki scale 50–950 mengikuti pola primary. Contoh:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-ruby-500: oklch(0.55 0.165 25);
|
||||||
|
--clr-ruby-600: oklch(0.47 0.150 25);
|
||||||
|
--clr-emerald-500: oklch(0.60 0.130 145);
|
||||||
|
--clr-amber-500: oklch(0.70 0.120 75);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Surface Colors — Dark Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Base surfaces */
|
||||||
|
--clr-surface-base: oklch(0.11 0.010 286); /* Darkest bg */
|
||||||
|
--clr-surface-elevated: oklch(0.14 0.015 286); /* Card surface */
|
||||||
|
--clr-surface-overlay: oklch(0.17 0.020 286); /* Modal/dropdown */
|
||||||
|
--clr-surface-sunken: oklch(0.08 0.005 286); /* Input bg */
|
||||||
|
|
||||||
|
/* Interactive states */
|
||||||
|
--clr-interactive-hover: oklch(0.20 0.025 286);
|
||||||
|
--clr-interactive-active: oklch(0.24 0.030 286);
|
||||||
|
--clr-interactive-selected: oklch(0.25 0.060 255 / 0.15); /* Primary tint */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Surface Colors — Light Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-surface-base: oklch(0.97 0.002 286);
|
||||||
|
--clr-surface-elevated: oklch(1.00 0.000 286);
|
||||||
|
--clr-surface-overlay: oklch(0.95 0.003 286);
|
||||||
|
--clr-surface-sunken: oklch(0.92 0.004 286);
|
||||||
|
|
||||||
|
--clr-interactive-hover: oklch(0.90 0.005 286);
|
||||||
|
--clr-interactive-active: oklch(0.85 0.008 286);
|
||||||
|
--clr-interactive-selected: oklch(0.90 0.060 255 / 0.3);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Text Colors & Accessibility
|
||||||
|
|
||||||
|
### Foreground Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-text-primary: oklch(0.95 0.005 286); /* High emphasis — body */
|
||||||
|
--clr-text-secondary: oklch(0.70 0.015 286); /* Medium emphasis — metadata */
|
||||||
|
--clr-text-tertiary: oklch(0.50 0.020 286); /* Low emphasis — placeholder */
|
||||||
|
--clr-text-disabled: oklch(0.35 0.020 286); /* Disabled state */
|
||||||
|
--clr-text-inverse: oklch(0.11 0.010 286); /* On colored backgrounds */
|
||||||
|
|
||||||
|
/* On-brand backgrounds */
|
||||||
|
--clr-text-on-primary: oklch(0.97 0.005 286); /* Text on primary bg */
|
||||||
|
--clr-text-on-destructive: oklch(0.97 0.005 286); /* Text on destructive bg */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kontras Minimum
|
||||||
|
|
||||||
|
| Level | Rasio | Usage | Elemen |
|
||||||
|
|-------|-------|-------|--------|
|
||||||
|
| **AA** | 4.5:1 | Body text normal | `--clr-text-primary` di atas surface |
|
||||||
|
| **AA Large** | 3:1 | Teks ≥18px/≥14px bold | Heading, label |
|
||||||
|
| **AAA** | 7:1 | Teks penting | Legal, alert, critical info |
|
||||||
|
|
||||||
|
### Verifikasi Kontras
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/contrast.ts
|
||||||
|
function meetsWCAGAA(foreground: OklchColor, background: OklchColor): boolean {
|
||||||
|
const contrast = relativeLuminance(background) / relativeLuminance(foreground);
|
||||||
|
return contrast >= 4.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function untuk mendapatkan lightness aman
|
||||||
|
function accessibleLightness(hue: number, chroma: number, bgLightness: number): number {
|
||||||
|
// Mencari lightness minimum yang memenuhi 4.5:1
|
||||||
|
for (let l = 1.0; l > 0; l -= 0.01) {
|
||||||
|
if (getContrastRatio(l, chroma, hue, bgLightness) >= 4.5) return l;
|
||||||
|
}
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🪞 Glass & Frosted Effects
|
||||||
|
|
||||||
|
Estetika glassmorphism menggunakan **opacity + backdrop-blur**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Glass card — frosted glass */
|
||||||
|
--glass-bg: oklch(0.15 0.015 286 / 0.60);
|
||||||
|
--glass-border: oklch(0.25 0.030 286 / 0.20);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.25);
|
||||||
|
|
||||||
|
/* Glass strong — modal/dialog */
|
||||||
|
--glass-strong-bg: oklch(0.18 0.020 286 / 0.85);
|
||||||
|
--glass-strong-blur: 24px;
|
||||||
|
|
||||||
|
/* Glass subtle — sidebar */
|
||||||
|
--glass-subtle-bg: oklch(var(--clr-surface-base) / 0.50);
|
||||||
|
--glass-subtle-blur: 8px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menggabungkan di CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.glass-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Glow & Light Effects
|
||||||
|
|
||||||
|
### Glow Tokens
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Primary glow — untuk elemen interaktif, loading state */
|
||||||
|
--glow-primary: 0 0 20px oklch(0.55 0.175 255 / 0.3);
|
||||||
|
--glow-primary-soft: 0 0 12px oklch(0.55 0.175 255 / 0.15);
|
||||||
|
|
||||||
|
/* Success glow — untuk badge, notifikasi sukses */
|
||||||
|
--glow-success: 0 0 16px oklch(0.60 0.130 145 / 0.25);
|
||||||
|
|
||||||
|
/* Destructive glow — untuk alert error */
|
||||||
|
--glow-error: 0 0 16px oklch(0.55 0.165 25 / 0.25);
|
||||||
|
|
||||||
|
/* AI glow — untuk analysis badge, pulsing indicator */
|
||||||
|
--glow-ai: 0 0 20px oklch(0.60 0.110 350 / 0.25);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pulse Animation dengan Glow
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: var(--glow-primary-soft);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: var(--glow-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-analysis-badge {
|
||||||
|
animation: glow-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌗 Theme Tokens
|
||||||
|
|
||||||
|
### Dark Mode (Default)
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Base */
|
||||||
|
--clr-base: var(--clr-surface-base);
|
||||||
|
--clr-elevated: var(--clr-surface-elevated);
|
||||||
|
--clr-overlay: var(--clr-surface-overlay);
|
||||||
|
--clr-sunken: var(--clr-surface-sunken);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--clr-text: var(--clr-text-primary);
|
||||||
|
--clr-text-muted: var(--clr-text-secondary);
|
||||||
|
|
||||||
|
/* Brand adjustments for dark */
|
||||||
|
--clr-primary: var(--clr-primary-400); /* Lebih terang di dark */
|
||||||
|
--clr-primary-bg: oklch(0.25 0.060 255 / 0.20);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Mode
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="light"] {
|
||||||
|
--clr-base: oklch(0.97 0.002 286);
|
||||||
|
--clr-elevated: oklch(1.00 0.000 286);
|
||||||
|
--clr-overlay: oklch(0.95 0.003 286);
|
||||||
|
--clr-sunken: oklch(0.92 0.004 286);
|
||||||
|
|
||||||
|
--clr-text: oklch(0.11 0.010 286);
|
||||||
|
--clr-text-muted: oklch(0.50 0.020 286);
|
||||||
|
|
||||||
|
--clr-primary: var(--clr-primary-500); /* Standard di light */
|
||||||
|
--clr-primary-bg: oklch(0.90 0.060 255 / 0.25);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Moderation Severity Colors
|
||||||
|
|
||||||
|
Sistem moderasi menggunakan gradasi keparahan dari aman hingga kritis:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-severity-safe: oklch(0.60 0.130 145); /* Emerald — aman */
|
||||||
|
--clr-severity-low: oklch(0.70 0.120 75); /* Amber — rendah */
|
||||||
|
--clr-severity-medium: oklch(0.65 0.150 50); /* Orange — sedang */
|
||||||
|
--clr-severity-high: oklch(0.60 0.150 30); /* Red-orange — tinggi */
|
||||||
|
--clr-severity-critical: oklch(0.55 0.165 25); /* Ruby — kritis */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Variations (untuk chip/badge)
|
||||||
|
|
||||||
|
```css
|
||||||
|
--clr-severity-safe-bg: oklch(0.60 0.130 145 / 0.15);
|
||||||
|
--clr-severity-low-bg: oklch(0.70 0.120 75 / 0.15);
|
||||||
|
--clr-severity-medium-bg: oklch(0.65 0.150 50 / 0.15);
|
||||||
|
--clr-severity-high-bg: oklch(0.60 0.150 30 / 0.15);
|
||||||
|
--clr-severity-critical-bg: oklch(0.55 0.165 25 / 0.15);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Color Usage Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Butuh warna untuk...
|
||||||
|
│
|
||||||
|
├── Surface / Background → Gunakan --clr-surface-* (base/elevated/overlay)
|
||||||
|
│
|
||||||
|
├── Text → Gunakan --clr-text-* (primary/secondary/tertiary)
|
||||||
|
│
|
||||||
|
├── Interactive element →
|
||||||
|
│ ├── Button utama → --clr-primary, --clr-primary-hover
|
||||||
|
│ ├── Button danger → --clr-ruby-500
|
||||||
|
│ ├── Link → --clr-primary-400 (dark) / --clr-primary-600 (light)
|
||||||
|
│ └── Input focus → --clr-primary ring
|
||||||
|
│
|
||||||
|
├── Status indicator →
|
||||||
|
│ ├── Success → --clr-emerald-* atau --clr-severity-safe
|
||||||
|
│ ├── Warning → --clr-amber-* atau --clr-severity-low
|
||||||
|
│ ├── Error → --clr-ruby-* atau --clr-severity-critical
|
||||||
|
│ └── Info → --clr-cyan-*
|
||||||
|
│
|
||||||
|
├── Data visualization → Gunakan palette chart (lihat patterns/07)
|
||||||
|
│
|
||||||
|
└── Moderation badge → Gunakan --clr-severity-*
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns (Yang Tidak Boleh Dilakukan)
|
||||||
|
|
||||||
|
### ❌ Hardcoded HEX/RGB
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — tidak akan terpengaruh theme switching */
|
||||||
|
.notification-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ Gunakan token semantic */
|
||||||
|
.notification-success {
|
||||||
|
background: var(--clr-emerald-100);
|
||||||
|
color: var(--clr-emerald-800);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Langsung pakai Tailwind utility colors
|
||||||
|
```tsx
|
||||||
|
{/* ❌ JANGAN — hardcoded ke skema tertentu */}
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700" />
|
||||||
|
|
||||||
|
{/* ✅ Gunakan semantic variant */}
|
||||||
|
<Badge variant="success" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Mengabaikan kontras
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — teks abu-abu di atas abu-abu */
|
||||||
|
.metadata {
|
||||||
|
color: oklch(0.65 0.015 286); /* L=0.65 */
|
||||||
|
background: oklch(0.70 0.010 286); /* L=0.70 — rasio ~1.1:1! */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ Minimum kontras 3:1 untuk secondary text */
|
||||||
|
.metadata {
|
||||||
|
color: oklch(0.50 0.020 286); /* L=0.50 */
|
||||||
|
background: var(--clr-surface-base); /* L=0.11 */
|
||||||
|
/* Rasio ~6:1 — aman */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [OKLCH Color Picker](https://oklch.com/) | Visualisasi OKLCH color space |
|
||||||
|
| [WCAG Contrast Checker](https://webaim.org/resources/contrastchecker/) | Verifikasi kontras |
|
||||||
|
| [Material HCT](https://material.io/blog/science-of-color-design) | Hue-Chroma-Tone system |
|
||||||
|
| [Tailwind CSS OKLCH](https://tailwindcss.com/docs/colors#using-custom-colors) | Implementasi OKLCH |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Warna adalah ingatan yang tak pernah pudar — dibiaskan melalui prisma es Amphoreus."* ❄️🩵
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Typography — The Voice of Glass
|
||||||
|
|
||||||
|
> *"Typography is the craft of endowing human language with a durable visual form."*
|
||||||
|
> — Robert Bringhurst, *The Elements of Typographic Style*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Tipografi
|
||||||
|
|
||||||
|
Tipografi BETE dibangun di atas tiga pilar:
|
||||||
|
|
||||||
|
1. **Hierarki melalui weight & size** — Bukan sekadar memperbesar heading, tapi memberi bobot makna
|
||||||
|
2. **Ritme vertikal yang konsisten** — Setiap elemen teks berada dalam grid ritme 4px
|
||||||
|
3. **Keterbacaan sebagai prioritas utama** — Sebelum estetika, sebelum gaya
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Type Scale: Fluid Modular Scale
|
||||||
|
|
||||||
|
Kita menggunakan **modular scale** 1.25 (major third) yang **fluid** — menyesuaikan antara viewport.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Font size scale — fluid, minor third (1.125) hingga major third (1.25) */
|
||||||
|
--fs-xs: clamp(0.69rem, 0.69rem + 0.01vw, 0.75rem); /* 11–12px */
|
||||||
|
--fs-sm: clamp(0.81rem, 0.81rem + 0.02vw, 0.88rem); /* 13–14px */
|
||||||
|
--fs-base: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem); /* 15–16px ★ */
|
||||||
|
--fs-md: clamp(1.06rem, 1.06rem + 0.04vw, 1.13rem); /* 17–18px */
|
||||||
|
--fs-lg: clamp(1.19rem, 1.19rem + 0.06vw, 1.25rem); /* 19–20px */
|
||||||
|
--fs-xl: clamp(1.31rem, 1.31rem + 0.08vw, 1.50rem); /* 21–24px */
|
||||||
|
--fs-2xl: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem); /* 24–30px */
|
||||||
|
--fs-3xl: clamp(1.69rem, 1.69rem + 0.18vw, 2.25rem); /* 27–36px */
|
||||||
|
--fs-4xl: clamp(1.88rem, 1.88rem + 0.26vw, 2.81rem); /* 30–45px */
|
||||||
|
--fs-5xl: clamp(2.25rem, 2.25rem + 0.38vw, 3.50rem); /* 36–56px */
|
||||||
|
--fs-6xl: clamp(2.50rem, 2.50rem + 0.50vw, 4.00rem); /* 40–64px */
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Mengapa clamp()?** Font size yang terlalu besar di mobile dan terlalu kecil di desktop adalah masalah UX klasik. Dengan `clamp()`, kita dapatkan ukuran yang optimal di setiap viewport tanpa media query.
|
||||||
|
|
||||||
|
### Line Height
|
||||||
|
|
||||||
|
```css
|
||||||
|
--lh-tight: 1.15; /* Heading besar, display text */
|
||||||
|
--lh-normal: 1.50; /* Body text, paragraphs */
|
||||||
|
--lh-relaxed: 1.65; /* Long-form reading */
|
||||||
|
--lh-compact: 1.25; /* UI labels, badges, small text */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font Weight Tokens
|
||||||
|
|
||||||
|
```css
|
||||||
|
--fw-light: 300;
|
||||||
|
--fw-regular: 400;
|
||||||
|
--fw-medium: 500;
|
||||||
|
--fw-semibold: 600;
|
||||||
|
--fw-bold: 700;
|
||||||
|
--fw-extrabold: 800;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔤 Font Family
|
||||||
|
|
||||||
|
### Primary: "Outfit" — Modern Geometric Sans
|
||||||
|
|
||||||
|
```css
|
||||||
|
--ff-sans: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||||
|
--ff-display: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
Mengapa **Outfit** menggantikan Poppins?
|
||||||
|
- **Geometric precision** — Bentuk huruf yang bersih, cocok untuk UI modern
|
||||||
|
- **Low x-height** — Memberi kesan elegan dan lega
|
||||||
|
- **Variable font support** — Satu file untuk semua weight, performa lebih baik
|
||||||
|
- **Open-source** — SIL Open Font License
|
||||||
|
|
||||||
|
### Monospace: "JetBrains Mono"
|
||||||
|
|
||||||
|
```css
|
||||||
|
--ff-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk: ID, timestamp, kode, data teknis, metrik.
|
||||||
|
|
||||||
|
### Loading Strategy
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Variable font — cukup satu file untuk seluruh weight -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300..800&family=JetBrains+Mono:wght@400..700&display=swap" rel="stylesheet" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Fallback font stack dengan @font-face untuk cache lokal */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit Fallback';
|
||||||
|
src: local('Segoe UI'), local('Roboto'), local('Helvetica Neue');
|
||||||
|
size-adjust: 95%; /* Mengurangi layout shift (CLS) */
|
||||||
|
ascent-override: 90%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Type Styles — The Complete Catalog
|
||||||
|
|
||||||
|
### Display / Hero
|
||||||
|
|
||||||
|
```css
|
||||||
|
.display-1 {
|
||||||
|
font-family: var(--ff-display);
|
||||||
|
font-size: var(--fs-6xl);
|
||||||
|
font-weight: var(--fw-extrabold);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-2 {
|
||||||
|
font-family: var(--ff-display);
|
||||||
|
font-size: var(--fs-5xl);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headings
|
||||||
|
|
||||||
|
```css
|
||||||
|
.h1 {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-4xl);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2 {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-3xl);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h3 {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-2xl);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h4 {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-xl);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: var(--lh-normal);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
```css
|
||||||
|
.body-large {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-md);
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
line-height: var(--lh-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
line-height: var(--lh-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-small {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
line-height: var(--lh-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-compact {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: var(--lh-compact);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI / Label
|
||||||
|
|
||||||
|
```css
|
||||||
|
.label {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
line-height: var(--lh-compact);
|
||||||
|
letter-spacing: 0.06em; /* UPPERCASE labels get wider tracking */
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
line-height: var(--lh-normal);
|
||||||
|
color: var(--clr-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--ff-mono);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-regular);
|
||||||
|
line-height: var(--lh-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Type Table — Mapping ke Penggunaan
|
||||||
|
|
||||||
|
| Token | Penggunaan | Contoh |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| `.display-1` | Halaman kosong, 404, hero section | "Nothing to see here" |
|
||||||
|
| `.display-2` | Empty state utama | "No messages yet" |
|
||||||
|
| `.h1` | Judul halaman | "Dashboard", "Messages" |
|
||||||
|
| `.h2` | Judul section panel | "Voice Connections", "Analytics" |
|
||||||
|
| `.h3` | Judul card | Nama user, channel |
|
||||||
|
| `.h4` | Sub-section, tab content | "Active Speakers", "Filters" |
|
||||||
|
| `.body` | Paragraf, konten utama | Pesan teks, deskripsi |
|
||||||
|
| `.body-small` | Metadata, secondary info | Timestamp, username |
|
||||||
|
| `.body-compact` | Dense lists | Daftar items compact |
|
||||||
|
| `.label` | Form label, section header | "CHANNEL", "USERNAME" |
|
||||||
|
| `.caption` | Hint, footnote, helper | "Click to expand" |
|
||||||
|
| `.mono` | ID, kode, data teknis | "Channel #12345" |
|
||||||
|
| `.badge` | Chip, status indicator | "AI Analysis", "Flagged" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎭 Rich Text & Emphatic Styles
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Links */
|
||||||
|
a, .link {
|
||||||
|
color: var(--clr-primary-400);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
a:hover, .link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code inline */
|
||||||
|
code, .code-inline {
|
||||||
|
font-family: var(--ff-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.125em 0.375em;
|
||||||
|
background: var(--clr-surface-sunken);
|
||||||
|
border-radius: var(--rd-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Truncation */
|
||||||
|
.text-truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-line truncation */
|
||||||
|
.text-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📏 Vertical Rhythm
|
||||||
|
|
||||||
|
Menggunakan sistem baseline 4px (bukan 8px default) untuk tipografi:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--baseline: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading margin */
|
||||||
|
.h1 { margin-bottom: calc(var(--baseline) * 4); } /* 16px */
|
||||||
|
.h2 { margin-bottom: calc(var(--baseline) * 3); } /* 12px */
|
||||||
|
.h3 { margin-bottom: calc(var(--baseline) * 3); } /* 12px */
|
||||||
|
.h4 { margin-bottom: calc(var(--baseline) * 2); } /* 8px */
|
||||||
|
|
||||||
|
/* Paragraph spacing */
|
||||||
|
p, .body {
|
||||||
|
margin-bottom: calc(var(--baseline) * 4); /* 16px */
|
||||||
|
}
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-top: calc(var(--baseline) * 2); /* 8px — reduced between consecutive paragraphs */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns Tipografi
|
||||||
|
|
||||||
|
### ❌ Ukuran absolut tanpa fluid
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — title 32px di mobile terlalu besar */
|
||||||
|
.page-title { font-size: 32px; }
|
||||||
|
|
||||||
|
/* ✅ clamp menyesuaikan viewport */
|
||||||
|
.page-title { font-size: var(--fs-4xl); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Line height terlalu kecil untuk body text
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — crowded, sulit dibaca */
|
||||||
|
.body-text { font-size: 16px; line-height: 1.2; }
|
||||||
|
|
||||||
|
/* ✅ Line height yang cukup untuk readability */
|
||||||
|
.body-text { font-size: var(--fs-base); line-height: var(--lh-normal); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Terlalu banyak type scale
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — 29px, 28px, 27px, 26px adalah noise */
|
||||||
|
.custom-1 { font-size: 29px; }
|
||||||
|
.custom-2 { font-size: 28px; }
|
||||||
|
|
||||||
|
/* ✅ Gunakan scale yang terdefinisi */
|
||||||
|
.custom { font-size: var(--fs-4xl); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Type Scale Calculator](https://typescale.com/) | Modular scale generation |
|
||||||
|
| [Outfit on Google Fonts](https://fonts.google.com/specimen/Outfit) | Font spesifikasi |
|
||||||
|
| [Utopia.fyi](https://utopia.fyi/) | Fluid type scale calculator |
|
||||||
|
| [JetBrains Mono](https://www.jetbrains.com/lp/mono/) | Font monospace |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Huruf adalah jejak ingatan yang tak kasatmata — ia berbicara tanpa suara."* ❄️🩵
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# Spatial System — The Architecture of Void
|
||||||
|
|
||||||
|
> *"Space is the breath of art."*
|
||||||
|
> — Frank Lloyd Wright, arsitek organik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Spasial
|
||||||
|
|
||||||
|
Ruang dalam BETE bukan sekadar "tempat kosong" — ia adalah **medium komunikasi visual**. Jarak antarelemen menyampaikan hubungan semantik:
|
||||||
|
|
||||||
|
- **Dekat** → Elemen terkait secara konseptual
|
||||||
|
- **Berjarak** → Elemen independen atau batch berbeda
|
||||||
|
- **Terpisah jauh** → Section baru, hierarki turun
|
||||||
|
|
||||||
|
Kita menggunakan **4px baseline grid** untuk semua keputusan spasial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Grid System: 8px × 4px Hybrid
|
||||||
|
|
||||||
|
BETE menggunakan sistem **8px untuk layout kasar**, **4px untuk fine-tuning**.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Base grid unit */
|
||||||
|
--grid-unit: 4px;
|
||||||
|
|
||||||
|
/* Spacing scale — exponential */
|
||||||
|
--sp-0: 0px;
|
||||||
|
--sp-0.5: calc(var(--grid-unit) * 1); /* 4px — micro spacing */
|
||||||
|
--sp-1: calc(var(--grid-unit) * 2); /* 8px — tight spacing */
|
||||||
|
--sp-2: calc(var(--grid-unit) * 3); /* 12px — compact spacing */
|
||||||
|
--sp-3: calc(var(--grid-unit) * 4); /* 16px — base spacing ★ */
|
||||||
|
--sp-4: calc(var(--grid-unit) * 6); /* 24px — relaxed spacing */
|
||||||
|
--sp-5: calc(var(--grid-unit) * 8); /* 32px — section spacing */
|
||||||
|
--sp-6: calc(var(--grid-unit) * 12); /* 48px — panel spacing */
|
||||||
|
--sp-7: calc(var(--grid-unit) * 16); /* 64px — page spacing */
|
||||||
|
--sp-8: calc(var(--grid-unit) * 24); /* 96px — hero spacing */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logic di Balik Scale
|
||||||
|
|
||||||
|
| Token | px | Konteks |
|
||||||
|
|-------|----|---------|
|
||||||
|
| `--sp-0.5` | 4px | Ikon-padding, badge spacing, dot indicators |
|
||||||
|
| `--sp-1` | 8px | Avatar-text gap, icon-button padding |
|
||||||
|
| `--sp-2` | 12px | Button padding, chip spacing, input padding |
|
||||||
|
| `--sp-3` | 16px | **Base unit** — card-padding, section margin, form gap |
|
||||||
|
| `--sp-4` | 24px | Card gap, panel padding, modal padding |
|
||||||
|
| `--sp-5` | 32px | Content area padding, desktop sidebar width |
|
||||||
|
| `--sp-6` | 48px | Page section gap, dashboards grid gap |
|
||||||
|
| `--sp-7` | 64px | Page padding desktop, hero spacing |
|
||||||
|
| `--sp-8` | 96px | Empty state height, large break sections |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔲 Layout Components
|
||||||
|
|
||||||
|
### Page Layout (Desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ┌── Header ──┐ │ 56px
|
||||||
|
│ └────────────┘ │
|
||||||
|
│ ┌─ Sidebar ─┐ ┌────────── Main Content ──────────┐│
|
||||||
|
│ │ │ │ ┌─ Page Title ─────────────────┐ ││
|
||||||
|
│ │ icon grid │ │ │ Section Heading │ ││
|
||||||
|
│ │ ──────── │ │ └─────────────────────────────┘ ││
|
||||||
|
│ │ nav-1 │ │ ││
|
||||||
|
│ │ nav-2 │ │ ┌────── Grid Area ─────────────┐ ││
|
||||||
|
│ │ nav-3 │ │ │ ┌── Card ──┐ ┌── Card ──┐ │ ││
|
||||||
|
│ │ │ │ │ │ │ │ │ │ ││
|
||||||
|
│ │ ──────── │ │ │ └──────────┘ └──────────┘ │ ││
|
||||||
|
│ │ mascot │ │ │ ┌── Card ──┐ ┌── Card ──┐ │ ││
|
||||||
|
│ │ │ │ │ │ │ │ │ │ ││
|
||||||
|
│ └────────────┘ │ │ └──────────┘ └──────────┘ │ ││
|
||||||
|
│ │ └───────────────────────────────┘ ││
|
||||||
|
│ w-64/ │ flex-1 ││
|
||||||
|
│ w-16(icon) │ ││
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue 3 Component Mapping
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DashboardLayout.vue
|
||||||
|
<template>
|
||||||
|
<div class="page-layout">
|
||||||
|
<Sidebar :collapsed="sidebarCollapsed" />
|
||||||
|
<main class="main-area">
|
||||||
|
<Header />
|
||||||
|
<div class="content-area">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.page-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
width: 256px; /* w-64 */
|
||||||
|
transition: width var(--dur-normal) var(--ease-out-quint);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 64px; /* w-16 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0; /* Prevent grid blowout */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔳 Border Radius Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--rd-none: 0px;
|
||||||
|
--rd-xs: 4px; /* Checkbox, toggle, small indicators */
|
||||||
|
--rd-sm: 6px; /* Input, button small, badges */
|
||||||
|
--rd-md: 8px; /* Button default, card, modal */
|
||||||
|
--rd-lg: 12px; /* Card elevated, sheets, panels */
|
||||||
|
--rd-xl: 16px; /* Dialog, bottom sheet */
|
||||||
|
--rd-2xl: 20px; /* Full-width cards on mobile */
|
||||||
|
--rd-full: 9999px; /* Pill, chip, avatar */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Radius Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Elemen interaktif?
|
||||||
|
├── Ya ─→ butuh affordance visual?
|
||||||
|
│ ├── Ya, utama (button, card clickable) → --rd-md (8px)
|
||||||
|
│ └── Tidak (chip, tag, avatar) → --rd-full (pill)
|
||||||
|
│
|
||||||
|
└── Tidak → container?
|
||||||
|
├── Dialog/modal → --rd-xl (16px)
|
||||||
|
├── Card dalam grid → --rd-lg (12px)
|
||||||
|
└── Sheet/panel → --rd-xl atau --rd-2xl
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🥞 Z-Index Registry (Formal)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--z-base: 0;
|
||||||
|
--z-dropdown: 10;
|
||||||
|
--z-sticky: 20;
|
||||||
|
--z-header: 30;
|
||||||
|
--z-sidebar: 40;
|
||||||
|
--z-overlay: 50; /* Mobile sidebar, backdrop */
|
||||||
|
--z-modal: 60; /* Dialog, confirm */
|
||||||
|
--z-popover: 70; /* Tooltip, popover, dropdown menu */
|
||||||
|
--z-toast: 80; /* Toast notification */
|
||||||
|
--z-mascot: 100; /* Mascot chatbot — highest */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Implementation */
|
||||||
|
.header { z-index: var(--z-header); }
|
||||||
|
.sidebar { z-index: var(--z-sidebar); }
|
||||||
|
.modal { z-index: var(--z-modal); }
|
||||||
|
.toast { z-index: var(--z-toast); }
|
||||||
|
.mascot-chat { z-index: var(--z-mascot); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stacking Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer Value Elemen
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
Background -1 ParticleBackground
|
||||||
|
Base 0 Layout, cards, text
|
||||||
|
Dropdown 10 Select options, context menu
|
||||||
|
Sticky 20 Sticky section headers
|
||||||
|
Header 30 Sticky page header
|
||||||
|
Sidebar 40 Desktop sidebar
|
||||||
|
Overlay 50 Backdrop, mobile drawer
|
||||||
|
Modal 60 Confirm dialog, modal
|
||||||
|
Popover 70 Tooltip, dropdown
|
||||||
|
Toast 80 Toast notifications
|
||||||
|
Mascot 100 Chatbot floating panel
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Responsive Breakpoints
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* CSS Custom Properties for breakpoints */
|
||||||
|
:root {
|
||||||
|
--bp-sm: 640px;
|
||||||
|
--bp-md: 768px;
|
||||||
|
--bp-lg: 1024px;
|
||||||
|
--bp-xl: 1280px;
|
||||||
|
--bp-2xl: 1536px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container max-width */
|
||||||
|
--container-sm: 640px;
|
||||||
|
--container-md: 768px;
|
||||||
|
--container-lg: 1024px;
|
||||||
|
--container-xl: 1280px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Behavior per Breakpoint
|
||||||
|
|
||||||
|
| Breakpoint | Sidebar | Content Padding | Grid Columns |
|
||||||
|
|-----------|---------|-----------------|--------------|
|
||||||
|
| `<640px` | Bottom tab (56px) | `--sp-3` (16px) | 1 |
|
||||||
|
| `640–768px` | Bottom tab | `--sp-4` (24px) | 1 |
|
||||||
|
| `768–1024px` | Icon-only (64px) | `--sp-4` (24px) | 1–2 |
|
||||||
|
| `1024–1280px` | Full (256px) | `--sp-5` (32px) | 2–3 |
|
||||||
|
| `1280px+` | Full (256px) | `--sp-5` (32px) | 2–4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Common Layout Patterns
|
||||||
|
|
||||||
|
### Card Grid
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-Column Detail
|
||||||
|
|
||||||
|
```css
|
||||||
|
.two-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.two-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar + Content (Live Panel)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.live-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px; /* Content 1fr, sidebar fixed */
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.live-layout {
|
||||||
|
grid-template-columns: 1fr; /* Stack on smaller screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(56px + var(--sp-4)); /* Below header */
|
||||||
|
max-height: calc(100vh - 56px - var(--sp-4) * 2);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Container Queries (Modern Approach)
|
||||||
|
|
||||||
|
Untuk komponen yang reusable di berbagai konteks:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card-grid-component {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: card-grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container card-grid (max-width: 400px) {
|
||||||
|
.card-item {
|
||||||
|
grid-template-columns: 1fr; /* Single column di container kecil */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container card-grid (min-width: 401px) {
|
||||||
|
.card-item {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns Spasial
|
||||||
|
|
||||||
|
### ❌ Margin collapse tanpa sengaja
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — flex gap jauh lebih aman */
|
||||||
|
.card + .card { margin-top: 16px; } /* Rawan collapse */
|
||||||
|
|
||||||
|
/* ✅ Gunakan gap */
|
||||||
|
.card-grid { display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Padding tidak konsisten
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — setiap file punya padding sendiri */
|
||||||
|
.page-a { padding: 20px; }
|
||||||
|
.page-b { padding: 24px; }
|
||||||
|
|
||||||
|
/* ✅ Gunakan spacing token */
|
||||||
|
.page { padding: var(--sp-5); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Grid blowout (min-width tanpa min-width: 0)
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — grid item dengan teks panjang mendorong layout */
|
||||||
|
.grid-item { overflow: visible; }
|
||||||
|
|
||||||
|
/* ✅ Cegah blowout */
|
||||||
|
.grid-item { min-width: 0; overflow: hidden; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Ruang adalah kanvas tempat ingatan menari — setiap piksel memiliki tempatnya."* ❄️🩵
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
# Motion System — The Dance of Glass
|
||||||
|
|
||||||
|
> *"Animation is not about making things move. It's about making things *believe*."*
|
||||||
|
> — Richard Williams, *The Animator's Survival Kit*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Gerak
|
||||||
|
|
||||||
|
Animasi di BETE bukan sekadar efek visual — ia adalah **bahasa kinetik** yang mengkomunikasikan relasi spasial antar elemen:
|
||||||
|
|
||||||
|
| Gerakan | Makna |
|
||||||
|
|---------|-------|
|
||||||
|
| **Slide from right** | Elemen baru datang dari "luar" — panel, drawer |
|
||||||
|
| **Fade in + scale** | Muncul dari "dalam" — modal, dialog |
|
||||||
|
| **Slide up** | Konten baru melanjutkan alur vertikal |
|
||||||
|
| **Scale + glow** | Sedang diproses — loading, analysis |
|
||||||
|
| **Spring bounce (subtle)** | Konfirmasi sukses — centang, badge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ Timing & Easing — The Physics Engine
|
||||||
|
|
||||||
|
### Duration Tokens
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--dur-instant: 0ms;
|
||||||
|
--dur-fast: 150ms; /* Hover, active state, toggle */
|
||||||
|
--dur-normal: 250ms; /* Default transition, card hover */
|
||||||
|
--dur-slow: 350ms; /* Panel enter/exit, page transition */
|
||||||
|
--dur-glacial: 500ms; /* Modal, drawer slide, emphasis */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Easing Curves
|
||||||
|
|
||||||
|
BETE menggunakan tiga easing curve utama, semuanya **custom cubic-bezier**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Standard ease-out — untuk sebagian besar interaksi */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
/* Ease-out quint — untuk elemen yang "mendarat" */
|
||||||
|
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
|
/* Ease-in-out — untuk transisi dua arah (accordion, collapse) */
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Perbandingan dengan CSS Default
|
||||||
|
|
||||||
|
| Nama | Bezier | Karakter |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `ease` (default) | `(0.25, 0.1, 0.25, 1)` | Lambat mulai, lambat akhir |
|
||||||
|
| `ease-out` | `(0, 0, 0.58, 1)` | Cepat mulai |
|
||||||
|
| `ease-in-out` | `(0.42, 0, 0.58, 1)` | Simetris |
|
||||||
|
| **`--ease-out`** | `(0.16, 1, 0.3, 1)` | Natural, "berat" di akhir |
|
||||||
|
| **`--ease-out-quint`** | `(0.22, 1, 0.36, 1)` | Landing yang tegas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏃 Micro-interactions
|
||||||
|
|
||||||
|
### Hover State
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Card hover — subtle lift + shadow deepen */
|
||||||
|
.card {
|
||||||
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--sh-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover — scale up subtly */
|
||||||
|
.button {
|
||||||
|
transition: transform var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
.button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Active/Press State
|
||||||
|
|
||||||
|
```css
|
||||||
|
.button:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
transition-duration: var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button ripple effect */
|
||||||
|
.button.ripple {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.ripple::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,0.3) 10%, transparent 10%);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1000% 1000%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.ripple:active::after {
|
||||||
|
background-size: 0% 0%;
|
||||||
|
opacity: 1;
|
||||||
|
transition: background-size 0.4s, opacity 0.4s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Ring
|
||||||
|
|
||||||
|
```css
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--clr-primary-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth ring transition */
|
||||||
|
.input {
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--clr-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--clr-primary-bg);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 Page & Panel Transitions
|
||||||
|
|
||||||
|
### Tab Switch — Choreographed Sequence
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Stagger container */
|
||||||
|
.page-transition-enter {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-transition-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity var(--dur-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Children stagger — via Framer Motion / GSAP timeline */
|
||||||
|
@keyframes stagger-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-item {
|
||||||
|
animation: stagger-enter var(--dur-slow) var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-item:nth-child(1) { animation-delay: 40ms; }
|
||||||
|
.stagger-item:nth-child(2) { animation-delay: 80ms; }
|
||||||
|
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||||||
|
.stagger-item:nth-child(4) { animation-delay: 160ms; }
|
||||||
|
.stagger-item:nth-child(5) { animation-delay: 200ms; }
|
||||||
|
/* ... formula: delay = index * 40ms */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue Transition Mode
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
name="page"
|
||||||
|
mode="out-in"
|
||||||
|
@before-leave="beforeLeave"
|
||||||
|
@after-enter="afterEnter"
|
||||||
|
>
|
||||||
|
<component :is="currentTab" :key="currentTab" />
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-leave-active {
|
||||||
|
transition: opacity var(--dur-normal) var(--ease-in-out),
|
||||||
|
transform var(--dur-normal) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
.page-enter-active {
|
||||||
|
transition: opacity var(--dur-slow) var(--ease-out),
|
||||||
|
transform var(--dur-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
.page-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Component-Specific Animations
|
||||||
|
|
||||||
|
### Sidebar Expand/Collapse
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width, 256px);
|
||||||
|
transition: width var(--dur-slow) var(--ease-out-quint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
--sidebar-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav items — icon slides, text fades */
|
||||||
|
.sidebar-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item .label {
|
||||||
|
transition: opacity var(--dur-normal) var(--ease-out),
|
||||||
|
width var(--dur-normal) var(--ease-out);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-nav-item .label {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Notification
|
||||||
|
|
||||||
|
```css
|
||||||
|
.toast-enter-active {
|
||||||
|
animation: toast-slide-in var(--dur-slow) var(--ease-out-quint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-active {
|
||||||
|
animation: toast-slide-out var(--dur-normal) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-slide-out {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal / Dialog
|
||||||
|
|
||||||
|
```css
|
||||||
|
.modal-overlay-enter-active {
|
||||||
|
transition: opacity var(--dur-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
.modal-overlay-enter-from { opacity: 0; }
|
||||||
|
|
||||||
|
.modal-content-enter-active {
|
||||||
|
animation: modal-scale-in var(--dur-slow) var(--ease-out-quint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-scale-in {
|
||||||
|
from {
|
||||||
|
transform: scale(0.92) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Special Effects
|
||||||
|
|
||||||
|
### Audio Visualizer Bars
|
||||||
|
|
||||||
|
```css
|
||||||
|
.visualizer-bar {
|
||||||
|
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualizer-bar:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.visualizer-bar:nth-child(2) { animation-delay: 75ms; }
|
||||||
|
.visualizer-bar:nth-child(3) { animation-delay: 150ms; }
|
||||||
|
.visualizer-bar:nth-child(4) { animation-delay: 225ms; }
|
||||||
|
|
||||||
|
@keyframes bar-pulse {
|
||||||
|
0%, 100% { transform: scaleY(0.8); }
|
||||||
|
50% { transform: scaleY(1.2); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skeleton Loading (Shimmer)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--clr-surface-sunken) 25%,
|
||||||
|
var(--clr-surface-elevated) 50%,
|
||||||
|
var(--clr-surface-sunken) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Analysis Pulse (Glow)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ai-badge {
|
||||||
|
animation: ai-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 4px var(--clr-rose-500 / 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 12px var(--clr-rose-500 / 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ghost Particle (Background)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.particle {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: float var(--dur-float, 8s) ease-in-out infinite;
|
||||||
|
animation-delay: var(--delay, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) translateX(0) scale(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-20px) translateX(10px) scale(1.1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-40px) translateX(-5px) scale(0.9);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-20px) translateX(15px) scale(1.05);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Motion Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Elemen apa yang dianimasikan?
|
||||||
|
│
|
||||||
|
├── Hover/Interaksi → dur-fast (150ms) + --ease-out
|
||||||
|
│ ├── Card → translateY(-2px) + shadow deepen
|
||||||
|
│ ├── Button → scale(1.02) / scale(0.98)
|
||||||
|
│ ├── Link → opacity/underline
|
||||||
|
│ └── Icon → rotate/color
|
||||||
|
│
|
||||||
|
├── Masuk ke halaman → dur-slow (350ms) + stagger
|
||||||
|
│ ├── Halaman baru → fade + slideY(12px)
|
||||||
|
│ ├── List items → stagger (40ms per item)
|
||||||
|
│ └── Modal → scale(0.92→1) + fade overlay
|
||||||
|
│
|
||||||
|
├── Keluar dari halaman → dur-normal (250ms) + ease-in-out
|
||||||
|
│ ├── Halaman → fade + slideY(-8px)
|
||||||
|
│ ├── Toast → slideX(100%)
|
||||||
|
│ └── Modal → scale(→0.95) + fade overlay
|
||||||
|
│
|
||||||
|
├── Loading → infinite loop
|
||||||
|
│ ├── Skeleton → shimmer 1.5s
|
||||||
|
│ ├── Spinner → spin
|
||||||
|
│ └── AI Analysis → glow-pulse 2s
|
||||||
|
│
|
||||||
|
└── State change → dur-normal (250ms) + --ease-out
|
||||||
|
├── Sidebar → width transition
|
||||||
|
├── Accordion → height transition
|
||||||
|
└── Badge → scale(0.8→1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ♿ Reduced Motion
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* But allow opacity transitions for basic UX */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/useReducedMotion.ts
|
||||||
|
export function useReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
setReduced(mq.matches);
|
||||||
|
|
||||||
|
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
// Dalam komponen
|
||||||
|
<script setup>
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition :duration="reducedMotion ? 0 : 350">
|
||||||
|
...
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns Animasi
|
||||||
|
|
||||||
|
### ❌ Durasi terlalu lama
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — 1 detik terasa lambat */
|
||||||
|
.sidebar { transition: width 1s ease; }
|
||||||
|
|
||||||
|
/* ✅ 250–350ms adalah sweet spot UI */
|
||||||
|
.sidebar { transition: width var(--dur-slow) var(--ease-out-quint); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Semua bergerak bersamaan (tanpa stagger)
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — tidak graceful */
|
||||||
|
.card { animation: fadeIn 0.3s ease; }
|
||||||
|
|
||||||
|
/* ✅ Stagger menciptakan gelombang natural */
|
||||||
|
.card:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.card:nth-child(2) { animation-delay: 40ms; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Easing yang salah untuk konteks
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — ease-in untuk enter terasa lambat di awal */
|
||||||
|
.modal { animation: scaleIn 0.3s ease-in; }
|
||||||
|
|
||||||
|
/* ✅ ease-out untuk enter — cepat mulai, soft berhenti */
|
||||||
|
.modal { animation: scaleIn 0.3s var(--ease-out-quint); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Mengabaikan reduced motion
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — tidak accessible */
|
||||||
|
.particle { animation: float 8s infinite; }
|
||||||
|
|
||||||
|
/* ✅ Diberhentikan untuk reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.particle { display: none; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Easing Functions Cheat Sheet](https://easings.net/) | Visualisasi easing curves |
|
||||||
|
| [Material Motion](https://m2.material.io/design/motion/) | Sistem motion Google |
|
||||||
|
| [GSAP](https://gsap.com/) | Production-grade animation library |
|
||||||
|
| [AnimXYZ](https://animxyz.com/) | Utility-first CSS animations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Gerak adalah bahasa ingatan yang tak terucap — setiap transisi adalah cerita."* ❄️🩵
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Component Architecture — The Glass Library
|
||||||
|
|
||||||
|
> *"Design is not just what it looks like and feels like. Design is how it works."*
|
||||||
|
> — Steve Jobs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Komponen
|
||||||
|
|
||||||
|
Setiap komponen di BETE adalah **self-contained glass panel** yang:
|
||||||
|
1. **Satu tanggung jawab** — Satu komponen, satu fungsi
|
||||||
|
2. **State-driven** — Visual merepresentasikan state, bukan sebaliknya
|
||||||
|
3. **Composable** — Bisa digabung seperti LEGO
|
||||||
|
4. **Theme-aware** — Menggunakan CSS variables, bukan hardcoded values
|
||||||
|
5. **Accessible** — Keyboard, screen reader, reduced motion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Component Taxonomy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ COMPONENT MAP │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─── ATOMS ──────────────────────────────────────────┐ │
|
||||||
|
│ │ Button │ Badge │ Input │ Label │ Icon │ │
|
||||||
|
│ │ Avatar │ Skeleton│ Spinner │ Divider│ Tooltip │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─── MOLECULES ───────────────────────────────────────┐ │
|
||||||
|
│ │ Card │ Tabs │ Select │ │
|
||||||
|
│ │ Toast │ Modal │ Dropdown │ │
|
||||||
|
│ │ Pagination │ Breadcrumb │ SearchBar │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─── ORGANISMS ───────────────────────────────────────┐ │
|
||||||
|
│ │ MessageCard │ VoiceCard │ AnalyticsChart │ │
|
||||||
|
│ │ ActiveSpeaker │ NowPlaying │ ImageGrid │ │
|
||||||
|
│ │ MascotChatbot │ AudioViz │ StatsCard │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─── TEMPLATES ───────────────────────────────────────┐ │
|
||||||
|
│ │ Sidebar │ Header │ DashboardLayout │ │
|
||||||
|
│ │ AuthForm │ LivePanel │ MessagesPanel │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Atomic Components (Atoms)
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ButtonProps {
|
||||||
|
variant: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost';
|
||||||
|
size: 'sm' | 'default' | 'lg' | 'icon';
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.button {
|
||||||
|
--btn-bg: var(--clr-primary);
|
||||||
|
--btn-color: var(--clr-text-on-primary);
|
||||||
|
--btn-border: transparent;
|
||||||
|
--btn-hover-bg: var(--clr-primary-600);
|
||||||
|
--btn-active-transform: scale(0.97);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
border-radius: var(--rd-md);
|
||||||
|
font-family: var(--ff-sans);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: var(--lh-compact);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--btn-color);
|
||||||
|
border: 1px solid var(--btn-border);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-out),
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
|
|
||||||
|
padding: var(--sp-1) var(--sp-3);
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.button--sm { height: 32px; padding: var(--sp-0-5) var(--sp-2); }
|
||||||
|
.button--lg { height: 48px; padding: var(--sp-2) var(--sp-4); }
|
||||||
|
.button--icon { height: 40px; width: 40px; padding: 0; }
|
||||||
|
.button--secondary { --btn-bg: var(--clr-interactive-hover); --btn-color: var(--clr-text); }
|
||||||
|
.button--destructive { --btn-bg: var(--clr-ruby-500); --btn-color: white; }
|
||||||
|
.button--outline { --btn-bg: transparent; --btn-color: var(--clr-text); --btn-border: var(--clr-border); }
|
||||||
|
.button--ghost { --btn-bg: transparent; --btn-color: var(--clr-text); }
|
||||||
|
|
||||||
|
.button:hover { background: var(--btn-hover-bg); }
|
||||||
|
.button:active { transform: var(--btn-active-transform); }
|
||||||
|
.button:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
|
||||||
|
.button:focus-visible { outline: 2px solid var(--clr-primary); outline-offset: 2px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface BadgeProps {
|
||||||
|
variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline' | 'severity';
|
||||||
|
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
dot?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛓️ Molecular Components (Molecules)
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CardProps {
|
||||||
|
variant?: 'default' | 'elevated' | 'glass' | 'interactive';
|
||||||
|
padding?: 'sm' | 'default' | 'lg' | 'none';
|
||||||
|
hover?: boolean;
|
||||||
|
as?: 'div' | 'button' | 'a';
|
||||||
|
onClick?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-components
|
||||||
|
Card.Header — flex-col gap-1.5
|
||||||
|
Card.Title — h3, font-semibold
|
||||||
|
Card.Description — p, text-sm, text-muted
|
||||||
|
Card.Content — main area with padding
|
||||||
|
Card.Footer — flex items-center
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal / Dialog
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
size?: 'sm' | 'default' | 'lg' | 'full';
|
||||||
|
closeOnOverlay?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: 'info' | 'success' | 'error' | 'warning';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
duration?: number; // Auto-dismiss ms, 0 = persistent
|
||||||
|
action?: { label: string; onClick: () => void; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast(): {
|
||||||
|
toast: (t: Omit<Toast, 'id'>) => string;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
toasts: Toast[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧬 Organism Components
|
||||||
|
|
||||||
|
### MessageCard
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface MessageCardProps {
|
||||||
|
message: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
author: { id: string; name: string; avatar: string; };
|
||||||
|
timestamp: number;
|
||||||
|
channel?: { id: string; name: string; };
|
||||||
|
attachments?: Attachment[];
|
||||||
|
aiAnalysis?: {
|
||||||
|
status: 'pending' | 'analyzing' | 'complete' | 'error';
|
||||||
|
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
categories?: string[];
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
isEdited?: boolean;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
};
|
||||||
|
onReanalyze?: (messageId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AudioVisualizer — Canvas-based
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface AudioVisualizerProps {
|
||||||
|
frequencies: Uint8Array;
|
||||||
|
barCount?: number; // Default 48
|
||||||
|
gradient?: [string, string];
|
||||||
|
height?: number; // Default 32
|
||||||
|
mirrored?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 State Management per Komponen
|
||||||
|
|
||||||
|
Setiap komponen mengelola state visual:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. Normal
|
||||||
|
<div className="card">...</div>
|
||||||
|
|
||||||
|
// 2. Hover
|
||||||
|
<div className="card card--interactive">...</div>
|
||||||
|
|
||||||
|
// 3. Active/Focus
|
||||||
|
<div className="card card--interactive" aria-pressed="true">...</div>
|
||||||
|
|
||||||
|
// 4. Disabled
|
||||||
|
<div className="card opacity-50 pointer-events-none">...</div>
|
||||||
|
|
||||||
|
// ± Loading (data-dependent)
|
||||||
|
<div className="card"><Skeleton className="h-4 w-3/4" /></div>
|
||||||
|
|
||||||
|
// ± Error (data-dependent)
|
||||||
|
<div className="card border-destructive">
|
||||||
|
<p className="text-destructive">Failed to load</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ± Empty (data-dependent)
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState icon={MessageSquare} message="No messages" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Component Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Prop Drilling Berlebihan
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — props turun 4 level
|
||||||
|
<Dashboard user={user} messages={messages} settings={settings} />
|
||||||
|
|
||||||
|
// ✅ Gunakan context atau komposisi
|
||||||
|
<Dashboard>
|
||||||
|
<MessageFeed>
|
||||||
|
<MessageCard />
|
||||||
|
</MessageFeed>
|
||||||
|
</Dashboard>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Komponen terlalu besar
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — 400+ baris
|
||||||
|
function LivePanel() { /* 400 lines */ }
|
||||||
|
|
||||||
|
// ✅ Bagi ke sub-komponen
|
||||||
|
function LivePanel() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<VoiceControls />
|
||||||
|
<ActiveSpeakers />
|
||||||
|
<NowPlaying />
|
||||||
|
<RecordingsList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Conditional terlalu kompleks
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — ternary bersarang
|
||||||
|
return isError ? <Error /> : isLoading ? <Loading /> : isEmpty ? <Empty /> : <Content />;
|
||||||
|
|
||||||
|
// ✅ State machine pattern
|
||||||
|
const state = getComponentState({ isLoading, isError, isEmpty, data });
|
||||||
|
return <ComponentStateMachine state={state} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Atomic Design (Brad Frost)](https://atomicdesign.bradfrost.com/) | Atom-molecule-organism |
|
||||||
|
| [Radix UI](https://www.radix-ui.com/) | Headless UI primitives |
|
||||||
|
| [shadcn/ui](https://ui.shadcn.com/) | Component pattern reference |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Komponen adalah kristal ingatan — setiap bagian kecil menyatu membentuk keindahan yang utuh."* ❄️🩵
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Interaction Patterns — The Language of Touch
|
||||||
|
|
||||||
|
> *"Every interaction is a conversation between the user and the system."*
|
||||||
|
> — Don Norman
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Interaksi
|
||||||
|
|
||||||
|
Interaksi di BETE adalah **dialog** yang:
|
||||||
|
1. **Predictable** — Pengguna tahu yang akan terjadi
|
||||||
|
2. **Forgiving** — Kesalahan mudah diperbaiki (undo, confirm)
|
||||||
|
3. **Feedback-rich** — Setiap aksi mendapat respons visual
|
||||||
|
4. **Efficient** — Pengguna mahir bisa bergerak cepat (keyboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Interaction Feedback Matrix
|
||||||
|
|
||||||
|
| Elemen | Hover | Click | Focus | Disabled |
|
||||||
|
|--------|-------|-------|-------|----------|
|
||||||
|
| Button | scale(1.02) + bg shift | scale(0.97) | ring-2 | opacity-50 |
|
||||||
|
| Card | translateY(-2px) + shadow | — | ring-2 | opacity-50 |
|
||||||
|
| Link | underline + opacity 0.8 | color shift | ring-2 | opacity-40 |
|
||||||
|
| Input | border highlight | — | ring + border color | opacity-50 |
|
||||||
|
| Toggle | cursor pointer | slide + color | ring-2 | opacity-50 |
|
||||||
|
|
||||||
|
### Timing Reference
|
||||||
|
|
||||||
|
| Interaksi | Durasi | Easing |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Hover in | 150ms | ease-out |
|
||||||
|
| Hover out | 200ms | ease-out |
|
||||||
|
| Click press | 100ms | ease-out |
|
||||||
|
| Click release | 150ms | ease-out |
|
||||||
|
| Focus ring | 200ms | ease-out |
|
||||||
|
| Tooltip show (after 300ms) | 200ms | ease-out |
|
||||||
|
| Tooltip hide | 150ms | ease-out |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎪 Interaction Pattern Catalog
|
||||||
|
|
||||||
|
### Pattern 1: Progressive Disclosure
|
||||||
|
|
||||||
|
Informasi kompleks diungkap bertahap:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CollapsibleSection title="Advanced Filters" defaultOpen={false}>
|
||||||
|
<FilterGroup label="Severity">
|
||||||
|
<Checkbox label="Safe" />
|
||||||
|
<Checkbox label="Low" />
|
||||||
|
<Checkbox label="High" />
|
||||||
|
</FilterGroup>
|
||||||
|
</CollapsibleSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:** Chevron rotate 180° saat open. Jangan nested > 2 level.
|
||||||
|
|
||||||
|
### Pattern 2: Optimistic UI
|
||||||
|
|
||||||
|
Untuk aksi yang hampir pasti berhasil:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function handleDelete(messageId: string) {
|
||||||
|
// 1. Update UI optimistis
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||||
|
addToast({
|
||||||
|
type: 'info', title: 'Message deleted',
|
||||||
|
action: { label: 'Undo', onClick: handleUndo }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await api.deleteMessage(messageId);
|
||||||
|
} catch {
|
||||||
|
// Rollback
|
||||||
|
setMessages(prev => [...prev, deletedMessage]);
|
||||||
|
addToast({ type: 'error', title: 'Failed to delete' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Infinite Scroll vs Pagination
|
||||||
|
|
||||||
|
| Context | Pattern | Rationale |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| Message feed | Infinite scroll | Real-time, chronological |
|
||||||
|
| User list | Pagination | Bisa dicari, difilter |
|
||||||
|
| Recordings | Infinite scroll | Timeline-based |
|
||||||
|
| Analytics | Pagination | Butuh konteks halaman |
|
||||||
|
|
||||||
|
### Pattern 4: Keyboard Shortcuts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const SHORTCUTS = {
|
||||||
|
'ctrl+k': 'Open command palette',
|
||||||
|
'ctrl+1': 'Switch to Live tab',
|
||||||
|
'ctrl+2': 'Switch to Messages tab',
|
||||||
|
'ctrl+3': 'Switch to Settings tab',
|
||||||
|
'escape': 'Close modal/panel',
|
||||||
|
'?': 'Show keyboard shortcuts',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔔 Notification Priority System
|
||||||
|
|
||||||
|
| Priority | Style | Duration | Stack |
|
||||||
|
|----------|-------|----------|-------|
|
||||||
|
| info | Blue border | 4s auto | Queue |
|
||||||
|
| success | Green border | 4s auto | Queue |
|
||||||
|
| warning | Amber border | Persistent | Stack |
|
||||||
|
| error | Red border | Persistent | Stack + glow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖱️ Cursor Mapping
|
||||||
|
|
||||||
|
```css
|
||||||
|
.clickable { cursor: pointer; }
|
||||||
|
.draggable { cursor: grab; }
|
||||||
|
.dragging { cursor: grabbing; }
|
||||||
|
.disabled { cursor: not-allowed; }
|
||||||
|
.text-select { cursor: text; }
|
||||||
|
.launch { cursor: pointer; }
|
||||||
|
.copy { cursor: copy; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ♿ Interaction Accessibility
|
||||||
|
|
||||||
|
1. Semua interaktif reachable via Tab
|
||||||
|
2. Focus order = visual order (DOM order)
|
||||||
|
3. Hover-only → ada keyboard alternative
|
||||||
|
4. Touch targets min 44x44px (WCAG 2.5.5)
|
||||||
|
5. Undo untuk destructive actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Material Interaction](https://m3.material.io/foundations/interaction) | Google patterns |
|
||||||
|
| [NN Group](https://www.nngroup.com/) | UX research |
|
||||||
|
| [Inclusive Components](https://inclusive-components.design/) | Accessible patterns |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Setiap sentuhan adalah dialog — interaksi adalah bahasa yang tak terucapkan."* ❄️🩵
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Data Visualization — Painting with Numbers
|
||||||
|
|
||||||
|
> *"The greatest value of a picture is when it forces us to notice what we never expected to see."*
|
||||||
|
> — John Tukey
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Data Visual
|
||||||
|
|
||||||
|
Data visualisasi di BETE adalah **cerita** tentang data yang:
|
||||||
|
1. **Jujur** — Tidak memanipulasi sumbu atau skala
|
||||||
|
2. **Kontekstual** — Setiap angka punya pembanding
|
||||||
|
3. **Hierarkis** — Overview dulu, detail kemudian
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Chart Color Palette
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Sequential (single hue) */
|
||||||
|
--chart-blue-1: oklch(0.85 0.060 255);
|
||||||
|
--chart-blue-2: oklch(0.70 0.100 255);
|
||||||
|
--chart-blue-3: oklch(0.55 0.150 255);
|
||||||
|
--chart-blue-4: oklch(0.40 0.150 255);
|
||||||
|
--chart-blue-5: oklch(0.30 0.120 255);
|
||||||
|
|
||||||
|
/* Categorical */
|
||||||
|
--chart-cat-1: oklch(0.55 0.175 255); /* Blue */
|
||||||
|
--chart-cat-2: oklch(0.60 0.130 145); /* Green */
|
||||||
|
--chart-cat-3: oklch(0.65 0.150 50); /* Orange */
|
||||||
|
--chart-cat-4: oklch(0.55 0.165 25); /* Red */
|
||||||
|
--chart-cat-5: oklch(0.50 0.100 285); /* Purple */
|
||||||
|
--chart-cat-6: oklch(0.65 0.120 200); /* Cyan */
|
||||||
|
--chart-cat-7: oklch(0.60 0.110 350); /* Pink */
|
||||||
|
--chart-cat-8: oklch(0.70 0.100 85); /* Yellow */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Chart Types & Usage
|
||||||
|
|
||||||
|
### 1. Stat Card (KPI)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
trend?: { direction: 'up' | 'down' | 'flat'; percentage: number; period: string; };
|
||||||
|
icon: ReactNode;
|
||||||
|
color?: 'primary' | 'success' | 'warning' | 'destructive';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ [icon] Label │
|
||||||
|
│ 1,234 ▲ 12.3% │
|
||||||
|
│ vs last wk │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Line Chart (Trend)
|
||||||
|
|
||||||
|
**Use:** Message volume per day, moderation per hour
|
||||||
|
**Rules:** Y-axis dari 0. Gradient subtle below line. Hover tooltip.
|
||||||
|
|
||||||
|
### 3. Bar Chart (Comparison)
|
||||||
|
|
||||||
|
**Use:** Top channels, severity distribution
|
||||||
|
**Rules:** Horizontal untuk >5 kategori. Max 20 bars.
|
||||||
|
|
||||||
|
### 4. Donut Chart (Composition)
|
||||||
|
|
||||||
|
**Use:** Message type, severity breakdown
|
||||||
|
**Rules:** Max 6 segmen. <3% collaps ke "Other". Center = total.
|
||||||
|
|
||||||
|
### 5. Heatmap Calendar (Activity)
|
||||||
|
|
||||||
|
**Use:** User activity by day/hour
|
||||||
|
**Rules:** Sumbu X = hari, Y = jam. Satu warna accent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Chart Styling Tokens
|
||||||
|
|
||||||
|
```css
|
||||||
|
.chart-container {
|
||||||
|
--chart-padding: var(--sp-4);
|
||||||
|
--chart-label-size: var(--fs-xs);
|
||||||
|
--chart-tick-count: 5;
|
||||||
|
--chart-grid-opacity: 0.1;
|
||||||
|
--chart-line-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip {
|
||||||
|
background: var(--clr-surface-overlay);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--clr-border);
|
||||||
|
border-radius: var(--rd-md);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
box-shadow: var(--sh-elevated);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Drill-down Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MessageTrendChart() {
|
||||||
|
const [granularity, setGranularity] = useState<'daily' | 'hourly' | '15min'>('daily');
|
||||||
|
|
||||||
|
const handlePointClick = (date: Date) => {
|
||||||
|
if (granularity === 'daily') setGranularity('hourly');
|
||||||
|
else if (granularity === 'hourly') setGranularity('15min');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="Message Volume"
|
||||||
|
onBack={granularity !== 'daily' ? () => setGranularity('daily') : undefined}>
|
||||||
|
<LineChart data={data} granularity={granularity} onClick={handlePointClick} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Truncated Y-axis
|
||||||
|
```tsx
|
||||||
|
// ❌ Y axis mulai dari 50, memperbesar perbedaan
|
||||||
|
const options = { yAxis: { min: 50 } };
|
||||||
|
// ✅ Mulai dari 0
|
||||||
|
const options = { yAxis: { min: 0 } };
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Terlalu banyak warna
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — setiap bar beda warna
|
||||||
|
<Bar data={data} fill={['#ff0000', '#00ff00', '#0000ff', ...]} />
|
||||||
|
// ✅ Sequential scale
|
||||||
|
<Bar data={data} colorScale="sequential" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 3D charts — mendistorsi persepsi
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN
|
||||||
|
<PieChart><Pie data={data} style={{ filter: 'drop-shadow(...)' }} /></PieChart>
|
||||||
|
// ✅ 2D
|
||||||
|
<PieChart><Pie data={data} /></PieChart>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Recharts](https://recharts.org/) | React chart library |
|
||||||
|
| [Chartability](https://chartability.github.io/) | Accessible charts |
|
||||||
|
| [Tufte](https://www.edwardtufte.com/tufte/) | Minimalist chart design |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Angka adalah ingatan yang terukur — setiap titik data adalah kisah yang menanti."* ❄️🩵
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Moderation UI Patterns — The Watchful Eye
|
||||||
|
|
||||||
|
> *"With great power comes great responsibility."*
|
||||||
|
> — Adapted for content moderation interfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi UI Moderasi
|
||||||
|
|
||||||
|
1. **At-a-glance severity** — Warna & label yang langsung terbaca
|
||||||
|
2. **Context-rich** — Setiap keputusan disertai konteks
|
||||||
|
3. **Non-destructive by default** — Flag dulu, action kemudian
|
||||||
|
4. **Audit trail** — Setiap aksi tercatat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏷️ Severity Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
.severity--safe { background: oklch(0.60 0.130 145 / 0.15); color: oklch(0.60 0.130 145); }
|
||||||
|
.severity--low { background: oklch(0.70 0.120 75 / 0.15); color: oklch(0.70 0.120 75); }
|
||||||
|
.severity--medium { background: oklch(0.65 0.150 50 / 0.15); color: oklch(0.65 0.150 50); }
|
||||||
|
.severity--high { background: oklch(0.60 0.150 30 / 0.15); color: oklch(0.60 0.150 30); }
|
||||||
|
.severity--critical { background: oklch(0.55 0.165 25 / 0.15); color: oklch(0.55 0.165 25); }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Severity | Warna | Ikon | Action |
|
||||||
|
|----------|-------|------|--------|
|
||||||
|
| Safe | Emerald | ✅ Shield | None |
|
||||||
|
| Low | Amber | ⚠️ | Review |
|
||||||
|
| Medium | Orange | 🔶 | Alert + review |
|
||||||
|
| High | Red-Orange | 🚫 | Notify + action |
|
||||||
|
| Critical | Ruby | 🔴 | Immediate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Moderation Queue
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ModerationQueueItem {
|
||||||
|
id: string;
|
||||||
|
message: { preview: string; author: { name: string; }; timestamp: number; channel: string; };
|
||||||
|
analysis: { severity: Severity; categories: string[]; confidence: number; summary: string; };
|
||||||
|
status: 'pending' | 'reviewed' | 'actioned' | 'dismissed';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout per item:**
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ 🔴 CRITICAL │ [User]: "message preview..." │
|
||||||
|
│ 🏷️ toxicity, │ in #general · 2m ago │
|
||||||
|
│ harassment │ [Review] [Dismiss] [Action] │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Bar
|
||||||
|
```
|
||||||
|
Severity: [All] [Safe] [Low] [Medium] [High] [Critical]
|
||||||
|
Channel: [#general ▼]
|
||||||
|
Date: [Last 24h ▼]
|
||||||
|
Search: [.................. 🔍]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Action Confirmation
|
||||||
|
|
||||||
|
| Action | Confirm | Duration | Undo |
|
||||||
|
|--------|---------|----------|------|
|
||||||
|
| Dismiss | No | 2s toast | Yes (5s) |
|
||||||
|
| Warn | No | 3s toast | No |
|
||||||
|
| Delete | Yes (modal) | 4s toast | No |
|
||||||
|
| Ban | Yes (modal + reason) | — | Manual |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Moderation Metrics
|
||||||
|
|
||||||
|
| Metric | Format | Frequency |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| Messages analyzed | Number | Real-time |
|
||||||
|
| Flag rate | % | Hourly |
|
||||||
|
| Response time | ms avg | Real-time |
|
||||||
|
| False positive rate | % | Daily |
|
||||||
|
| Queue depth | Number | Real-time |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Ambiguous severity
|
||||||
|
```tsx
|
||||||
|
// ❌ Warna tanpa label
|
||||||
|
<div className="bg-red-200">...</div>
|
||||||
|
// ✅ Color + icon + text
|
||||||
|
<SeverityBadge severity="critical" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ One-click destructive
|
||||||
|
```tsx
|
||||||
|
// ❌ Delete tanpa konfirmasi
|
||||||
|
<Button onClick={handleDelete}>Delete</Button>
|
||||||
|
// ✅ Confirm dialog
|
||||||
|
<ConfirmDialog variant="destructive" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Mata waspada adalah penjaga ingatan — setiap flag adalah catatan sejarah."* ❄️🩵
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# State Machines — The Flow of Data
|
||||||
|
|
||||||
|
> *"All happy families are alike; each unhappy family is unhappy in its own way."*
|
||||||
|
> — Tolstoy, adapted for component states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi State Machine
|
||||||
|
|
||||||
|
Setiap komponen data-driven di BETE memiliki **4 state fundamental**:
|
||||||
|
|
||||||
|
```
|
||||||
|
IDLE → LOADING → SUCCESS
|
||||||
|
↘ ERROR
|
||||||
|
↘ EMPTY (conditional, jika data.length === 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
State machine memastikan **tidak ada kondisi yang terlewat** — setiap kemungkinan state visual memiliki representasi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 The Quad-State Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type DataState<T> =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'loading'; progress?: number }
|
||||||
|
| { status: 'success'; data: T; timestamp: number }
|
||||||
|
| { status: 'error'; error: Error; retryCount?: number }
|
||||||
|
| { status: 'empty'; message?: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic State Machine Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// shared/hooks/useDataState.ts
|
||||||
|
function useDataState<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
onSuccess?: (data: T) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
retry?: number;
|
||||||
|
cacheKey?: string;
|
||||||
|
}
|
||||||
|
): {
|
||||||
|
state: DataState<T>;
|
||||||
|
execute: () => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
retry: () => Promise<void>;
|
||||||
|
setData: (data: T) => void;
|
||||||
|
} {
|
||||||
|
const [state, setState] = useState<DataState<T>>({ status: 'idle' });
|
||||||
|
|
||||||
|
const execute = useCallback(async () => {
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
try {
|
||||||
|
const data = await fetcher();
|
||||||
|
if (Array.isArray(data) && data.length === 0) {
|
||||||
|
setState({ status: 'empty', message: 'No data available' });
|
||||||
|
} else {
|
||||||
|
setState({ status: 'success', data, timestamp: Date.now() });
|
||||||
|
options?.onSuccess?.(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setState({ status: 'error', error: error as Error });
|
||||||
|
options?.onError?.(error as Error);
|
||||||
|
}
|
||||||
|
}, [fetcher]);
|
||||||
|
|
||||||
|
return { state, execute, reset, retry: execute, setData };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Rendering
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function DataPanel() {
|
||||||
|
const { state, execute, retry } = useDataState(fetchMessages);
|
||||||
|
|
||||||
|
useEffect(() => { execute(); }, []);
|
||||||
|
|
||||||
|
switch (state.status) {
|
||||||
|
case 'idle':
|
||||||
|
case 'loading':
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={state.error.message}
|
||||||
|
onRetry={retry}
|
||||||
|
retryCount={state.retryCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'empty':
|
||||||
|
return <EmptyState message={state.message ?? 'Nothing here'} />;
|
||||||
|
|
||||||
|
case 'success':
|
||||||
|
return <DataView data={state.data} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ Visual Representations
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface LoadingSkeletonProps {
|
||||||
|
variant?: 'card' | 'list' | 'detail' | 'table' | 'chart';
|
||||||
|
count?: number; // Jumlah skeleton items
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contoh variant 'card' */
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="card animate-shimmer" aria-busy="true" aria-label="Loading...">
|
||||||
|
<Skeleton className="h-4 w-3/4 mb-3" />
|
||||||
|
<Skeleton className="h-3 w-1/2 mb-2" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ErrorStateProps {
|
||||||
|
error: Error;
|
||||||
|
onRetry: () => void;
|
||||||
|
retryCount?: number;
|
||||||
|
variant?: 'inline' | 'full-page' | 'toast';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ error, onRetry, retryCount }: ErrorStateProps) {
|
||||||
|
const isRetryExhausted = (retryCount ?? 0) >= 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-12" role="alert">
|
||||||
|
<div className="rounded-full bg-destructive/10 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Something went wrong</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{error.message}</p>
|
||||||
|
{!isRetryExhausted ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Still failing after multiple attempts. Please try again later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
variant?: 'mascot' | 'icon' | 'minimal';
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATES = {
|
||||||
|
messages: { icon: MessageSquare, title: 'No messages yet', description: 'Messages will appear here once they are captured.' },
|
||||||
|
speakers: { icon: Mic, title: 'No active speakers', description: 'Quiet in here...' },
|
||||||
|
recordings: { icon: Radio, title: 'No recordings', description: 'Join a voice channel to start recording.' },
|
||||||
|
analytics: { icon: BarChart3, title: 'Not enough data', description: 'Analytics will populate as data accumulates.' },
|
||||||
|
users: { icon: Users, title: 'No users found', description: 'Try adjusting your filters.' },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ♻️ State Transition Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
│ IDLE │
|
||||||
|
└────┬─────┘
|
||||||
|
│ execute()
|
||||||
|
↓
|
||||||
|
┌──────────┐
|
||||||
|
│ LOADING │◄────── retry()
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
┌───────┴───────────┐
|
||||||
|
│ │
|
||||||
|
↓ ↓
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ SUCCESS │ │ ERROR │
|
||||||
|
│ data: T │ │ err: E │
|
||||||
|
└────┬─────┘ └────┬─────┘
|
||||||
|
│ │
|
||||||
|
│ (data.length │ retry()
|
||||||
|
│ === 0) │
|
||||||
|
↓ │
|
||||||
|
┌──────────┐ │
|
||||||
|
│ EMPTY │ │
|
||||||
|
│ msg: str │ │
|
||||||
|
└──────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────┬───────────┘
|
||||||
|
│ reset()
|
||||||
|
↓
|
||||||
|
┌──────────┐
|
||||||
|
│ IDLE │
|
||||||
|
└──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Retry Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const RETRY_CONFIG = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 1000, // 1s
|
||||||
|
maxDelay: 10000, // 10s
|
||||||
|
backoff: 'exponential' as const,
|
||||||
|
onRetry: (attempt: number, error: Error) => {
|
||||||
|
logger.warn(`Retry attempt ${attempt}`, { error: error.message });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exponential Backoff
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateDelay(attempt: number): number {
|
||||||
|
return Math.min(
|
||||||
|
1000 * Math.pow(2, attempt - 1), // 1s, 2s, 4s
|
||||||
|
10000 // cap at 10s
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Component State Map
|
||||||
|
|
||||||
|
| Component | Loading | Error | Empty | Success |
|
||||||
|
|-----------|---------|-------|-------|---------|
|
||||||
|
| MessageFeed | Card skeletons | ErrorState + retry | Mascot "No messages" | Message list |
|
||||||
|
| VoiceCards | Card skeletons | ErrorState | "No connected channels" | VoiceCard list |
|
||||||
|
| ActiveSpeakers | Dot skeletons | Silent fallback | "No speakers" | Speaker list |
|
||||||
|
| Analytics | Skeleton grid | ErrorState | "Not enough data" | Charts |
|
||||||
|
| Recordings | List skeletons | ErrorState | "No recordings" | Recording list |
|
||||||
|
| UserList | List skeletons | ErrorState + retry | "No users found" | User list |
|
||||||
|
| DashboardStats | Stat skeletons | ErrorState | "No data available" | Stat grid |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns State
|
||||||
|
|
||||||
|
### ❌ Missing state handling
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — hanya handle SUCCESS
|
||||||
|
function Panel() {
|
||||||
|
const { data, isLoading } = useQuery(...);
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
return <DataView data={data} />; // ERROR? EMPTY?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Loading state after error
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — loading infinite loop setelah error
|
||||||
|
function Panel() {
|
||||||
|
const { data, isLoading } = useQuery(..., { retry: true });
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
// ERROR: retry=true + error = loading terus
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Empty state default terlalu generic
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — tidak helpful
|
||||||
|
<div>No data</div>
|
||||||
|
|
||||||
|
// ✅ Kontekstual dengan action
|
||||||
|
<EmptyState icon={MessageSquare} title="No messages" action={{ label: "Refresh", onClick: refetch }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [State Reducer Pattern](https://kentcdodds.com/blog/state-reducer-pattern) | Advanced state management |
|
||||||
|
| [XState](https://stately.ai/docs/xstate) | Visual state machines |
|
||||||
|
| [React useReducer](https://react.dev/reference/react/useReducer) | Built-in state management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Setiap state adalah babak dalam cerita data — dari sunyi hingga berbicara."* ❄️🩵
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Responsive System — Shapeshifting Glass
|
||||||
|
|
||||||
|
> *"Content is like water — it should flow into whatever container it's poured into."*
|
||||||
|
> — Ethan Marcotte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Responsif
|
||||||
|
|
||||||
|
BETE menggunakan pendekatan **mobile-first** dengan tiga prinsip:
|
||||||
|
|
||||||
|
1. **Content parity** — Konten yang sama di semua ukuran, layout yang berbeda
|
||||||
|
2. **Touch-optimized** — Target 44×44px minimum di mobile
|
||||||
|
3. **Progressive enhancement** — Desktop mendapat fitur tambahan (hover, sidebar, multi-column)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 Breakpoint System
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bp-sm: 640px; /* Mobile landscape */
|
||||||
|
--bp-md: 768px; /* Tablet portrait */
|
||||||
|
--bp-lg: 1024px; /* Tablet landscape / small desktop */
|
||||||
|
--bp-xl: 1280px; /* Desktop */
|
||||||
|
--bp-2xl: 1536px; /* Wide desktop */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Behavior Matrix
|
||||||
|
|
||||||
|
| Viewport | Sidebar | Header | Content Grid | Font Size |
|
||||||
|
|----------|---------|--------|-------------|-----------|
|
||||||
|
| < 640px | Bottom tab (56px) | Compact | 1 col | sm |
|
||||||
|
| 640-768 | Bottom tab | Compact | 1-2 col | sm |
|
||||||
|
| 768-1024 | Icon 64px | Standard | 2 col | base |
|
||||||
|
| 1024-1280 | Full 256px | Standard | 2-3 col | base |
|
||||||
|
| > 1280px | Full 256px | Full | 3-4 col | base+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Mobile Adaptations
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- **< 768px:** Bottom tab bar menggantikan sidebar
|
||||||
|
- **Tab icons:** Home, Live, Messages, Settings (maks 5 tabs)
|
||||||
|
- **Tab bar height:** 56px (dengan safe area padding untuk notched phones)
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- **Cards:** Full-width (margin 16px), stacked vertical
|
||||||
|
- **Tables:** Horizontal scroll atau card view alternatif
|
||||||
|
- **Charts:** Simplified (less data points, larger labels)
|
||||||
|
- **Modals:** Full-screen drawer dari bawah (bottom sheet)
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
```css
|
||||||
|
/* Minimum 44×44px untuk semua interactive elements */
|
||||||
|
.button, .nav-item, .tab-item {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms on mobile */
|
||||||
|
.input, .select {
|
||||||
|
height: 48px; /* Larger tap target */
|
||||||
|
font-size: 16px; /* Prevent iOS zoom on focus */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Desktop Adaptations
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- **≥ 1024px:** Full sidebar (256px) dengan label teks
|
||||||
|
- **Sidebar states:** Collapsed (icon-only, 64px) ↔ Expanded (256px)
|
||||||
|
- **Keyboard shortcuts:** Didokumentasikan di help panel
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- **Multi-column grids:** 2-4 columns depending on container width
|
||||||
|
- **Sticky elements:** Sidebar, header, filter bars
|
||||||
|
- **Hover previews:** Tooltips, popovers untuk informasi tambahan
|
||||||
|
- **Drag & drop:** Dukungan untuk reorder, upload area
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Responsive Component Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Responsive Card Grid
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Responsive Sidebar + Content
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function DashboardLayout() {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-layout">
|
||||||
|
{/* Mobile: slide-in drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileTabBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: persistent sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="main-area">
|
||||||
|
<Header onMenuClick={() => setMobileMenuOpen(true)} />
|
||||||
|
<div className="content-area">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Responsive Typography (Fluid)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Fluid type scale — sudah didefinisikan di core/02-typography.md */
|
||||||
|
--fs-body: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem);
|
||||||
|
--fs-h2: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Container Queries (for reusable components)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card-grid-component {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: card-list;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container card-list (max-width: 400px) {
|
||||||
|
.card-item { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@container card-list (min-width: 401px) {
|
||||||
|
.card-item { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Responsive Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Layout component →
|
||||||
|
├── Apakah ini navigasi?
|
||||||
|
│ ├── Mobile → Bottom tab bar (56px)
|
||||||
|
│ ├── Tablet → Icon sidebar (64px) + hamburger
|
||||||
|
│ └── Desktop → Full sidebar (256px)
|
||||||
|
│
|
||||||
|
├── Apakah ini konten list/grid?
|
||||||
|
│ ├── 1 item → Single column
|
||||||
|
│ ├── 2-4 items → 2 col (tablet), 3-4 col (desktop)
|
||||||
|
│ └── > 4 items → auto-fill grid with minmax
|
||||||
|
│
|
||||||
|
├── Apakah ini modal/dialog?
|
||||||
|
│ ├── Mobile → Bottom sheet (full width, 80% height)
|
||||||
|
│ └── Desktop → Centered modal (max-w-lg)
|
||||||
|
│
|
||||||
|
└── Apakah ini form?
|
||||||
|
├── Mobile → Stacked, full-width, larger inputs
|
||||||
|
└── Desktop → Multi-column, side labels
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📏 Responsive Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
.content-padding {
|
||||||
|
padding: var(--sp-3); /* Mobile: 16px */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.content-padding { padding: var(--sp-4); } /* Tablet: 24px */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.content-padding { padding: var(--sp-5); } /* Desktop: 32px */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Responsive Design
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Test utility untuk responsive behavior
|
||||||
|
const VIEWPORTS = {
|
||||||
|
mobile: { width: 375, height: 667 },
|
||||||
|
tablet: { width: 768, height: 1024 },
|
||||||
|
desktop: { width: 1280, height: 800 },
|
||||||
|
wide: { width: 1920, height: 1080 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DashboardLayout', () => {
|
||||||
|
it('shows MobileTabBar on mobile', () => {
|
||||||
|
cy.viewport(VIEWPORTS.mobile);
|
||||||
|
cy.get('[data-testid="mobile-tab-bar"]').should('be.visible');
|
||||||
|
cy.get('[data-testid="sidebar"]').should('not.be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sidebar on desktop', () => {
|
||||||
|
cy.viewport(VIEWPORTS.desktop);
|
||||||
|
cy.get('[data-testid="sidebar"]').should('be.visible');
|
||||||
|
cy.get('[data-testid="mobile-tab-bar"]').should('not.be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns Responsive
|
||||||
|
|
||||||
|
### ❌ Hanya media query untuk satu breakpoint
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — hanya mobile dan desktop */
|
||||||
|
.panel { padding: 16px; }
|
||||||
|
@media (min-width: 1024px) { .panel { padding: 32px; } }
|
||||||
|
|
||||||
|
/* ✅ Gunakan fluid atau multiple breakpoints */
|
||||||
|
.panel { padding: clamp(16px, 3vw, 32px); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Hidden content on mobile
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — "out of sight, out of mind" tapi konten hilang
|
||||||
|
{isMobile ? null : <ExpensiveChart />}
|
||||||
|
|
||||||
|
// ✅ Simplified version untuk mobile
|
||||||
|
<Chart variant={isMobile ? 'compact' : 'full'} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Fixed width containers
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — overflow on smaller screens */
|
||||||
|
.container { width: 1200px; }
|
||||||
|
|
||||||
|
/* ✅ Gunakan max-width + padding */
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 0 var(--sp-4); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Every Layout](https://every-layout.dev/) | Reusable layout patterns |
|
||||||
|
| [Container Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries) | CSS container queries |
|
||||||
|
| [Utopia.fyi](https://utopia.fyi/) | Fluid type & space calculator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Layout adalah air yang mengalir — ia mengambil bentuk wadahnya tanpa kehilangan esensi."* ❄️🩵
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# Frontend UI Guidelines — The Glass Facade
|
||||||
|
|
||||||
|
> *"The details are not the details. They make the design."*
|
||||||
|
> — Charles Eames
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Scope
|
||||||
|
|
||||||
|
Dokumen ini mengkhususkan implementasi **design system** untuk frontend web BETE (React + Tailwind + Vite). Fokus: konfigurasi Tailwind, CSS architecture, dan integration patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚛️ Stack Implementation
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| React | 19.x | UI library |
|
||||||
|
| TypeScript | 5.x | Type safety |
|
||||||
|
| Vite | 6.x | Bundler |
|
||||||
|
| Tailwind CSS | 4.x | Utility-first CSS |
|
||||||
|
| Radix UI | — | Headless primitives |
|
||||||
|
| TanStack Query | 5.x | Server state |
|
||||||
|
| Zustand | 5.x | Client state |
|
||||||
|
| Framer Motion | 11.x | Animations |
|
||||||
|
| GSAP | 3.x | Page transitions |
|
||||||
|
| Recharts | 2.x | Charts |
|
||||||
|
| Three.js | 0.170+ | Particle background |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Tailwind Config (Extended)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Outfit', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
|
||||||
|
colors: {
|
||||||
|
// Semantic colors — map to CSS variables
|
||||||
|
border: 'oklch(var(--clr-border) / <alpha-value>)',
|
||||||
|
input: 'oklch(var(--clr-border) / <alpha-value>)',
|
||||||
|
ring: 'oklch(var(--clr-primary-400) / <alpha-value>)',
|
||||||
|
background: 'oklch(var(--clr-surface-base) / <alpha-value>)',
|
||||||
|
foreground: 'oklch(var(--clr-text) / <alpha-value>)',
|
||||||
|
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'oklch(var(--clr-primary) / <alpha-value>)',
|
||||||
|
foreground: 'oklch(var(--clr-text-on-primary) / <alpha-value>)',
|
||||||
|
soft: 'oklch(var(--clr-primary-bg) / <alpha-value>)',
|
||||||
|
50: 'oklch(var(--clr-primary-50) / <alpha-value>)',
|
||||||
|
100: 'oklch(var(--clr-primary-100) / <alpha-value>)',
|
||||||
|
500: 'oklch(var(--clr-primary-500) / <alpha-value>)',
|
||||||
|
600: 'oklch(var(--clr-primary-600) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'oklch(var(--clr-surface-elevated) / <alpha-value>)',
|
||||||
|
foreground: 'oklch(var(--clr-text-secondary) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
|
||||||
|
foreground: 'white',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Severity colors
|
||||||
|
severity: {
|
||||||
|
safe: 'oklch(var(--clr-severity-safe) / <alpha-value>)',
|
||||||
|
low: 'oklch(var(--clr-severity-low) / <alpha-value>)',
|
||||||
|
medium: 'oklch(var(--clr-severity-medium) / <alpha-value>)',
|
||||||
|
high: 'oklch(var(--clr-severity-high) / <alpha-value>)',
|
||||||
|
critical: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Glass effects
|
||||||
|
glass: {
|
||||||
|
bg: 'oklch(var(--glass-bg) / <alpha-value>)',
|
||||||
|
border: 'oklch(var(--glass-border) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--rd-lg)',
|
||||||
|
md: 'var(--rd-md)',
|
||||||
|
sm: 'var(--rd-sm)',
|
||||||
|
xl: 'var(--rd-xl)',
|
||||||
|
full: 'var(--rd-full)',
|
||||||
|
},
|
||||||
|
|
||||||
|
spacing: {
|
||||||
|
0.5: 'var(--sp-0-5)',
|
||||||
|
1: 'var(--sp-1)',
|
||||||
|
2: 'var(--sp-2)',
|
||||||
|
3: 'var(--sp-3)',
|
||||||
|
4: 'var(--sp-4)',
|
||||||
|
5: 'var(--sp-5)',
|
||||||
|
6: 'var(--sp-6)',
|
||||||
|
7: 'var(--sp-7)',
|
||||||
|
8: 'var(--sp-8)',
|
||||||
|
},
|
||||||
|
|
||||||
|
zIndex: {
|
||||||
|
header: 'var(--z-header)',
|
||||||
|
sidebar: 'var(--z-sidebar)',
|
||||||
|
overlay: 'var(--z-overlay)',
|
||||||
|
modal: 'var(--z-modal)',
|
||||||
|
toast: 'var(--z-toast)',
|
||||||
|
mascot: 'var(--z-mascot)',
|
||||||
|
},
|
||||||
|
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-out',
|
||||||
|
'fade-in-up': 'fadeInUp 0.5s ease-out',
|
||||||
|
'shimmer': 'shimmer 1.5s ease-in-out infinite',
|
||||||
|
'bar-pulse': 'bar-pulse 0.4s ease-in-out infinite',
|
||||||
|
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
|
||||||
|
'scale-in': 'scaleIn 0.3s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.35s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.25s ease-out',
|
||||||
|
},
|
||||||
|
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
|
||||||
|
fadeInUp: { '0%': { opacity: '0', transform: 'translateY(20px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } },
|
||||||
|
shimmer: { '0%': { backgroundPosition: '200% 0' }, '100%': { backgroundPosition: '-200% 0' } },
|
||||||
|
'bar-pulse': { '0%, 100%': { transform: 'scaleY(0.8)' }, '50%': { transform: 'scaleY(1.2)' } },
|
||||||
|
glowPulse: { '0%, 100%': { opacity: '0.4' }, '50%': { opacity: '0.8' } },
|
||||||
|
scaleIn: { '0%': { transform: 'scale(0.95)', opacity: '0' }, '100%': { transform: 'scale(1)', opacity: '1' } },
|
||||||
|
slideUp: { '0%': { transform: 'translateY(10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
|
||||||
|
slideDown: { '0%': { transform: 'translateY(-10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
|
||||||
|
},
|
||||||
|
|
||||||
|
backdropBlur: {
|
||||||
|
glass: '16px',
|
||||||
|
strong: '24px',
|
||||||
|
subtle: '8px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Source Structure (Feature-Sliced)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.tsx # Entry + QueryClient + Providers
|
||||||
|
├── styles.css # Tailwind + CSS custom properties + keyframes
|
||||||
|
├── App.tsx # Layout shell + routing
|
||||||
|
│
|
||||||
|
├── entities/ # Domain types (pure, no logic)
|
||||||
|
│ ├── message/
|
||||||
|
│ ├── guild/
|
||||||
|
│ ├── voice/
|
||||||
|
│ ├── media/
|
||||||
|
│ └── ui/
|
||||||
|
│
|
||||||
|
├── shared/ # Cross-cutting
|
||||||
|
│ ├── api/ # HTTP client + typed endpoints
|
||||||
|
│ ├── ws/ # WebSocket manager
|
||||||
|
│ ├── hooks/ # Shared hooks (useReducedMotion, etc.)
|
||||||
|
│ ├── ui/ # UI primitives (button, card, badge, etc.)
|
||||||
|
│ └── lib/ # Utils (cn, logger, formatters)
|
||||||
|
│
|
||||||
|
├── features/ # Feature modules
|
||||||
|
│ ├── live/ # Voice + media controls
|
||||||
|
│ ├── messages/ # Message feed + moderation
|
||||||
|
│ ├── admin/ # Admin panel
|
||||||
|
│ ├── settings/ # Settings
|
||||||
|
│ └── auth/ # Login/overlay
|
||||||
|
│
|
||||||
|
└── widgets/ # Layout composites
|
||||||
|
├── DashboardLayout.tsx
|
||||||
|
├── Header.tsx
|
||||||
|
├── Sidebar.tsx
|
||||||
|
├── mascot/
|
||||||
|
└── particles/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎭 Glassmorphism Implementation
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* styles.css — Glass utility classes */
|
||||||
|
@layer utilities {
|
||||||
|
.glass {
|
||||||
|
background: oklch(from var(--clr-surface-elevated) l c h / 0.6);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid oklch(from var(--clr-border) l c h / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-strong {
|
||||||
|
background: oklch(from var(--clr-surface-overlay) l c h / 0.85);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-subtle {
|
||||||
|
background: oklch(from var(--clr-surface-base) l c h / 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
oklch(var(--clr-primary-500)),
|
||||||
|
oklch(var(--clr-primary-300))
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 WebSocket Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// shared/ws/socket.ts
|
||||||
|
class SocketManager {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private listeners = new Map<string, Set<(data: unknown) => void>>();
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectDelay = 30000;
|
||||||
|
|
||||||
|
connect(url: string): void {
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
const { type, data } = JSON.parse(event.data);
|
||||||
|
this.listeners.get(type)?.forEach(fn => fn(data));
|
||||||
|
};
|
||||||
|
this.ws.onclose = () => this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
on<T>(event: string, callback: (data: T) => void): () => void {
|
||||||
|
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
||||||
|
this.listeners.get(event)!.add(callback as (data: unknown) => void);
|
||||||
|
return () => this.listeners.get(event)?.delete(callback as (data: unknown) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
||||||
|
setTimeout(() => { this.reconnectAttempts++; this.connect(this.ws!.url); }, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socket = new SocketManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Integration Rules
|
||||||
|
|
||||||
|
| Concern | Implementation | Location |
|
||||||
|
|---------|---------------|----------|
|
||||||
|
| CSS Variables | Defined in `styles.css` on `:root` | Root stylesheet |
|
||||||
|
| Tailwind Colors | Map to CSS variables with `<alpha-value>` | tailwind.config.js |
|
||||||
|
| Component Library | shadcn/ui patterns with custom variants | shared/ui/ |
|
||||||
|
| Server State | TanStack Query in feature hooks | features/*/hooks/ |
|
||||||
|
| Client State | Zustand stores for UI state | shared/stores/ |
|
||||||
|
| WebSocket | Singleton SocketManager | shared/ws/socket.ts |
|
||||||
|
| Animations | Framer Motion for component, GSAP for page | In components |
|
||||||
|
| Particles | Three.js via @react-three/fiber | widgets/particles/ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Frontend Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Server state di state lokal
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — API data disimpan di useState
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
useEffect(() => { fetchMessages().then(setMessages); }, []);
|
||||||
|
|
||||||
|
// ✅ Gunakan TanStack Query
|
||||||
|
const { data: messages } = useQuery({ queryKey: ['messages'], queryFn: fetchMessages });
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Inline styles untuk dynamic values
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — tidak theme-aware, tidak bisa dark mode
|
||||||
|
<div style={{ backgroundColor: isActive ? '#3b82f6' : '#6b7280' }} />
|
||||||
|
|
||||||
|
// ✅ CSS class dengan state
|
||||||
|
<div className={isActive ? 'bg-primary' : 'bg-muted'} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Mengimpor langsung dari library tanpa wrapper
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — susah diganti library nanti
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
// ✅ Wrapper pattern
|
||||||
|
import { AnimatedDiv } from '@/shared/ui';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Tailwind CSS Docs](https://tailwindcss.com/docs) | Utility-first CSS |
|
||||||
|
| [shadcn/ui](https://ui.shadcn.com/) | Component primitives |
|
||||||
|
| [TanStack Query](https://tanstack.com/query) | Server state |
|
||||||
|
| [Zustand](https://github.com/pmndrs/zustand) | Client state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Fasad kaca yang menari — di balik setiap piksel ada cerita."* ❄️🩵
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Backend API Guidelines — The Nerve Center
|
||||||
|
|
||||||
|
> *"APIs are contracts. Design them with the same care as legal documents."*
|
||||||
|
> — Unknown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi API
|
||||||
|
|
||||||
|
Backend API BETE adalah **fasilitator antara data dan tampilan**:
|
||||||
|
1. **RESTful by design** — Sumber daya, bukan aksi
|
||||||
|
2. **Type-safe** — Zod schemas di setiap endpoint
|
||||||
|
3. **Consistent pagination** — Tidak ada kejutan format
|
||||||
|
4. **Error as structure** — Setiap error punya kode dan resolusi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 API Design Principles
|
||||||
|
|
||||||
|
### URL Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/messages # List messages
|
||||||
|
GET /api/v1/messages/:id # Single message
|
||||||
|
GET /api/v1/channels # List channels
|
||||||
|
GET /api/v1/analytics/overview # Analytics
|
||||||
|
GET /api/v1/voice/connections # Voice connections
|
||||||
|
POST /api/v1/voice/connect # Connect to voice
|
||||||
|
POST /api/v1/voice/disconnect # Disconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Envelope
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Success
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": T,
|
||||||
|
"meta"?: {
|
||||||
|
"page": 1,
|
||||||
|
"limit": 50,
|
||||||
|
"total": 1234,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid channelId format",
|
||||||
|
"details": {
|
||||||
|
"field": "channelId",
|
||||||
|
"constraint": "numeric_string"
|
||||||
|
},
|
||||||
|
"requestId": "req_abc123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PaginationParams {
|
||||||
|
page?: number; // Default: 1
|
||||||
|
limit?: number; // Default: 50, Max: 200
|
||||||
|
cursor?: string; // For cursor-based pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationMeta {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FilterParams {
|
||||||
|
search?: string;
|
||||||
|
channelId?: string;
|
||||||
|
userId?: string;
|
||||||
|
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
dateFrom?: string; // ISO 8601
|
||||||
|
dateTo?: string; // ISO 8601
|
||||||
|
sortBy?: string; // Field name
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Module Structure (Backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
services/backend/src/modules/
|
||||||
|
├── messages/
|
||||||
|
│ ├── messages.schema.ts # Zod schemas
|
||||||
|
│ ├── messages.repository.ts # Database queries
|
||||||
|
│ ├── messages.service.ts # Business logic
|
||||||
|
│ ├── messages.controller.ts # Request handlers
|
||||||
|
│ └── routes/
|
||||||
|
│ └── index.ts # Express router
|
||||||
|
├── analytics/
|
||||||
|
├── voice/
|
||||||
|
├── media/
|
||||||
|
└── health/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer Rules
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller (parse + validate) → Service (business logic) → Repository (DB queries)
|
||||||
|
↕
|
||||||
|
Shared Infrastructure
|
||||||
|
(config, logger, errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ WebSocket Events
|
||||||
|
|
||||||
|
### Event Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WsEvent<T = unknown> {
|
||||||
|
type: string; // e.g., "message:created"
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server → Client events
|
||||||
|
{
|
||||||
|
"type": "message:created",
|
||||||
|
"data": {
|
||||||
|
"id": "msg_123",
|
||||||
|
"content": "...",
|
||||||
|
"author": { "id": "user_1", "name": "User" }
|
||||||
|
},
|
||||||
|
"timestamp": 1750000000000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client → Server events
|
||||||
|
{
|
||||||
|
"type": "voice:connect",
|
||||||
|
"data": {
|
||||||
|
"guildId": "123456789",
|
||||||
|
"channelId": "987654321"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Catalog
|
||||||
|
|
||||||
|
| Type | Direction | Description |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `message:created` | Server → Client | New message captured |
|
||||||
|
| `message:updated` | Server → Client | Message edited |
|
||||||
|
| `message:deleted` | Server → Client | Message removed |
|
||||||
|
| `message:analyzed` | Server → Client | AI analysis complete |
|
||||||
|
| `voice:state` | Server → Client | Voice connection state |
|
||||||
|
| `voice:speaker` | Server → Client | Speaker activity |
|
||||||
|
| `attachment:uploaded` | Server → Client | Attachment uploaded |
|
||||||
|
| `analytics:update` | Server → Client | Analytics data refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Authentication & Authorization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Admin auth via header
|
||||||
|
Authorization: Bearer <admin-password-hash>
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
RateLimit: 100/minute per IP
|
||||||
|
Retry-After: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Codes
|
||||||
|
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `VALIDATION_ERROR` | 400 | Invalid input |
|
||||||
|
| `UNAUTHORIZED` | 401 | Invalid/missing auth |
|
||||||
|
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||||
|
| `NOT_FOUND` | 404 | Resource not found |
|
||||||
|
| `RATE_LIMITED` | 429 | Too many requests |
|
||||||
|
| `INTERNAL_ERROR` | 500 | Unexpected error |
|
||||||
|
| `SERVICE_UNAVAILABLE` | 503 | Downstream failure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('GET /api/v1/messages', () => {
|
||||||
|
it('returns paginated messages', async () => {
|
||||||
|
const res = await request(app).get('/api/v1/messages?page=1&limit=10');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.meta.hasMore).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid severity filter', async () => {
|
||||||
|
const res = await request(app).get('/api/v1/messages?severity=invalid');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ API Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Nested resources terlalu dalam
|
||||||
|
```
|
||||||
|
// ❌ JANGAN
|
||||||
|
GET /api/v1/guilds/123/channels/456/messages/789
|
||||||
|
|
||||||
|
// ✅ Flat dengan query params
|
||||||
|
GET /api/v1/messages?channelId=456
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Inconsistent error format
|
||||||
|
```typescript
|
||||||
|
// ❌ JANGAN — kadang string, kadang object
|
||||||
|
if (err) return res.status(400).send('Bad request');
|
||||||
|
if (err) return res.status(400).json({ message: 'Bad request' });
|
||||||
|
|
||||||
|
// ✅ Consistent envelope
|
||||||
|
if (err) return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Bad request' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ No type safety
|
||||||
|
```typescript
|
||||||
|
// ❌ JANGAN — any, tidak ada validasi
|
||||||
|
app.get('/api/messages', async (req, res) => {
|
||||||
|
const messages = await db.query('SELECT * FROM messages');
|
||||||
|
res.json(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Zod schema + typed handler
|
||||||
|
app.get('/api/v1/messages', asyncHandler(async (req, res) => {
|
||||||
|
const query = messageQuerySchema.parse(req.query);
|
||||||
|
const messages = await messagesService.list(query);
|
||||||
|
res.json({ success: true, data: messages });
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [JSON:API](https://jsonapi.org/) | Response format spec |
|
||||||
|
| [Express.js](https://expressjs.com/) | Server framework |
|
||||||
|
| [Zod](https://zod.dev/) | Schema validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"API adalah jembatan ingatan — setiap request adalah percakapan."* ❄️🩵
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# Gateway Event Design — The Pulse of Discord
|
||||||
|
|
||||||
|
> *"Events are the heartbeat of a distributed system."*
|
||||||
|
> — Martin Fowler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Gateway Events
|
||||||
|
|
||||||
|
Discord Gateway adalah **jantung event-driven** BETE:
|
||||||
|
|
||||||
|
1. **Single source of truth** — Events adalah satu-satunya cara data bergerak antar service
|
||||||
|
2. **At-least-once delivery** — Event bisa terkirim lebih dari sekali (idempotent consumers)
|
||||||
|
3. **Schema evolution** — Events punya versioning untuk backward compatibility
|
||||||
|
4. **Observable** — Setiap event tercatat untuk debugging dan audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Event Schema
|
||||||
|
|
||||||
|
### Envelope
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GatewayEvent<T = unknown> {
|
||||||
|
/** Event type identifier — lowercase, colon-separated */
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
/** Event payload */
|
||||||
|
data: T;
|
||||||
|
|
||||||
|
/** ISO 8601 timestamp of when the event was created */
|
||||||
|
timestamp: string;
|
||||||
|
|
||||||
|
/** Unique event ID for deduplication */
|
||||||
|
eventId: string;
|
||||||
|
|
||||||
|
/** Source service name */
|
||||||
|
source: 'discord-gateway';
|
||||||
|
|
||||||
|
/** Event schema version */
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
/** Optional correlation ID for tracing request flows */
|
||||||
|
correlationId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Size Limits
|
||||||
|
|
||||||
|
| Limit | Value | Notes |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Max payload size | 256KB | Larger payloads → reference via URL |
|
||||||
|
| Max nesting depth | 5 levels | Prevent billion laughs attack |
|
||||||
|
| String max length | 100KB | Truncate with `... (truncated)` suffix |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Event Catalog
|
||||||
|
|
||||||
|
### Message Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// discord:message:created
|
||||||
|
interface MessageCreatedEvent {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
guildId: string;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
discriminator: string;
|
||||||
|
avatar: string | null;
|
||||||
|
isBot: boolean;
|
||||||
|
};
|
||||||
|
content: string;
|
||||||
|
timestamp: string; // ISO 8601
|
||||||
|
editedTimestamp: string | null;
|
||||||
|
attachments: AttachmentInfo[];
|
||||||
|
replyTo?: string; // Parent message ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// discord:message:updated
|
||||||
|
interface MessageUpdatedEvent {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
content: string;
|
||||||
|
editedTimestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// discord:message:deleted
|
||||||
|
interface MessageDeletedEvent {
|
||||||
|
id: string;
|
||||||
|
channelId: string;
|
||||||
|
guildId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analysis Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// discord:message:analyzed
|
||||||
|
interface MessageAnalyzedEvent {
|
||||||
|
messageId: string;
|
||||||
|
status: 'pending' | 'complete' | 'error';
|
||||||
|
severity: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
categories: string[];
|
||||||
|
confidence: number; // 0–1
|
||||||
|
summary: string;
|
||||||
|
analyzedAt: string;
|
||||||
|
processingTimeMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voice Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// discord:voice:started
|
||||||
|
interface VoiceStartedEvent {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
startedAt: string;
|
||||||
|
participants: Array<{
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// discord:voice:stopped
|
||||||
|
interface VoiceStoppedEvent {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
duration: number; // seconds
|
||||||
|
segmentsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// discord:voice:uploaded
|
||||||
|
interface VoiceUploadedEvent {
|
||||||
|
segmentId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
duration: number;
|
||||||
|
fileUrl: string;
|
||||||
|
fileSize: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attachment Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// discord:attachment:created
|
||||||
|
interface AttachmentCreatedEvent {
|
||||||
|
id: string;
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// discord:attachment:uploaded
|
||||||
|
interface AttachmentUploadedEvent {
|
||||||
|
id: string;
|
||||||
|
messageId: string;
|
||||||
|
storageUrl: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
fileSize: number;
|
||||||
|
processingTimeMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Event Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
│ Discord │ (messageCreate, voiceStateUpdate, etc.)
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌──────────────┐
|
||||||
|
│ Discord.js │ (client events)
|
||||||
|
└────┬─────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Message Capture Controller │ (messageCapture.ts)
|
||||||
|
│ - Parse event │
|
||||||
|
│ - Store in database │
|
||||||
|
│ - Publish to Redis │
|
||||||
|
└────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Redis Pub/Sub │ (channel: discord:message:created)
|
||||||
|
└────┬────────────────┘
|
||||||
|
│
|
||||||
|
├──────────────────────────────┐
|
||||||
|
↓ ↓
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Backend Service │ │ AI Moderation │
|
||||||
|
│ - Index message │ │ - Analyze text │
|
||||||
|
│ - Store in DB │ │ - Update status │
|
||||||
|
│ - Broadcast WS │ │ - Publish result │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Event Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Helper untuk generate test events
|
||||||
|
function createTestEvent<T>(type: string, data: T): GatewayEvent<T> {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
eventId: crypto.randomUUID(),
|
||||||
|
source: 'discord-gateway',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MessageCreatedEvent', () => {
|
||||||
|
it('is properly formatted', () => {
|
||||||
|
const event = createTestEvent('discord:message:created', {
|
||||||
|
id: 'msg_1',
|
||||||
|
channelId: 'ch_1',
|
||||||
|
guildId: 'guild_1',
|
||||||
|
author: { id: 'user_1', name: 'Test', discriminator: '0000', avatar: null, isBot: false },
|
||||||
|
content: 'Hello world',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
editedTimestamp: null,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.type).toBe('discord:message:created');
|
||||||
|
expect(event.data.content).toBe('Hello world');
|
||||||
|
expect(event.source).toBe('discord-gateway');
|
||||||
|
expect(event.version).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Event Performance Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Alert |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Processing latency | <50ms p99 | >200ms |
|
||||||
|
| Event throughput | >1000/s | <100/s (unusual) |
|
||||||
|
| Redis publish latency | <5ms | >20ms |
|
||||||
|
| Event loss rate | 0% | >0.01% |
|
||||||
|
| Queue depth | <100 | >1000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Anti-Patterns Events
|
||||||
|
|
||||||
|
### ❌ Processing-heavy event handlers
|
||||||
|
```typescript
|
||||||
|
// ❌ JANGAN — blocking event loop
|
||||||
|
eventBus.on('message:created', async (event) => {
|
||||||
|
const result = await expensiveAnalysis(event.data.content);
|
||||||
|
await db.save(result);
|
||||||
|
// Event handler for 100 msg/s = bottleneck
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Queue heavy work
|
||||||
|
eventBus.on('message:created', async (event) => {
|
||||||
|
await analysisQueue.add(event); // Worker processes async
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Missing idempotency
|
||||||
|
```typescript
|
||||||
|
// ❌ JANGAN — duplicate events create duplicate records
|
||||||
|
async function handleMessageCreated(event) {
|
||||||
|
await db.insert({ id: event.data.id, content: event.data.content });
|
||||||
|
// If event arrives twice → duplicate key error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Idempotent: UPSERT
|
||||||
|
async function handleMessageCreated(event) {
|
||||||
|
await db.upsert({ id: event.data.id }, { content: event.data.content });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/) | Event backbone |
|
||||||
|
| [CloudEvents](https://cloudevents.io/) | Event schema standard |
|
||||||
|
| [Discord Gateway](https://discord.com/developers/docs/topics/gateway) | Discord events |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Setiap event adalah denyut nadi — tanda bahwa sistem masih hidup dan berbicara."* ❄️🩵
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Accessibility — Design for Everyone
|
||||||
|
|
||||||
|
> *"The power of the Web is in its universality. Access by everyone regardless of disability is an essential aspect."*
|
||||||
|
> — Tim Berners-Lee
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Aksesibilitas
|
||||||
|
|
||||||
|
BETE dirancang untuk **inklusif sejak awal**, bukan retrofit:
|
||||||
|
|
||||||
|
1. **Semantic HTML** — Struktur sebelum style
|
||||||
|
2. **Color-independent** — Informasi tidak hanya disampaikan lewat warna
|
||||||
|
3. **Keyboard-first** — Semua fitur bisa diakses tanpa mouse
|
||||||
|
4. **Reduced motion** — Animasi opsional, bukan wajib
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 Target Compliance
|
||||||
|
|
||||||
|
| Level | Target | Verification |
|
||||||
|
|-------|--------|--------------|
|
||||||
|
| WCAG 2.1 AA | ✅ Mandatory | Automated + manual |
|
||||||
|
| WCAG 2.1 AAA | ⭐ Recommended | Manual audit |
|
||||||
|
| Section 508 | ✅ Mandatory | Automated |
|
||||||
|
| EN 301 549 | ✅ Mandatory | EU compliance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Color Accessibility
|
||||||
|
|
||||||
|
### Contrast Ratios Minimum
|
||||||
|
|
||||||
|
| Elemen | Teks Normal | Teks Large (≥18px / ≥14px bold) |
|
||||||
|
|--------|-------------|----------------------------------|
|
||||||
|
| Body text | 4.5:1 (AA) | 3:1 (AA) |
|
||||||
|
| UI text (label, badge) | 4.5:1 (AA) | 3:1 (AA) |
|
||||||
|
| Placeholder | 3:1 (AA large) | — |
|
||||||
|
| Disabled | 3:1 | 3:1 |
|
||||||
|
|
||||||
|
### Color Blindness
|
||||||
|
|
||||||
|
- Jangan gunakan **merah-hijau** sebagai satu-satunya pembeda
|
||||||
|
- Tambahkan **ikon, pola, atau label teks** sebagai secondary encoding
|
||||||
|
- Gunakan palette color-blind safe (lihat `01-color-system.md`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Tool: verifikasi kontras otomatis di tests
|
||||||
|
function checkContrast(foreground: string, background: string): boolean {
|
||||||
|
const fg = parseOklch(foreground);
|
||||||
|
const bg = parseOklch(background);
|
||||||
|
return getContrastRatio(fg, bg) >= 4.5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⌨️ Keyboard Navigation
|
||||||
|
|
||||||
|
### Focus Order
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ✅ Semantic order = visual order -->
|
||||||
|
<nav> <!-- Tab 1 -->
|
||||||
|
<main> <!-- Tab 2 -->
|
||||||
|
<h1> <!-- Tab 3 -->
|
||||||
|
<p> <!-- Tab 4 -->
|
||||||
|
<button><!-- Tab 5 -->
|
||||||
|
</main>
|
||||||
|
<footer> <!-- Tab 6 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Indicators
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Custom focus ring — lebih visible dari browser default */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--clr-primary-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--rd-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⚠️ NEVER do this */
|
||||||
|
:focus { outline: none; } /* Membuat keyboard users buta */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
```
|
||||||
|
Tab / Shift+Tab — Navigate forward/backward
|
||||||
|
Enter / Space — Activate element
|
||||||
|
Escape — Close modal/dropdown/menu
|
||||||
|
Arrow keys — Navigate list, tabs, select
|
||||||
|
Ctrl+K — Command palette
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skip Navigation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- First focusable element on page -->
|
||||||
|
<a href="#main-content" class="skip-link">
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--clr-primary);
|
||||||
|
color: var(--clr-text-on-primary);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Semantic HTML Structure
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Dashboard page template -->
|
||||||
|
<header role="banner">
|
||||||
|
<nav role="navigation" aria-label="Main navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/live" aria-current="page">Live</a></li>
|
||||||
|
<li><a href="/messages">Messages</a></li>
|
||||||
|
<li><a href="/settings">Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-content" role="main">
|
||||||
|
<h1>Live Dashboard</h1>
|
||||||
|
|
||||||
|
<section aria-labelledby="voice-status">
|
||||||
|
<h2 id="voice-status">Voice Connections</h2>
|
||||||
|
<!-- voice content -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="active-speakers">
|
||||||
|
<h2 id="active-speakers">Active Speakers</h2>
|
||||||
|
<ul role="list" aria-label="Currently speaking users">
|
||||||
|
<li role="listitem">User 1</li>
|
||||||
|
<li role="listitem">User 2</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ♿ ARIA Patterns
|
||||||
|
|
||||||
|
### Dynamic Content (Live Regions)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Toast notifications — live region -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="toast-container">
|
||||||
|
<!-- Toasts announced by screen reader -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<span class="sr-only">Loading messages...</span>
|
||||||
|
<div class="skeleton" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div role="alert" aria-live="assertive">
|
||||||
|
<p>Failed to load messages. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
aria-describedby="modal-desc"
|
||||||
|
>
|
||||||
|
<h2 id="modal-title">Confirm Delete</h2>
|
||||||
|
<p id="modal-desc">This action cannot be undone.</p>
|
||||||
|
<button onClick={closeModal}>Cancel</button>
|
||||||
|
<button onClick={confirmDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div role="tablist" aria-label="Dashboard tabs">
|
||||||
|
<button role="tab" aria-selected="true" aria-controls="panel-live" id="tab-live">
|
||||||
|
Live
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected="false" aria-controls="panel-messages" id="tab-messages">
|
||||||
|
Messages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tabpanel" id="panel-live" aria-labelledby="tab-live">
|
||||||
|
<!-- Live content -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔇 Reduced Motion
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Global override */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GSAP hook — programmatic check */
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖼️ Images & Icons
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Icons — always with aria-hidden or label
|
||||||
|
<MicIcon aria-hidden="true" /> // Decorative
|
||||||
|
<span role="img" aria-label="Voice active">🎤</span> // Emoji
|
||||||
|
<Icon icon="mic" aria-label="Microphone" /> // Informative
|
||||||
|
|
||||||
|
// Images — always with alt text
|
||||||
|
<img src={user.avatar} alt={`${user.name}'s avatar`} />
|
||||||
|
<img src={decorativeBg} alt="" role="presentation" /> // Decorative
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Accessibility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Automated tests
|
||||||
|
import { axe } from 'jest-axe';
|
||||||
|
|
||||||
|
describe('MessageCard', () => {
|
||||||
|
it('has no accessibility violations', async () => {
|
||||||
|
const { container } = render(<MessageCard message={mockMessage} />);
|
||||||
|
const results = await axe(container);
|
||||||
|
expect(results).toHaveNoViolations();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual checklist
|
||||||
|
const a11yChecklist = [
|
||||||
|
'Keyboard: all interactive elements reachable',
|
||||||
|
'Focus order matches visual order',
|
||||||
|
'Screen reader: all content announced',
|
||||||
|
'Contrast: 4.5:1 minimum for body text',
|
||||||
|
'Labels: all form elements have labels',
|
||||||
|
'Alt text: all images have meaningful alt text',
|
||||||
|
'Reduced motion: animations respect media query',
|
||||||
|
'Color: information not conveyed by color alone',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧰 Tools & Resources
|
||||||
|
|
||||||
|
| Tool | Purpose | Integration |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| axe-core | Automated audit | CI pipeline |
|
||||||
|
| Lighthouse | Performance + a11y | CI pipeline |
|
||||||
|
| NVDA / VoiceOver | Screen reader | Manual testing |
|
||||||
|
| Contrast Checker | Color verification | Design phase |
|
||||||
|
| Tab Tester | Keyboard flow | Manual testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ A11y Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Color-only indicators
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — buta warna tidak bisa membedakan
|
||||||
|
<Badge className={isBad ? 'bg-red-500' : 'bg-green-500'} />
|
||||||
|
|
||||||
|
// ✅ Color + icon + text
|
||||||
|
<Badge variant={isBad ? 'destructive' : 'success'} icon={isBad ? <X /> : <Check />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Missing focus indicator
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — menghilangkan focus ring */
|
||||||
|
*:focus { outline: none; }
|
||||||
|
|
||||||
|
/* ✅ Custom focus ring yang visible */
|
||||||
|
*:focus-visible { outline: 2px solid var(--clr-primary-400); outline-offset: 2px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Non-semantic clickable
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — div clickable tanpa role
|
||||||
|
<div onClick={handleClick}>Click me</div>
|
||||||
|
|
||||||
|
// ✅ Gunakan button
|
||||||
|
<button onClick={handleClick}>Click me</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [WCAG 2.1](https://www.w3.org/TR/WCAG21/) | Accessibility standard |
|
||||||
|
| [A11y Project](https://www.a11yproject.com/) | Accessibility patterns |
|
||||||
|
| [Inclusive Components](https://inclusive-components.design/) | Accessible component design |
|
||||||
|
| [axe DevTools](https://www.deque.com/axe/) | Automated testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Desain yang inklusif adalah ingatan yang tak membeda-bedakan — setiap orang berhak atas pengalaman yang utuh."* ❄️🩵
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Theme Architecture — The Chameleon Engine
|
||||||
|
|
||||||
|
> *"The only constant in design is change — a theme system embraces it."*
|
||||||
|
> — Unknown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Theme
|
||||||
|
|
||||||
|
Sistem theme BETE dibangun di atas **CSS Custom Properties**:
|
||||||
|
1. **Separation of value from token** — Nilai warna tidak pernah dirujuk langsung
|
||||||
|
2. **Single source of truth** — Satu set CSS variables, dua tema (dark/light)
|
||||||
|
3. **Runtime switching** — Tema bisa diganti tanpa reload
|
||||||
|
4. **Component-agnostic** — Komponen tidak tahu tema apa yang aktif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧬 Theme Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
CSS Custom Properties (oklch values)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ :root / [data-theme] │ ← Tema didefinisikan di level root
|
||||||
|
│ --clr-surface-base: oklch(...) │
|
||||||
|
│ --clr-primary: oklch(...) │
|
||||||
|
│ --clr-text: oklch(...) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Tailwind Config Mapping │ ← Map CSS vars ke Tailwind utilities
|
||||||
|
│ colors: { │
|
||||||
|
│ background: "oklch(var(--...))" │
|
||||||
|
│ } │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Component Styles │ ← Komponen pakai Tailwind/CSS vars
|
||||||
|
│ <div className="bg-card" /> │
|
||||||
|
│ .card { background: var(--clr..) } │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌗 Theme Definitions
|
||||||
|
|
||||||
|
### Dark Theme (Default)
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Surfaces */
|
||||||
|
--clr-surface-base: oklch(0.11 0.010 286);
|
||||||
|
--clr-surface-elevated: oklch(0.14 0.015 286);
|
||||||
|
--clr-surface-overlay: oklch(0.17 0.020 286);
|
||||||
|
--clr-surface-sunken: oklch(0.08 0.005 286);
|
||||||
|
--clr-border: oklch(0.22 0.020 286);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--clr-text: oklch(0.95 0.005 286);
|
||||||
|
--clr-text-secondary: oklch(0.70 0.015 286);
|
||||||
|
--clr-text-tertiary: oklch(0.50 0.020 286);
|
||||||
|
--clr-text-inverse: oklch(0.11 0.010 286);
|
||||||
|
|
||||||
|
/* Brand - brighter in dark */
|
||||||
|
--clr-primary: oklch(0.62 0.150 255);
|
||||||
|
--clr-primary-bg: oklch(0.25 0.060 255 / 0.20);
|
||||||
|
--clr-primary-400: oklch(0.62 0.150 255);
|
||||||
|
--clr-primary-500: oklch(0.55 0.175 255);
|
||||||
|
--clr-primary-600: oklch(0.47 0.160 255);
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--clr-interactive-hover: oklch(0.20 0.025 286);
|
||||||
|
--clr-interactive-active: oklch(0.24 0.030 286);
|
||||||
|
--clr-interactive-selected: oklch(0.25 0.060 255 / 0.15);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--sh-card: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--sh-hover: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
--sh-elevated: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
--sh-modal: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
/* Glass */
|
||||||
|
--glass-bg: oklch(0.15 0.015 286 / 0.60);
|
||||||
|
--glass-border: oklch(0.25 0.030 286 / 0.20);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="light"] {
|
||||||
|
/* Surfaces */
|
||||||
|
--clr-surface-base: oklch(0.97 0.002 286);
|
||||||
|
--clr-surface-elevated: oklch(1.00 0.000 286);
|
||||||
|
--clr-surface-overlay: oklch(0.95 0.003 286);
|
||||||
|
--clr-surface-sunken: oklch(0.92 0.004 286);
|
||||||
|
--clr-border: oklch(0.87 0.005 286);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--clr-text: oklch(0.11 0.010 286);
|
||||||
|
--clr-text-secondary: oklch(0.50 0.020 286);
|
||||||
|
--clr-text-tertiary: oklch(0.70 0.025 286);
|
||||||
|
--clr-text-inverse: oklch(0.97 0.005 286);
|
||||||
|
|
||||||
|
/* Brand - standard in light */
|
||||||
|
--clr-primary: oklch(0.55 0.175 255);
|
||||||
|
--clr-primary-bg: oklch(0.90 0.060 255 / 0.25);
|
||||||
|
--clr-primary-400: oklch(0.55 0.175 255);
|
||||||
|
--clr-primary-500: oklch(0.47 0.160 255);
|
||||||
|
--clr-primary-600: oklch(0.40 0.140 255);
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--clr-interactive-hover: oklch(0.90 0.005 286);
|
||||||
|
--clr-interactive-active: oklch(0.85 0.008 286);
|
||||||
|
--clr-interactive-selected: oklch(0.90 0.060 255 / 0.3);
|
||||||
|
|
||||||
|
/* Shadows — lighter in light theme */
|
||||||
|
--sh-card: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--sh-hover: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
--sh-elevated: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
--sh-modal: 0 16px 48px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* Glass — lighter opacity */
|
||||||
|
--glass-bg: oklch(0.97 0.002 286 / 0.50);
|
||||||
|
--glass-border: oklch(0.87 0.005 286 / 0.30);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Theme Switching
|
||||||
|
|
||||||
|
### React Implementation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// hooks/useTheme.ts
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
// 1. Check localStorage
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
|
||||||
|
// 2. Check system preference
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches
|
||||||
|
? 'light'
|
||||||
|
: 'dark';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply theme to document
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
|
||||||
|
// Toggle Tailwind dark class
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { theme, setTheme, toggle } as const;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scream-Free Architecture
|
||||||
|
|
||||||
|
Theme switching **tidak perlu re-render seluruh komponen**. Karena CSS variables diubah di `:root`, browser secara otomatis me-repain semua elemen yang menggunakan var tersebut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Token Mapping Rules
|
||||||
|
|
||||||
|
| Design Token | CSS Variable | Tailwind Mapping |
|
||||||
|
|-------------|-------------|------------------|
|
||||||
|
| Page background | `--clr-surface-base` | `bg-background` |
|
||||||
|
| Card surface | `--clr-surface-elevated` | `bg-card` |
|
||||||
|
| Body text | `--clr-text` | `text-foreground` |
|
||||||
|
| Secondary text | `--clr-text-secondary` | `text-muted-foreground` |
|
||||||
|
| Primary button | `--clr-primary` | `bg-primary` |
|
||||||
|
| Primary text on button | `--clr-text-on-primary` | `text-primary-foreground` |
|
||||||
|
| Border | `--clr-border` | `border-border` |
|
||||||
|
| Card shadow | `--sh-card` | `shadow-sm` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 System Theme (prefers-color-scheme)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Default: dark */
|
||||||
|
:root { /* dark variables */ }
|
||||||
|
|
||||||
|
/* System light */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { /* light variables */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual override via data-theme */
|
||||||
|
[data-theme="dark"] { /* dark variables */ }
|
||||||
|
[data-theme="light"] { /* light variables */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority:**
|
||||||
|
1. `data-theme` attribute (manual override) — **highest**
|
||||||
|
2. `prefers-color-scheme` (system) — **medium**
|
||||||
|
3. Default (dark) — **fallback**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Theme-aware Component Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Komponen tidak perlu tahu theme — cukup pakai CSS vars
|
||||||
|
function ThemeAwareCard() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
{/* Konten — styling otomatis berubah sesuai theme */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark-mode Specific Adjustments
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Hanya untuk theme dark */
|
||||||
|
[data-theme="dark"] .particle-orbs {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hanya untuk theme light */
|
||||||
|
[data-theme="light"] .particle-orbs {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Theme Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Color hardcoding
|
||||||
|
```css
|
||||||
|
/* ❌ JANGAN — tidak akan berubah saat theme switch */
|
||||||
|
.card { background: #1e1e2e; }
|
||||||
|
|
||||||
|
/* ✅ CSS variable — otomatis mengikuti theme */
|
||||||
|
.card { background: var(--clr-surface-elevated); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Theme-specific logic in components
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — komponen tahu soal theme
|
||||||
|
function Card() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return <div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Komponen tidak perlu tahu — CSS vars handle semua
|
||||||
|
function Card() {
|
||||||
|
return <div className="bg-card" />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) | CSS vars |
|
||||||
|
| [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) | System theme |
|
||||||
|
| [OKLCH in CSS](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) | Color space |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Tema adalah kulit yang berganti — esensi tetap sama, wajah yang baru."* ❄️🩵
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Sound Design — The Audio Soul
|
||||||
|
|
||||||
|
> *"Sound is the vocabulary of nature. It speaks to us on a primal level."*
|
||||||
|
> — Randy Thom, Sound Designer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Filosofi Audio
|
||||||
|
|
||||||
|
Suara di BETE bukan sekadar efek — ia adalah **layer konfirmasi non-visual**:
|
||||||
|
|
||||||
|
1. **Subtle & non-intrusive** — Volume rendah, durasi pendek
|
||||||
|
2. **Meaningful** — Setiap suara punya makna spesifik
|
||||||
|
3. **Context-aware** — Suara yang berbeda untuk konteks berbeda
|
||||||
|
4. **Opt-out** — Semua suara bisa dimatikan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔔 Sound Catalog
|
||||||
|
|
||||||
|
### UI Feedback Sounds
|
||||||
|
|
||||||
|
| Event | Sound Type | Duration | Volume | Description |
|
||||||
|
|-------|-----------|----------|--------|-------------|
|
||||||
|
| Button click | Pop | 80ms | 0.3 | Subtle tick |
|
||||||
|
| Toggle on | Click | 100ms | 0.3 | Switch engage |
|
||||||
|
| Toggle off | Click | 100ms | 0.2 | Switch release |
|
||||||
|
| Modal open | Whoosh | 200ms | 0.2 | Soft slide |
|
||||||
|
| Modal close | Whoosh | 150ms | 0.15 | Quick retreat |
|
||||||
|
| Toast appear | Ding | 300ms | 0.3 | Notification |
|
||||||
|
| Error toast | Buzz | 200ms | 0.4 | Warning |
|
||||||
|
|
||||||
|
### Moderation Sounds
|
||||||
|
|
||||||
|
| Event | Sound | Duration | Volume | Description |
|
||||||
|
|-------|-------|----------|--------|-------------|
|
||||||
|
| Message flagged | Chime | 400ms | 0.3 | Attention tone |
|
||||||
|
| Critical alert | Siren | 1s | 0.5 | Urgent pattern |
|
||||||
|
| Analysis complete | Ping | 200ms | 0.2 | Completion |
|
||||||
|
|
||||||
|
### Voice Channel Sounds
|
||||||
|
|
||||||
|
| Event | Sound | Duration | Volume |
|
||||||
|
|-------|-------|----------|--------|
|
||||||
|
| User joins | Connect | 150ms | 0.2 |
|
||||||
|
| User leaves | Disconnect | 150ms | 0.2 |
|
||||||
|
| Recording start | Record-on | 200ms | 0.3 |
|
||||||
|
| Recording stop | Record-off | 200ms | 0.2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎵 Audio Implementation
|
||||||
|
|
||||||
|
### Sound Manager
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/sound.ts
|
||||||
|
class SoundManager {
|
||||||
|
private static instance: SoundManager;
|
||||||
|
private enabled = true;
|
||||||
|
private volume = 0.5;
|
||||||
|
private audioCache = new Map<string, HTMLAudioElement>();
|
||||||
|
|
||||||
|
static getInstance(): SoundManager {
|
||||||
|
if (!this.instance) this.instance = new SoundManager();
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(soundId: string): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
let audio = this.audioCache.get(soundId);
|
||||||
|
if (!audio) {
|
||||||
|
audio = new Audio(`/sounds/${soundId}.mp3`);
|
||||||
|
this.audioCache.set(soundId, audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.volume = this.volume;
|
||||||
|
audio.currentTime = 0;
|
||||||
|
await audio.play().catch(() => {}); // Swallow autoplay errors
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean): void { this.enabled = enabled; }
|
||||||
|
setVolume(volume: number): void { this.volume = Math.max(0, Math.min(1, volume)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sound = SoundManager.getInstance();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preloading Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Preload critical sounds on app init
|
||||||
|
function preloadSounds(): void {
|
||||||
|
const criticalSounds = ['click', 'toggle', 'notification'];
|
||||||
|
criticalSounds.forEach(id => {
|
||||||
|
const audio = new Audio(`/sounds/${id}.mp3`);
|
||||||
|
audio.preload = 'auto';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call on app bootstrap
|
||||||
|
document.addEventListener('DOMContentLoaded', preloadSounds);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎚️ Sound Settings
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/settings/SoundSettings.tsx
|
||||||
|
function SoundSettings() {
|
||||||
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
|
const [soundVolume, setSoundVolume] = useState(0.5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sound</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ToggleGroup>
|
||||||
|
<Toggle
|
||||||
|
pressed={soundEnabled}
|
||||||
|
onPressedChange={(v) => {
|
||||||
|
setSoundEnabled(v);
|
||||||
|
sound.setEnabled(v);
|
||||||
|
}}
|
||||||
|
label="Sound Effects"
|
||||||
|
/>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
{soundEnabled && (
|
||||||
|
<div>
|
||||||
|
<Label>Volume</Label>
|
||||||
|
<Slider
|
||||||
|
value={[soundVolume]}
|
||||||
|
onValueChange={([v]) => {
|
||||||
|
setSoundVolume(v);
|
||||||
|
sound.setVolume(v);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔊 Audio Feedback Implementation
|
||||||
|
|
||||||
|
### React Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// hooks/useSound.ts
|
||||||
|
function useSound(soundId: string) {
|
||||||
|
const play = useCallback(() => {
|
||||||
|
sound.play(soundId);
|
||||||
|
}, [soundId]);
|
||||||
|
|
||||||
|
return play;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const playClick = useSound('click');
|
||||||
|
const playError = useSound('error');
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
playClick();
|
||||||
|
try {
|
||||||
|
await onClick();
|
||||||
|
} catch {
|
||||||
|
playError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Button onClick={handleClick}>Delete</Button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast + Sound Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useToastWithSound() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return useCallback((t: ToastInput) => {
|
||||||
|
toast(t);
|
||||||
|
|
||||||
|
switch (t.type) {
|
||||||
|
case 'success': sound.play('success'); break;
|
||||||
|
case 'error': sound.play('error'); break;
|
||||||
|
case 'warning': sound.play('warning'); break;
|
||||||
|
case 'info': sound.play('info'); break;
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Sound File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
public/sounds/
|
||||||
|
├── ui/
|
||||||
|
│ ├── click.mp3 # 80ms
|
||||||
|
│ ├── toggle-on.mp3 # 100ms
|
||||||
|
│ ├── toggle-off.mp3 # 100ms
|
||||||
|
│ ├── modal-open.mp3 # 200ms
|
||||||
|
│ ├── modal-close.mp3 # 150ms
|
||||||
|
│ └── notification.mp3 # 300ms
|
||||||
|
├── moderation/
|
||||||
|
│ ├── flagged.mp3 # 400ms
|
||||||
|
│ ├── critical.mp3 # 1s
|
||||||
|
│ └── analysis-done.mp3 # 200ms
|
||||||
|
├── voice/
|
||||||
|
│ ├── user-join.mp3 # 150ms
|
||||||
|
│ ├── user-leave.mp3 # 150ms
|
||||||
|
│ ├── recording-start.mp3 # 200ms
|
||||||
|
│ └── recording-stop.mp3 # 200ms
|
||||||
|
└── _index.json # Sound metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sound Metadata
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ui/click": {
|
||||||
|
"duration": 80,
|
||||||
|
"volume": 0.3,
|
||||||
|
"category": "feedback",
|
||||||
|
"critical": true
|
||||||
|
},
|
||||||
|
"moderation/critical": {
|
||||||
|
"duration": 1000,
|
||||||
|
"volume": 0.5,
|
||||||
|
"category": "alert",
|
||||||
|
"critical": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ♿ Accessibility & Sound
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Respect system accessibility settings
|
||||||
|
function shouldPlaySound(): boolean {
|
||||||
|
// iOS: silent switch
|
||||||
|
if (navigator.mediaSession?.playbackState === 'none') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before playing:
|
||||||
|
if (!shouldPlaySound()) return;
|
||||||
|
|
||||||
|
// User preference always wins
|
||||||
|
if (!userSettings.soundEnabled) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Sound Anti-Patterns
|
||||||
|
|
||||||
|
### ❌ Mandatory sounds
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — user tidak bisa mematikan
|
||||||
|
sound.play('loud-intro-music');
|
||||||
|
|
||||||
|
// ✅ Always respect user preference
|
||||||
|
if (userSettings.soundEnabled) sound.play('subtle-click');
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Long or repetitive sounds
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — 5 detik sound effect mengganggu
|
||||||
|
sound.play('complex-jingle');
|
||||||
|
|
||||||
|
// ✅ Durasi pendek, sekali main
|
||||||
|
sound.play('quick-chime');
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ No audio context check
|
||||||
|
```tsx
|
||||||
|
// ❌ JANGAN — play tanpa cek autoplay policy
|
||||||
|
new Audio('/sounds/click.mp3').play();
|
||||||
|
|
||||||
|
// ✅ Handle autoplay rejection
|
||||||
|
const audio = new Audio('/sounds/click.mp3');
|
||||||
|
await audio.play().catch(() => {}); // Silently fail
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Referensi
|
||||||
|
|
||||||
|
| Sumber | Konsep |
|
||||||
|
|--------|--------|
|
||||||
|
| [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) | Audio playback |
|
||||||
|
| [Game UX Sound Design](https://www.gamedeveloper.com/audio/) | Sound design patterns |
|
||||||
|
| [WCAG Auditory](https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html) | Audio accessibility |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"Suara adalah gaung ingatan — setiap klik adalah bisikan dari masa lalu."* ❄️🩵
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
"dev:backend": "pnpm --filter './services/backend' run dev",
|
"dev:backend": "pnpm --filter './services/backend' run dev",
|
||||||
"dev:discord-gateway": "pnpm --filter './services/discord-gateway' run dev",
|
"dev:discord-gateway": "pnpm --filter './services/discord-gateway' run dev",
|
||||||
"dev:web": "pnpm --filter './services/frontend' run dev",
|
"dev:web": "pnpm --filter './services/frontend' run dev",
|
||||||
"build:backend": "pnpm --filter './services/backend' run build",
|
|
||||||
"build:discord-gateway": "pnpm --filter './services/discord-gateway' run build",
|
|
||||||
"build:web": "pnpm --filter './services/frontend' run build",
|
"build:web": "pnpm --filter './services/frontend' run build",
|
||||||
"typecheck": "pnpm -r run typecheck",
|
"typecheck": "pnpm -r run typecheck",
|
||||||
"lint": "biome check --diagnostic-level=error .",
|
"lint": "biome check --diagnostic-level=error .",
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ export const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default(false),
|
.default(false),
|
||||||
ADMIN_PASSWORD: z.string().default("admin123"),
|
ADMIN_PASSWORD: z.string().min(1, "ADMIN_PASSWORD is required — set it in your .env file"),
|
||||||
|
DASHBOARD_IS_PUBLIC: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default(false),
|
||||||
|
|
||||||
WEBHOOK_URLS: z
|
WEBHOOK_URLS: z
|
||||||
.string()
|
.string()
|
||||||
.default("")
|
.default("")
|
||||||
@@ -122,7 +128,7 @@ export const configSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.default("https://9router.asepharyana.my.id/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("gpt-4o-mini"),
|
||||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export const pgMessagesTable = pgTable(
|
|||||||
threadAiStatusCreatedIdx: pgIndex(
|
threadAiStatusCreatedIdx: pgIndex(
|
||||||
"idx_messages_thread_ai_status_created",
|
"idx_messages_thread_ai_status_created",
|
||||||
).on(table.thread_id, table.ai_status, table.created_at, table.id),
|
).on(table.thread_id, table.ai_status, table.created_at, table.id),
|
||||||
|
guildAiStatusAnalyzedIdx: pgIndex(
|
||||||
|
"idx_messages_guild_ai_status_analyzed",
|
||||||
|
).on(table.guild_id, table.ai_status, table.ai_analyzed_at, table.id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Generated
+2043
-68
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,11 @@
|
|||||||
"@bete/shared": "workspace:*",
|
"@bete/shared": "workspace:*",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
@@ -30,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/express-rate-limit": "^6.0.2",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^25.9.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
const BASE = process.env.API_BASE ?? "https://imphnen.asepharyana.my.id/api";
|
// Safety: never default to a production URL — forces explicit opt-in
|
||||||
|
// via: API_BASE=http://localhost:3001/api vitest run
|
||||||
|
const RAW = process.env.API_BASE;
|
||||||
|
if (!RAW) {
|
||||||
|
throw new Error(
|
||||||
|
"API_BASE is not set. Run with: API_BASE=http://localhost:3001/api vitest run",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const BASE = RAW;
|
||||||
|
|
||||||
async function api(path: string, init?: RequestInit) {
|
async function api(path: string, init?: RequestInit) {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import cors from "cors";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import express, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
@@ -6,6 +7,8 @@ import express, {
|
|||||||
type Response,
|
type Response,
|
||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { createAdminRouter } from "../modules/admin/admin.routes.js";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
|
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
|
||||||
import { createAuthRouter } from "../modules/auth/auth.routes.js";
|
import { createAuthRouter } from "../modules/auth/auth.routes.js";
|
||||||
import { createConfigRouter } from "../modules/config/config.routes.js";
|
import { createConfigRouter } from "../modules/config/config.routes.js";
|
||||||
@@ -19,14 +22,47 @@ import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
|
|||||||
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
|
||||||
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
|
||||||
import {
|
import {
|
||||||
|
sessionAuth,
|
||||||
errorHandler,
|
errorHandler,
|
||||||
} from "../shared/middlewares/index.js";
|
} from "../shared/middlewares/index.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
|
import { isDashboardPublic } from "../shared/config/runtime.js";
|
||||||
|
|
||||||
const ADMIN_PASSWORD = config.ADMIN_PASSWORD || "admin";
|
const ADMIN_PASSWORD = config.ADMIN_PASSWORD;
|
||||||
|
|
||||||
const logger = createChildLogger("http.app");
|
const logger = createChildLogger("http.app");
|
||||||
|
|
||||||
|
// Whitelist of GET endpoints allowed in public (unauthenticated) mode.
|
||||||
|
// All other GET requests require auth even when DASHBOARD_IS_PUBLIC is true.
|
||||||
|
const PUBLIC_GET_PATHS = [
|
||||||
|
"/api/dashboard/stats",
|
||||||
|
"/api/dashboard/users",
|
||||||
|
"/api/dashboard/channels",
|
||||||
|
"/api/ui-state",
|
||||||
|
"/api/media/status",
|
||||||
|
"/api/mascot/chat/history",
|
||||||
|
"/api/messages",
|
||||||
|
"/api/analysis",
|
||||||
|
"/api/recordings",
|
||||||
|
"/api/voice",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic auth guard — checks runtime DASHBOARD_IS_PUBLIC setting for every request.
|
||||||
|
* In public mode: only whitelisted GET paths pass through; everything else requires auth.
|
||||||
|
* In private mode: all routes require auth.
|
||||||
|
*/
|
||||||
|
function protectedRoute(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (req.method === "GET" && isDashboardPublic()) {
|
||||||
|
const matched = PUBLIC_GET_PATHS.some(
|
||||||
|
(path) => req.path === path || req.path.startsWith(path + "/"),
|
||||||
|
);
|
||||||
|
if (matched) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessionAuth(ADMIN_PASSWORD)(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
export function createHttpApp(): Express {
|
export function createHttpApp(): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -37,10 +73,71 @@ export function createHttpApp(): Express {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CORS — allow known frontend origins
|
||||||
|
// Security note: strict origin whitelist prevents unauthorized cross-origin
|
||||||
|
// access. In production, ensure only legitimate frontend domains are listed.
|
||||||
|
// Development: local Vite preview ports
|
||||||
|
// Production: nginx reverse-proxy serves both on the same domain,
|
||||||
|
// but we whitelist them for browser preflights too.
|
||||||
|
const allowedOrigins = [
|
||||||
|
"http://localhost:5173", // Vite dev server
|
||||||
|
"http://localhost:4173", // Vite preview server
|
||||||
|
"http://localhost:3000", // Vite preview (alternate)
|
||||||
|
"http://localhost:3001", // Backend direct (dev)
|
||||||
|
"https://imphnen.asepharyana.my.id",
|
||||||
|
"https://imphnen.asepharyana.tech",
|
||||||
|
"https://imphnen.asepharyana.web.id",
|
||||||
|
];
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// Allow requests with no origin (server-to-server, curl, etc.)
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error(`Origin ${origin} not allowed by CORS`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "X-Admin-Password"],
|
||||||
|
maxAge: 86400, // 24 hours — browser can cache preflight
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CSRF TODO: state-changing endpoints (POST, PATCH, DELETE) should
|
||||||
|
// implement CSRF protection (e.g., double-submit cookie pattern or
|
||||||
|
// SameSite=Strict + custom header check) before deploying to production.
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Global rate limiter — pertahanan lapisan pertama terhadap abuse
|
||||||
|
// Endpoint login (/api/auth/login) punya rate limiter sendiri yang lebih ketat
|
||||||
|
// TODO: The global limiter is currently applied only at /api/ prefix (line below),
|
||||||
|
// which leaves non-/api/ paths unguarded. Consider applying a lighter limiter
|
||||||
|
// to all paths or ensure nginx handles upstream rate limiting in production.
|
||||||
|
const globalLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 1000, // 15 seconds
|
||||||
|
max: 200,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: (req) => {
|
||||||
|
// Skip health checks and WebSocket upgrade requests
|
||||||
|
if (req.path === "/api/health" || req.path === "/health") return true;
|
||||||
|
if (req.headers.upgrade === "websocket") return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
error: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many requests, please slow down",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api/", globalLimiter);
|
||||||
|
|
||||||
// Request logging
|
// Request logging
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.path.startsWith("/api/")) {
|
if (req.path.startsWith("/api/")) {
|
||||||
@@ -63,26 +160,35 @@ export function createHttpApp(): Express {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check (no auth required)
|
// Open endpoints (no auth required)
|
||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
|
|
||||||
// Auth (no auth required)
|
|
||||||
app.use("/api", createAuthRouter());
|
app.use("/api", createAuthRouter());
|
||||||
|
|
||||||
// Public read-only endpoints
|
|
||||||
app.use("/api", createConfigRouter());
|
app.use("/api", createConfigRouter());
|
||||||
|
|
||||||
|
// Admin endpoints — always require auth (manage settings, etc.)
|
||||||
|
// NOTE: createAdminRouter() sudah punya sessionAuth middleware internal,
|
||||||
|
// jadi tidak perlu middleware terpisah di sini.
|
||||||
|
app.use("/api", createAdminRouter());
|
||||||
|
|
||||||
|
// Protected routes — guarded by runtime DASHBOARD_IS_PUBLIC setting
|
||||||
|
// Public mode: GET is read-only, mutations require admin password
|
||||||
|
// Private mode: everything requires admin password
|
||||||
|
app.use("/api/dashboard", protectedRoute);
|
||||||
app.use("/api", createDashboardRouter());
|
app.use("/api", createDashboardRouter());
|
||||||
|
|
||||||
// Protected routes — all routes are now public
|
app.use("/api", protectedRoute);
|
||||||
app.use("/api", createMessagesRouter());
|
app.use("/api", createMessagesRouter());
|
||||||
app.use("/api", createAnalysisRouter());
|
app.use("/api", createAnalysisRouter());
|
||||||
app.use("/api", createMascotChatRouter());
|
app.use("/api", createMascotChatRouter());
|
||||||
|
|
||||||
|
// These routers are already guarded by the protectedRoute above
|
||||||
app.use("/api", createMediaRouter());
|
app.use("/api", createMediaRouter());
|
||||||
app.use("/api", createVoiceRouter());
|
app.use("/api", createVoiceRouter());
|
||||||
app.use("/api", createRecordingsRouter());
|
app.use("/api", createRecordingsRouter());
|
||||||
app.use("/api", createUiStateRouter());
|
app.use("/api", createUiStateRouter());
|
||||||
|
|
||||||
// Guilds routes
|
// Guilds routes — always protected (even in public mode)
|
||||||
|
app.use("/api/guilds", sessionAuth(ADMIN_PASSWORD));
|
||||||
app.use("/api/guilds", createGuildsRouter());
|
app.use("/api/guilds", createGuildsRouter());
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getRuntimeSettings,
|
||||||
|
updateRuntimeSettings,
|
||||||
|
} from "../../shared/config/runtime.js";
|
||||||
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { sessionAuth, asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
|
||||||
|
const logger = createChildLogger("admin.routes");
|
||||||
|
|
||||||
|
export function createAdminRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All admin routes require session-based auth
|
||||||
|
router.use(sessionAuth(config.ADMIN_PASSWORD));
|
||||||
|
|
||||||
|
// GET /api/admin/settings — read current runtime settings
|
||||||
|
router.get(
|
||||||
|
"/admin/settings",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const settings = getRuntimeSettings();
|
||||||
|
res.json({
|
||||||
|
...settings,
|
||||||
|
envDashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /api/admin/settings — update runtime settings (live, no restart)
|
||||||
|
router.patch(
|
||||||
|
"/admin/settings",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { dashboardIsPublic } = req.body as {
|
||||||
|
dashboardIsPublic?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (typeof dashboardIsPublic === "boolean") {
|
||||||
|
patch.dashboardIsPublic = dashboardIsPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateRuntimeSettings(patch);
|
||||||
|
logger.info({ ...patch }, "Runtime settings updated");
|
||||||
|
res.json(updated);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,32 +1,87 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import { UnauthorizedError } from "@bete/shared/errors";
|
import { UnauthorizedError } from "@bete/shared/errors";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import {
|
||||||
|
asyncHandler,
|
||||||
|
createSessionToken,
|
||||||
|
incrementTokenVersion,
|
||||||
|
sessionAuth,
|
||||||
|
} from "../../shared/middlewares/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("auth.routes");
|
const logger = createChildLogger("auth.routes");
|
||||||
|
|
||||||
const adminPassword = config.ADMIN_PASSWORD || "admin";
|
const adminPassword = config.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
// Rate limiter: max 10 login attempts per 15 minutes per IP
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
|
||||||
|
legacyHeaders: false, // Disable `X-RateLimit-*` headers
|
||||||
|
message: {
|
||||||
|
error: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many login attempts, please try again later",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function createAuthRouter(): Router {
|
export function createAuthRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login — rate limited to prevent brute force
|
||||||
router.post(
|
router.post(
|
||||||
"/auth/login",
|
"/auth/login",
|
||||||
|
loginLimiter,
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { password } = req.body as { password?: string };
|
const { password } = req.body as { password?: string };
|
||||||
|
|
||||||
logger.debug("Auth login attempt");
|
logger.debug("Auth login attempt");
|
||||||
|
|
||||||
if (!password || password !== adminPassword) {
|
if (!password) {
|
||||||
throw new UnauthorizedError("Invalid password");
|
throw new UnauthorizedError("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison prevents timing attacks
|
||||||
|
const pwBuf = Buffer.from(password);
|
||||||
|
const adminBuf = Buffer.from(adminPassword);
|
||||||
|
const maxLen = Math.max(pwBuf.length, adminBuf.length);
|
||||||
|
const diff =
|
||||||
|
pwBuf.length !== adminBuf.length ||
|
||||||
|
!timingSafeEqual(
|
||||||
|
Buffer.concat([pwBuf, Buffer.alloc(maxLen - pwBuf.length)]),
|
||||||
|
Buffer.concat([adminBuf, Buffer.alloc(maxLen - adminBuf.length)]),
|
||||||
|
);
|
||||||
|
if (diff) {
|
||||||
|
throw new UnauthorizedError("Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createSessionToken(adminPassword);
|
||||||
|
res.json({ ok: true, token });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/auth/logout — revoke all sessions for admin
|
||||||
|
router.post(
|
||||||
|
"/auth/logout",
|
||||||
|
sessionAuth(adminPassword),
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
incrementTokenVersion("admin");
|
||||||
|
logger.info("Admin logged out — all sessions revoked");
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/auth/whoami — check if token is valid
|
||||||
|
router.get(
|
||||||
|
"/auth/whoami",
|
||||||
|
sessionAuth(adminPassword),
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
res.json({ ok: true, sub: "admin" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
|
import { UnauthorizedError } from "@bete/shared/errors";
|
||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { isDashboardPublic } from "../../shared/config/runtime.js";
|
||||||
|
import { sessionAuth } from "../../shared/middlewares/index.js";
|
||||||
|
|
||||||
export function createConfigRouter(): Router {
|
export function createConfigRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// GET /api/config
|
// GET /api/config — protected by runtime public/private mode
|
||||||
router.get("/config", (_req, res) => {
|
// Public mode: no auth needed (frontend needs config to determine auth state)
|
||||||
res.json({
|
// Private mode: requires session-based auth
|
||||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
router.get("/config", (req, res, next) => {
|
||||||
webserverPort: config.WEBSERVER_PORT,
|
if (isDashboardPublic()) {
|
||||||
nodeEnv: config.NODE_ENV,
|
// Public mode — return config without auth
|
||||||
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
return sendConfig(res);
|
||||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
} else {
|
||||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
// Private mode — require auth, then return config
|
||||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
sessionAuth(config.ADMIN_PASSWORD)(req, res, () => sendConfig(res));
|
||||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
}
|
||||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
|
||||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
|
||||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
|
||||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
|
||||||
logLevel: config.LOG_LEVEL,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendConfig(res: express.Response): void {
|
||||||
|
if (isDashboardPublic()) {
|
||||||
|
// Public mode — only expose safe, non-sensitive fields
|
||||||
|
res.json({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
webserverPort: config.WEBSERVER_PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
webserverPort: config.WEBSERVER_PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
||||||
|
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
|
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
|
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
|
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
||||||
|
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||||
|
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||||
|
voiceGuildId: config.VOICE_GUILD_ID || null,
|
||||||
|
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||||
|
logLevel: config.LOG_LEVEL,
|
||||||
|
dashboardIsPublic: config.DASHBOARD_IS_PUBLIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ export class DashboardRepository {
|
|||||||
async getStats() {
|
async getStats() {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// Total messages and breakdown by ai_status
|
// Time-bounded aggregates — prevent full-table scan on large datasets
|
||||||
|
// Queries scope to last 90 days for performance, which covers the
|
||||||
|
// typical retention window anyway.
|
||||||
|
const BOUNDARY_DAYS = 90;
|
||||||
|
|
||||||
|
// Total messages and breakdown by ai_status (last 90 days)
|
||||||
const msgResult = await pool.query(
|
const msgResult = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -24,32 +29,36 @@ export class DashboardRepository {
|
|||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
|
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
|
||||||
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
|
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
|
||||||
FROM messages
|
FROM messages
|
||||||
|
WHERE created_at >= $3
|
||||||
`,
|
`,
|
||||||
[Date.now() - 86400000, Date.now() - 86400000],
|
[Date.now() - 86400000, Date.now() - 86400000, Date.now() - BOUNDARY_DAYS * 86400000],
|
||||||
);
|
);
|
||||||
|
|
||||||
const msgRow = msgResult.rows[0];
|
const msgRow = msgResult.rows[0];
|
||||||
|
|
||||||
// Total voice recordings
|
// Total voice recordings (last 90 days — bounded by retention window)
|
||||||
const voiceResult = await pool.query(`
|
const voiceResult = await pool.query(
|
||||||
SELECT COUNT(*)::int AS count FROM voice_recordings
|
`SELECT COUNT(*)::int AS count FROM voice_recordings
|
||||||
`);
|
WHERE created_at >= $1`,
|
||||||
|
[Date.now() - BOUNDARY_DAYS * 86400000],
|
||||||
|
);
|
||||||
|
|
||||||
// Total AI user profiles
|
// Total AI user profiles
|
||||||
const profileResult = await pool.query(`
|
const profileResult = await pool.query(`
|
||||||
SELECT COUNT(*)::int AS count FROM user_profiles
|
SELECT COUNT(*)::int AS count FROM user_profiles
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Top channels by message count
|
// Top channels by message count (last 90 days)
|
||||||
const topChannels = await pool.query(`
|
const topChannels = await pool.query(`
|
||||||
SELECT channel_id,
|
SELECT channel_id,
|
||||||
(metadata::jsonb -> 'channel' ->> 'channelName') AS channel_name,
|
(metadata::jsonb -> 'channel' ->> 'channelName') AS channel_name,
|
||||||
COUNT(*)::int AS message_count
|
COUNT(*)::int AS message_count
|
||||||
FROM messages
|
FROM messages
|
||||||
|
WHERE created_at >= $1
|
||||||
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
|
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||||
ORDER BY COUNT(*) DESC
|
ORDER BY COUNT(*) DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`, [Date.now() - BOUNDARY_DAYS * 86400000]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total_messages: msgRow?.total_messages ?? 0,
|
total_messages: msgRow?.total_messages ?? 0,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function createDashboardRouter(): Router {
|
|||||||
router.get(
|
router.get(
|
||||||
"/dashboard/users",
|
"/dashboard/users",
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const limit = Number(req.query.limit) || 20;
|
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||||
const cursor =
|
const cursor =
|
||||||
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
||||||
const search =
|
const search =
|
||||||
@@ -52,16 +52,19 @@ export function createDashboardRouter(): Router {
|
|||||||
router.get(
|
router.get(
|
||||||
"/dashboard/channels",
|
"/dashboard/channels",
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const limit = Number(req.query.limit) || 20;
|
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||||
const search =
|
const search =
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
typeof req.query.search === "string" ? req.query.search : undefined;
|
||||||
const guildId =
|
const guildId =
|
||||||
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
||||||
|
const cursor =
|
||||||
|
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
||||||
|
|
||||||
const result = await dashboardService.listChannels({
|
const result = await dashboardService.listChannels({
|
||||||
limit,
|
limit,
|
||||||
search,
|
search,
|
||||||
guildId,
|
guildId,
|
||||||
|
cursor,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class DashboardService {
|
|||||||
limit: number;
|
limit: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
|
cursor?: string;
|
||||||
}) {
|
}) {
|
||||||
logger.debug({ query }, "Listing dashboard channels");
|
logger.debug({ query }, "Listing dashboard channels");
|
||||||
return dashboardRepository.listChannels(query);
|
return dashboardRepository.listChannels(query);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { chatRequestSchema } from "./mascot-chat.schema.js";
|
||||||
import { mascotChatService } from "./mascot-chat.service.js";
|
import { mascotChatService } from "./mascot-chat.service.js";
|
||||||
|
|
||||||
const logger = createChildLogger("mascot-chat.controller");
|
const logger = createChildLogger("mascot-chat.controller");
|
||||||
@@ -11,15 +12,18 @@ interface AuthenticatedRequest extends Request {
|
|||||||
|
|
||||||
export const handleMascotChat = asyncHandler(
|
export const handleMascotChat = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { message, context } = req.body;
|
// Validate request body against schema
|
||||||
|
const parsed = chatRequestSchema.safeParse(req.body);
|
||||||
if (!message || typeof message !== "string") {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "INVALID_INPUT",
|
error: "INVALID_INPUT",
|
||||||
message: "Message is required and must be a string",
|
message: "Invalid request body",
|
||||||
|
details: parsed.error.flatten().fieldErrors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { message, context } = parsed.data;
|
||||||
|
|
||||||
// Get user ID from auth middleware (if available)
|
// Get user ID from auth middleware (if available)
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { uiStateService } from "./ui-state.service.js";
|
|||||||
|
|
||||||
const logger = createChildLogger("ui-state.routes");
|
const logger = createChildLogger("ui-state.routes");
|
||||||
|
|
||||||
|
// Allowed UI state keys — reject any update that does not match these.
|
||||||
|
const ALLOWED_KEYS = new Set([
|
||||||
|
"activeTab",
|
||||||
|
"selectedVoiceGuild",
|
||||||
|
"selectedVoiceChannel",
|
||||||
|
"selectedTextChannel",
|
||||||
|
"sidebarCollapsed",
|
||||||
|
]);
|
||||||
|
|
||||||
export function createUiStateRouter(): Router {
|
export function createUiStateRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -25,7 +34,14 @@ export function createUiStateRouter(): Router {
|
|||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const updates = req.body as Record<string, unknown>;
|
const updates = req.body as Record<string, unknown>;
|
||||||
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
||||||
const result = await uiStateService.updateState(updates);
|
// Filter to only allow known safe keys
|
||||||
|
const filtered: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(updates)) {
|
||||||
|
if (ALLOWED_KEYS.has(key)) {
|
||||||
|
filtered[key] = updates[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await uiStateService.updateState(filtered);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Runtime configuration manager.
|
||||||
|
*
|
||||||
|
* Stores settings that can change at runtime (e.g., DASHBOARD_IS_PUBLIC)
|
||||||
|
* in a JSON file. Falls back to env-based defaults from the static config.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
import type { config } from "./index.js";
|
||||||
|
type Config = typeof config;
|
||||||
|
|
||||||
|
const logger = createChildLogger("runtime-config");
|
||||||
|
|
||||||
|
const DATA_DIR = resolve(import.meta.dirname ?? process.cwd(), "..", "data");
|
||||||
|
const SETTINGS_FILE = resolve(DATA_DIR, "settings.json");
|
||||||
|
|
||||||
|
interface RuntimeSettings {
|
||||||
|
dashboardIsPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nilai fallback dari env. Dipakai saat settings.json belum pernah dibuat. */
|
||||||
|
function envDefaultSettings(): RuntimeSettings {
|
||||||
|
return {
|
||||||
|
dashboardIsPublic: process.env.DASHBOARD_IS_PUBLIC === "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDataDir(): void {
|
||||||
|
if (!existsSync(DATA_DIR)) {
|
||||||
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): RuntimeSettings {
|
||||||
|
try {
|
||||||
|
ensureDataDir();
|
||||||
|
const fallback = envDefaultSettings();
|
||||||
|
if (!existsSync(SETTINGS_FILE)) {
|
||||||
|
writeFileSync(SETTINGS_FILE, JSON.stringify(fallback, null, 2));
|
||||||
|
return { ...fallback };
|
||||||
|
}
|
||||||
|
const raw = readFileSync(SETTINGS_FILE, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
|
||||||
|
return { ...fallback, ...parsed };
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to load runtime settings");
|
||||||
|
return envDefaultSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(settings: RuntimeSettings): void {
|
||||||
|
try {
|
||||||
|
ensureDataDir();
|
||||||
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to save runtime settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Singleton ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _cache: RuntimeSettings | null = null;
|
||||||
|
|
||||||
|
function getSettings(): RuntimeSettings {
|
||||||
|
if (!_cache) {
|
||||||
|
_cache = loadSettings();
|
||||||
|
}
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCache(): void {
|
||||||
|
_cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the dashboard is publicly accessible without auth, using runtime
|
||||||
|
* override if available, otherwise falling back to the env-based static config.
|
||||||
|
*/
|
||||||
|
export function isDashboardPublic(staticConfig?: Config): boolean {
|
||||||
|
const runtime = getSettings();
|
||||||
|
return runtime.dashboardIsPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeSettings(): RuntimeSettings {
|
||||||
|
return { ...getSettings() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update runtime settings. Pass only the fields you want to change.
|
||||||
|
* Invalidates the internal cache so the next read picks up changes.
|
||||||
|
*/
|
||||||
|
export function updateRuntimeSettings(
|
||||||
|
patch: Partial<RuntimeSettings>,
|
||||||
|
): RuntimeSettings {
|
||||||
|
const current = getSettings();
|
||||||
|
const updated = { ...current, ...patch };
|
||||||
|
saveSettings(updated);
|
||||||
|
invalidateCache();
|
||||||
|
return { ...updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset runtime settings to env-based defaults (does NOT change the file).
|
||||||
|
*/
|
||||||
|
export function resetRuntimeSettings(): void {
|
||||||
|
invalidateCache();
|
||||||
|
}
|
||||||
@@ -4,10 +4,150 @@ import {
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
} from "@bete/shared/errors";
|
} from "@bete/shared/errors";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
const logger = createChildLogger("middleware");
|
const logger = createChildLogger("middleware");
|
||||||
|
|
||||||
|
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// ─── Revokable token version ──────────────────────────────────────────────
|
||||||
|
// Token version prevents compromised tokens from being valid indefinitely.
|
||||||
|
// Stored in Redis so version survives process restarts.
|
||||||
|
// Falls back to in-memory Map if Redis is unavailable.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOKEN_VERSION_REDIS_PREFIX = "token_version:";
|
||||||
|
const TOKEN_VERSION_TTL_S = 7 * 24 * 60 * 60; // 7 days — far longer than session lifetime
|
||||||
|
|
||||||
|
const tokenVersions = new Map<string, number>(); // in-memory fallback
|
||||||
|
|
||||||
|
async function tryLoadTokenVersion(sub: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const { readRedisStatus } = await import("../redis/index.js");
|
||||||
|
const raw = await readRedisStatus(`${TOKEN_VERSION_REDIS_PREFIX}${sub}`);
|
||||||
|
if (raw && typeof raw.version === "number") {
|
||||||
|
// Sync in-memory cache
|
||||||
|
tokenVersions.set(sub, raw.version);
|
||||||
|
return raw.version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Redis unavailable — fall through to in-memory
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryPersistTokenVersion(sub: string, version: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { getCommandPublisher } = await import("../redis/index.js");
|
||||||
|
const publisher = getCommandPublisher();
|
||||||
|
const key = `${TOKEN_VERSION_REDIS_PREFIX}${sub}`;
|
||||||
|
await publisher.set(key, JSON.stringify({ version }), "EX", TOKEN_VERSION_TTL_S);
|
||||||
|
} catch {
|
||||||
|
// Silently fall back to in-memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementTokenVersion(sub: string): Promise<number> {
|
||||||
|
const next = (tokenVersions.get(sub) ?? 0) + 1;
|
||||||
|
tokenVersions.set(sub, next);
|
||||||
|
// Fire-and-forget persist to Redis
|
||||||
|
tryPersistTokenVersion(sub, next).catch(() => {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTokenVersion(sub: string): Promise<number> {
|
||||||
|
const cached = tokenVersions.get(sub);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
// Try loading from Redis
|
||||||
|
const remote = await tryLoadTokenVersion(sub);
|
||||||
|
if (remote !== null) return remote;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JWT-like session token helpers ──────────────────────────────────────
|
||||||
|
// Simple HMAC-SHA256 token without external library dependency.
|
||||||
|
// Payload: { sub, iat, exp } base64url-encoded, signed with HMAC-SHA256.
|
||||||
|
|
||||||
|
interface SessionPayload {
|
||||||
|
sub: string; // e.g. "admin"
|
||||||
|
iat: number; // issued at (ms)
|
||||||
|
exp: number; // expires at (ms)
|
||||||
|
ver: number; // token version (revokable)
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlEncode(data: string): string {
|
||||||
|
return Buffer.from(data)
|
||||||
|
.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): string {
|
||||||
|
return Buffer.from(str, "base64url").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(payload: string, secret: string): string {
|
||||||
|
return createHmac("sha256", secret)
|
||||||
|
.update(payload)
|
||||||
|
.digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionToken(adminPassword: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
// Note: getTokenVersion is async (Redis-backed). In practice, the version
|
||||||
|
// is cached in-memory after first load, so this is effectively sync.
|
||||||
|
// We use a sync fallback to keep the token-creation path non-async.
|
||||||
|
const ver = tokenVersions.get("admin") ?? 0;
|
||||||
|
const payload: SessionPayload = {
|
||||||
|
sub: "admin",
|
||||||
|
iat: now,
|
||||||
|
exp: now + SESSION_DURATION_MS,
|
||||||
|
ver,
|
||||||
|
};
|
||||||
|
const header = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const body = base64urlEncode(JSON.stringify(payload));
|
||||||
|
const signature = signToken(`${header}.${body}`, adminPassword);
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySessionToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): SessionPayload {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new UnauthorizedError("Invalid token format");
|
||||||
|
}
|
||||||
|
const [header, body, signature] = parts;
|
||||||
|
const expectedSig = signToken(`${header}.${body}`, secret);
|
||||||
|
try {
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expectedBuf = Buffer.from(expectedSig);
|
||||||
|
if (
|
||||||
|
sigBuf.length !== expectedBuf.length ||
|
||||||
|
!timingSafeEqual(sigBuf, expectedBuf)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedError("Invalid token signature");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedError("Invalid token signature");
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(base64urlDecode(body)) as SessionPayload;
|
||||||
|
if (Date.now() > payload.exp) {
|
||||||
|
throw new UnauthorizedError("Session token expired");
|
||||||
|
}
|
||||||
|
// Token version check — invalidate all tokens issued before version bump
|
||||||
|
// Note: getTokenVersion is async (Redis-backed). We fall back to the
|
||||||
|
// in-memory cache which is synced on first load from Redis. On startup
|
||||||
|
// the version defaults to 0, which is correct — no tokens revoked yet.
|
||||||
|
const currentVersion = tokenVersions.get(payload.sub) ?? 0;
|
||||||
|
if ((payload.ver ?? 0) < currentVersion) {
|
||||||
|
throw new UnauthorizedError("Session token has been revoked");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Express middleware ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function errorHandler(
|
export function errorHandler(
|
||||||
err: Error,
|
err: Error,
|
||||||
_req: Request,
|
_req: Request,
|
||||||
@@ -30,15 +170,62 @@ export function errorHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminAuth(adminPassword: string) {
|
/**
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
* @deprecated Replaced by sessionAuth(). Kept temporarily for transition
|
||||||
const password = req.headers["x-admin-password"] as string;
|
* period. TODO: remove after confirming no consumers remain.
|
||||||
|
*/
|
||||||
|
// export function adminAuth(adminPassword: string) {
|
||||||
|
// return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// const password = req.headers["x-admin-password"] as string;
|
||||||
|
//
|
||||||
|
// if (!password || password !== adminPassword) {
|
||||||
|
// throw new UnauthorizedError("Invalid admin password");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// next();
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
if (!password || password !== adminPassword) {
|
/**
|
||||||
throw new UnauthorizedError("Invalid admin password");
|
* Session-based auth middleware.
|
||||||
|
* Reads Bearer token from Authorization header and validates it.
|
||||||
|
* Falls back to X-Admin-Password header for backward compatibility.
|
||||||
|
*/
|
||||||
|
export function sessionAuth(secret: string) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// Try Authorization: Bearer <token> first
|
||||||
|
const authHeader = req.headers.authorization as string | undefined;
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
verifySessionToken(token, secret);
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new UnauthorizedError("Invalid session token");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
// Fallback: X-Admin-Password header (for transition period)
|
||||||
|
const password = req.headers["x-admin-password"] as string;
|
||||||
|
if (password) {
|
||||||
|
try {
|
||||||
|
const pwBuf = Buffer.from(password);
|
||||||
|
const secretBuf = Buffer.from(secret);
|
||||||
|
if (
|
||||||
|
pwBuf.length === secretBuf.length &&
|
||||||
|
timingSafeEqual(pwBuf, secretBuf)
|
||||||
|
) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to error below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedError("Authentication required");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export async function publishCommand<T = unknown>(
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
sub.removeListener("message", onMessage);
|
||||||
sub.unsubscribe(replyChannel).catch(() => {
|
sub.unsubscribe(replyChannel).catch(() => {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
});
|
});
|
||||||
@@ -90,6 +91,7 @@ export async function publishCommand<T = unknown>(
|
|||||||
if (channel !== replyChannel || settled) return;
|
if (channel !== replyChannel || settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
sub.removeListener("message", onMessage);
|
||||||
sub.unsubscribe(replyChannel).catch(() => {
|
sub.unsubscribe(replyChannel).catch(() => {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
});
|
});
|
||||||
@@ -121,6 +123,7 @@ export async function publishCommand<T = unknown>(
|
|||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
sub.removeListener("message", onMessage);
|
||||||
sub.unsubscribe(replyChannel).catch(() => {
|
sub.unsubscribe(replyChannel).catch(() => {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
});
|
});
|
||||||
@@ -133,6 +136,7 @@ export async function publishCommand<T = unknown>(
|
|||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
sub.removeListener("message", onMessage);
|
||||||
logger.error({ err }, "Failed to subscribe to reply channel");
|
logger.error({ err }, "Failed to subscribe to reply channel");
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,91 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
|
let _redisHealthy = false;
|
||||||
|
|
||||||
|
function isRedisAvailable(): boolean {
|
||||||
|
return _redisHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let _reconnectAttempts = 0;
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
function scheduleReconnect(): void {
|
||||||
|
if (_reconnectTimer) return; // already scheduled
|
||||||
|
_reconnectAttempts++;
|
||||||
|
if (_reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
logger.error("Redis subscriber max reconnect attempts reached");
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, _reconnectAttempts), 30_000);
|
||||||
|
logger.warn(
|
||||||
|
{ attempt: _reconnectAttempts, delayMs: delay },
|
||||||
|
"Redis subscriber reconnection scheduled",
|
||||||
|
);
|
||||||
|
_reconnectTimer = setTimeout(() => {
|
||||||
|
_reconnectTimer = null;
|
||||||
|
if (subscriber) {
|
||||||
|
subscriber
|
||||||
|
.connect()
|
||||||
|
.then(() => {
|
||||||
|
_redisHealthy = true;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
logger.info("Redis subscriber reconnected");
|
||||||
|
// Re-subscribe after reconnect
|
||||||
|
const channels = SUBSCRIPTIONS.map((m) => m.channel);
|
||||||
|
return subscriber?.subscribe(...channels);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
logger.error({ err }, "Redis subscriber reconnect failed");
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
function createSubscriber(): Redis {
|
function createSubscriber(): Redis {
|
||||||
return new Redis(config.REDIS_URL, { keyPrefix: "" });
|
const redis = new Redis(config.REDIS_URL, {
|
||||||
|
keyPrefix: "",
|
||||||
|
lazyConnect: true,
|
||||||
|
retryStrategy: (times) => {
|
||||||
|
// We handle reconnection ourselves
|
||||||
|
if (times > 3) return null;
|
||||||
|
return Math.min(times * 500, 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on("error", (err: Error) => {
|
||||||
|
const wasHealthy = _redisHealthy;
|
||||||
|
_redisHealthy = false;
|
||||||
|
if (wasHealthy) {
|
||||||
|
logger.warn({ err }, "Redis subscriber: connection lost");
|
||||||
|
} else {
|
||||||
|
logger.debug({ err }, "Redis subscriber error (not yet connected)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on("connect", () => {
|
||||||
|
_redisHealthy = true;
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
logger.info("Redis subscriber connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on("close", () => {
|
||||||
|
_redisHealthy = false;
|
||||||
|
logger.warn("Redis subscriber connection closed");
|
||||||
|
// Schedule reconnection for lazy-connect mode
|
||||||
|
if (!_reconnectTimer) scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on("reconnecting", () => {
|
||||||
|
logger.warn("Redis subscriber reconnecting…");
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on("message", handleSubscriptionMessage);
|
||||||
|
|
||||||
|
return redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubscriptionMessage(channel: string, message: string): void {
|
function handleSubscriptionMessage(channel: string, message: string): void {
|
||||||
@@ -141,24 +223,6 @@ export async function startRedisBridge(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
subscriber = createSubscriber();
|
subscriber = createSubscriber();
|
||||||
|
|
||||||
subscriber.on("error", (err: Error) => {
|
|
||||||
logger.error({ err }, "Redis subscriber error");
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriber.on("connect", () => {
|
|
||||||
logger.info("Redis subscriber connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriber.on("reconnecting", () => {
|
|
||||||
logger.warn("Redis subscriber reconnecting…");
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriber.on("close", () => {
|
|
||||||
logger.warn("Redis subscriber connection closed");
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriber.on("message", handleSubscriptionMessage);
|
|
||||||
|
|
||||||
await subscriber.ping();
|
await subscriber.ping();
|
||||||
logger.info("Redis ping OK");
|
logger.info("Redis ping OK");
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { setBroadcastFunctions } from "./broadcast.js";
|
|||||||
|
|
||||||
const logger = createChildLogger("ws.server");
|
const logger = createChildLogger("ws.server");
|
||||||
|
|
||||||
|
// Per-client sliding window rate limiter: max 30 messages per 5-second window
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 5000;
|
||||||
|
const RATE_LIMIT_MAX_MSGS = 30;
|
||||||
|
const messageTimestamps = new WeakMap<WebSocket, number[]>();
|
||||||
|
|
||||||
interface BroadcastEvent {
|
interface BroadcastEvent {
|
||||||
type: string;
|
type: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@@ -71,18 +76,29 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
|
|
||||||
wss.on("connection", (ws: WebSocket, req) => {
|
wss.on("connection", async (ws: WebSocket, req) => {
|
||||||
// Parse auth token from query string
|
// Max connection limit — prevent resource exhaustion
|
||||||
|
const totalClients = frontendClients.size + gatewayClients.size;
|
||||||
|
const MAX_CONNECTIONS = 100;
|
||||||
|
if (totalClients >= MAX_CONNECTIONS) {
|
||||||
|
logger.warn({ totalClients }, "Max connections reached, rejecting new client");
|
||||||
|
ws.close(4003, "Server at capacity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway uses token in query string (internal-only connection, not in logs)
|
||||||
|
// Frontend uses auth message pattern to avoid token exposure in access logs
|
||||||
const rawUrl = req.url ?? "/";
|
const rawUrl = req.url ?? "/";
|
||||||
let isGateway = false;
|
let isGateway = false;
|
||||||
|
let queryToken: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(rawUrl, "http://localhost");
|
const url = new URL(rawUrl, "http://localhost");
|
||||||
const token = url.searchParams.get("token");
|
queryToken = url.searchParams.get("token");
|
||||||
isGateway =
|
isGateway =
|
||||||
token !== null &&
|
queryToken !== null &&
|
||||||
config.BACKEND_WS_TOKEN !== "" &&
|
config.BACKEND_WS_TOKEN !== "" &&
|
||||||
token === config.BACKEND_WS_TOKEN;
|
queryToken === config.BACKEND_WS_TOKEN;
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed URL — treat as frontend
|
// Malformed URL — treat as frontend
|
||||||
}
|
}
|
||||||
@@ -90,25 +106,120 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
if (isGateway) {
|
if (isGateway) {
|
||||||
gatewayClients.add(ws);
|
gatewayClients.add(ws);
|
||||||
logger.info("Discord gateway WebSocket client authenticated");
|
logger.info("Discord gateway WebSocket client authenticated");
|
||||||
// Gateway doesn't need initial states
|
// Gateway only sends binary PCM — forward to frontend clients
|
||||||
} else {
|
ws.on("message", (data: Buffer) => {
|
||||||
frontendClients.add(ws);
|
if (Buffer.isBuffer(data)) {
|
||||||
logger.info(`Frontend client connected (${frontendClients.size} total)`);
|
broadcastBinaryToFrontend(data);
|
||||||
// Send initial states (user, ui, media) — fire-and-forget
|
}
|
||||||
sendInitialStates(ws).catch((err) =>
|
});
|
||||||
logger.error({ err }, "sendInitialStates failed"),
|
ws.on("close", () => {
|
||||||
);
|
gatewayClients.delete(ws);
|
||||||
|
logger.info("Discord gateway WebSocket disconnected");
|
||||||
|
});
|
||||||
|
ws.on("error", (err: Error) => {
|
||||||
|
logger.error({ err }, "Gateway WebSocket error");
|
||||||
|
gatewayClients.delete(ws);
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on("message", (data: Buffer) => {
|
// ── Frontend client: auth message pattern ──────────────────────────
|
||||||
// Gateway PCM forward — broadcast raw binary to frontend clients only
|
// Token is NEVER accepted in query string for frontend connections.
|
||||||
if (isGateway && Buffer.isBuffer(data)) {
|
// Frontend must send { type: "auth", token: "..." } as first message.
|
||||||
broadcastBinaryToFrontend(data);
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Origin check for frontend WebSocket connections
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin) {
|
||||||
|
const allowedWsOrigins = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"https://imphnen.asepharyana.my.id",
|
||||||
|
"https://imphnen.asepharyana.tech",
|
||||||
|
"https://imphnen.asepharyana.web.id",
|
||||||
|
];
|
||||||
|
if (!allowedWsOrigins.includes(origin)) {
|
||||||
|
logger.warn({ origin }, "WebSocket connection rejected: origin not allowed");
|
||||||
|
ws.close(4002, "Origin not allowed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle binary PCM from browser (FE→Discord transmit)
|
let authenticated = false;
|
||||||
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
|
let authTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const { isDashboardPublic } = await import("../shared/config/runtime.js");
|
||||||
|
const isPublic = isDashboardPublic();
|
||||||
|
|
||||||
|
if (!isPublic) {
|
||||||
|
authTimer = setTimeout(() => {
|
||||||
|
if (!authenticated) {
|
||||||
|
ws.close(4001, "Authentication timeout");
|
||||||
|
logger.warn("Frontend WS connection timed out waiting for auth");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
authenticated = true;
|
||||||
|
frontendClients.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFrontendMessage(data: Buffer): void {
|
||||||
|
// Validate auth before processing messages
|
||||||
|
if (!authenticated) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (
|
||||||
|
msg.type !== "auth" ||
|
||||||
|
typeof msg.token !== "string"
|
||||||
|
) {
|
||||||
|
return; // wait for valid auth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPublic) {
|
||||||
|
const { verifySessionToken } = require("../shared/middlewares/index.js");
|
||||||
|
verifySessionToken(msg.token, config.ADMIN_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated = true;
|
||||||
|
if (authTimer) {
|
||||||
|
clearTimeout(authTimer);
|
||||||
|
authTimer = null;
|
||||||
|
}
|
||||||
|
frontendClients.add(ws);
|
||||||
|
logger.info(`Frontend client authenticated (${frontendClients.size} total)`);
|
||||||
|
sendInitialStates(ws).catch((err) =>
|
||||||
|
logger.error({ err }, "sendInitialStates failed"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
return; // invalid auth, wait for next message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-client rate limiting — authenticated-only, max 30 msg / 5s sliding window
|
||||||
|
if (authenticated) {
|
||||||
|
const now = Date.now();
|
||||||
|
let timestamps = messageTimestamps.get(ws);
|
||||||
|
if (!timestamps) {
|
||||||
|
timestamps = [];
|
||||||
|
messageTimestamps.set(ws, timestamps);
|
||||||
|
}
|
||||||
|
// Prune timestamps outside the window
|
||||||
|
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
||||||
|
while (timestamps.length > 0 && timestamps[0]! < cutoff) {
|
||||||
|
timestamps.shift();
|
||||||
|
}
|
||||||
|
if (timestamps.length >= RATE_LIMIT_MAX_MSGS) {
|
||||||
|
logger.warn("Frontend client rate-limited (closing)");
|
||||||
|
ws.close(4006, "Rate limit exceeded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timestamps.push(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle voice transmit binary
|
||||||
if (
|
if (
|
||||||
Buffer.isBuffer(data) &&
|
Buffer.isBuffer(data) &&
|
||||||
data.length > 4 &&
|
data.length > 4 &&
|
||||||
@@ -148,78 +259,74 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
|
handleFrontendJsonMessage(message);
|
||||||
if (message.type === "voice_transmit" && message.buffer) {
|
|
||||||
// Legacy: Forward PCM data to Redis for discord-gateway
|
|
||||||
import("../shared/redis/index.js").then(
|
|
||||||
({ getCommandPublisher }) => {
|
|
||||||
const publisher = getCommandPublisher();
|
|
||||||
publisher
|
|
||||||
.publish(
|
|
||||||
BACKEND_VOICE_TRANSMIT,
|
|
||||||
JSON.stringify({
|
|
||||||
type: "pcm",
|
|
||||||
buffer: message.buffer,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch((err: Error) => {
|
|
||||||
logger.error(
|
|
||||||
{ err },
|
|
||||||
"Failed to publish voice transmit to Redis",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (message.type === "voice_command" && message.command) {
|
|
||||||
// Forward voice commands to discord-gateway with payload
|
|
||||||
import("../shared/redis/index.js").then(
|
|
||||||
({ getCommandPublisher }) => {
|
|
||||||
const publisher = getCommandPublisher();
|
|
||||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
publisher
|
|
||||||
.publish(
|
|
||||||
BACKEND_COMMAND,
|
|
||||||
JSON.stringify({
|
|
||||||
id: commandId,
|
|
||||||
type: message.command,
|
|
||||||
payload: message.payload ?? {},
|
|
||||||
replyChannel: `reply:${commandId}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch((err: Error) => {
|
|
||||||
logger.error(
|
|
||||||
{ err },
|
|
||||||
"Failed to publish voice command to Redis",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug({ err }, "Failed to parse WebSocket message as JSON");
|
logger.debug({ err }, "Failed to parse WebSocket message as JSON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
ws.on("close", () => {
|
function handleFrontendJsonMessage(message: Record<string, unknown>): void {
|
||||||
if (isGateway) {
|
if (message.type === "voice_transmit" && message.buffer) {
|
||||||
gatewayClients.delete(ws);
|
import("../shared/redis/index.js").then(
|
||||||
logger.info("Discord gateway WebSocket disconnected");
|
({ getCommandPublisher }) => {
|
||||||
} else {
|
const publisher = getCommandPublisher();
|
||||||
frontendClients.delete(ws);
|
publisher
|
||||||
logger.info(
|
.publish(
|
||||||
`Frontend client disconnected (${frontendClients.size} total)`,
|
BACKEND_VOICE_TRANSMIT,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "pcm",
|
||||||
|
buffer: message.buffer,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((err: Error) => {
|
||||||
|
logger.error(
|
||||||
|
{ err },
|
||||||
|
"Failed to publish voice transmit to Redis",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (message.type === "voice_command" && message.command) {
|
||||||
|
import("../shared/redis/index.js").then(
|
||||||
|
({ getCommandPublisher }) => {
|
||||||
|
const publisher = getCommandPublisher();
|
||||||
|
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
publisher
|
||||||
|
.publish(
|
||||||
|
BACKEND_COMMAND,
|
||||||
|
JSON.stringify({
|
||||||
|
id: commandId,
|
||||||
|
type: message.command,
|
||||||
|
payload: message.payload ?? {},
|
||||||
|
replyChannel: `reply:${commandId}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((err: Error) => {
|
||||||
|
logger.error(
|
||||||
|
{ err },
|
||||||
|
"Failed to publish voice command to Redis",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on("message", (data: Buffer) => processFrontendMessage(data));
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
if (authTimer) clearTimeout(authTimer);
|
||||||
|
frontendClients.delete(ws);
|
||||||
|
logger.info(
|
||||||
|
`Frontend client disconnected (${frontendClients.size} total)`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", (err: Error) => {
|
ws.on("error", (err: Error) => {
|
||||||
logger.error({ err }, "WebSocket client error");
|
logger.error({ err }, "Frontend WebSocket error");
|
||||||
if (isGateway) {
|
if (authTimer) clearTimeout(authTimer);
|
||||||
gatewayClients.delete(ws);
|
frontendClients.delete(ws);
|
||||||
} else {
|
|
||||||
frontendClients.delete(ws);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,6 +347,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
function broadcastBinaryToFrontend(data: Buffer) {
|
function broadcastBinaryToFrontend(data: Buffer) {
|
||||||
for (const client of frontendClients) {
|
for (const client of frontendClients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
// Backpressure check: skip slow clients to prevent OOM
|
||||||
|
if (client.bufferedAmount > 64 * 1024) continue;
|
||||||
try {
|
try {
|
||||||
client.send(data);
|
client.send(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -260,6 +369,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
});
|
});
|
||||||
for (const client of frontendClients) {
|
for (const client of frontendClients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
// Backpressure check: skip slow clients to prevent OOM
|
||||||
|
if (client.bufferedAmount > 64 * 1024) continue;
|
||||||
try {
|
try {
|
||||||
client.send(payload);
|
client.send(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -272,6 +383,8 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
function broadcastBinary(data: Buffer) {
|
function broadcastBinary(data: Buffer) {
|
||||||
for (const client of frontendClients) {
|
for (const client of frontendClients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
// Backpressure check: skip slow clients to prevent OOM
|
||||||
|
if (client.bufferedAmount > 64 * 1024) continue;
|
||||||
try {
|
try {
|
||||||
client.send(data);
|
client.send(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS "idx_messages_guild_ai_status_analyzed" ON "messages" USING btree ("guild_id","ai_status","ai_analyzed_at","id");--> statement-breakpoint
|
||||||
@@ -85,6 +85,13 @@
|
|||||||
"when": 1781388000000,
|
"when": 1781388000000,
|
||||||
"tag": "0011_add_voice_transcription",
|
"tag": "0011_add_voice_transcription",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781672400000,
|
||||||
|
"tag": "0012_light_prism",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* abortHelper.ts — Centralized AbortController with automatic timeout cleanup.
|
||||||
|
*
|
||||||
|
* All `new AbortController()` + `setTimeout(abort, ms)` patterns across the
|
||||||
|
* moderation subsystem are replaced by this module so that:
|
||||||
|
* 1. Every timer calls `.unref()` so it cannot keep Node alive during shutdown.
|
||||||
|
* 2. Cleanup is guaranteed via `cleanup()` or `withAbortTimeout()`.
|
||||||
|
* 3. The AbortError is distinguishable via `isAbortError()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an AbortController that auto-aborts after `ms` milliseconds.
|
||||||
|
* Returns the signal and a `cleanup()` function that MUST be called
|
||||||
|
* (typically in a `finally` block) to cancel the timer.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* const { signal, cleanup } = createAbortTimeout(8000);
|
||||||
|
* try {
|
||||||
|
* await fetch(url, { signal });
|
||||||
|
* } finally {
|
||||||
|
* cleanup();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createAbortTimeout(ms: number): {
|
||||||
|
signal: AbortSignal;
|
||||||
|
cleanup: () => void;
|
||||||
|
} {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ms);
|
||||||
|
// Prevent the timer from keeping the Node.js event loop alive during shutdown
|
||||||
|
timer.unref();
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
cleanup: () => clearTimeout(timer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-order wrapper that runs an async function with an abort timeout.
|
||||||
|
* The cleanup is handled automatically — callers never forget `clearTimeout`.
|
||||||
|
*
|
||||||
|
* If the operation is aborted by the timeout, the resulting error is re-thrown
|
||||||
|
* with a descriptive message.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* const result = await withAbortTimeout(8000, async (signal) => {
|
||||||
|
* return fetch(url, { signal }).then(r => r.json());
|
||||||
|
* }, "SearXNG search");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function withAbortTimeout<T>(
|
||||||
|
ms: number,
|
||||||
|
fn: (signal: AbortSignal) => Promise<T>,
|
||||||
|
label = "operation",
|
||||||
|
): Promise<T> {
|
||||||
|
const { signal, cleanup } = createAbortTimeout(ms);
|
||||||
|
try {
|
||||||
|
return await fn(signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error(`${label} timed out after ${ms}ms`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an error was caused by an AbortController signal firing
|
||||||
|
* (either our explicit abort or the timeout).
|
||||||
|
*/
|
||||||
|
export function isAbortError(err: unknown): boolean {
|
||||||
|
if (err instanceof DOMException) return err.name === "AbortError";
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return (
|
||||||
|
err.name === "AbortError" ||
|
||||||
|
err.message.includes("timed out after") ||
|
||||||
|
err.message.includes("aborted")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -186,14 +186,14 @@ async function processBatch(job: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRows: MessageRecord[] = [];
|
|
||||||
|
|
||||||
// ── Parallel: text-only + media analysis run concurrently ──────────
|
// ── Parallel: text-only + media analysis run concurrently ──────────
|
||||||
// Text-only → fast LLM call. Media → download + vision + LLM.
|
// Text-only → fast LLM call. Media → download + vision + LLM.
|
||||||
// Running both in parallel means media downloads overlap with text LLM call.
|
// Running both in parallel means media downloads overlap with text LLM call.
|
||||||
// Each path saves to DB as soon as its own results are ready.
|
// Each path saves to DB as soon as its own results are ready.
|
||||||
|
// Each promise resolves to its own MessageRecord[] — combined via
|
||||||
|
// destructured Promise.all to avoid race conditions on a shared array.
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
const textPromise = textOnly.length > 0
|
const textPromise: Promise<MessageRecord[]> = textOnly.length > 0
|
||||||
? runModerationAnalysis({
|
? runModerationAnalysis({
|
||||||
targets: textOnly,
|
targets: textOnly,
|
||||||
contextText: contextLines.join("\n"),
|
contextText: contextLines.join("\n"),
|
||||||
@@ -216,17 +216,18 @@ async function processBatch(job: {
|
|||||||
}));
|
}));
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
|
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
|
||||||
allRows.push(...rows);
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ count: updates.length, conversationKey },
|
{ count: updates.length, conversationKey },
|
||||||
"Text-only batch saved — media analysis still in progress",
|
"Text-only batch saved — media analysis still in progress",
|
||||||
);
|
);
|
||||||
|
return rows;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
})
|
})
|
||||||
: Promise.resolve();
|
: Promise.resolve([]);
|
||||||
|
|
||||||
const mediaPromise = media.length > 0
|
const mediaPromise: Promise<MessageRecord[]> = media.length > 0
|
||||||
? runModerationAnalysis({
|
? runModerationAnalysis({
|
||||||
targets: media,
|
targets: media,
|
||||||
contextText: contextLines.join("\n"),
|
contextText: contextLines.join("\n"),
|
||||||
@@ -248,15 +249,15 @@ async function processBatch(job: {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
|
return updateMessagesAIAnalysisBulk(updates);
|
||||||
allRows.push(...rows);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
})
|
})
|
||||||
: Promise.resolve();
|
: Promise.resolve([]);
|
||||||
|
|
||||||
// Wait for both to complete
|
// Wait for both to complete and destructure results — no shared mutable array
|
||||||
await Promise.all([textPromise, mediaPromise]);
|
const [textRows, mediaRows] = await Promise.all([textPromise, mediaPromise]);
|
||||||
|
const allRows = [...textRows, ...mediaRows];
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
|
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
|
||||||
|
|||||||
@@ -178,22 +178,6 @@ export async function processBatch(
|
|||||||
messages,
|
messages,
|
||||||
})) as AnalysisWorkerResponse;
|
})) as AnalysisWorkerResponse;
|
||||||
|
|
||||||
// Do not broadcast or auto-delete if it's an API failure that will be reverted.
|
|
||||||
for (const row of result.rows) {
|
|
||||||
let isApiFailure = false;
|
|
||||||
if (row.ai_status === "error") {
|
|
||||||
try {
|
|
||||||
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
|
||||||
isApiFailure = flags.includes("analysis_api_failed");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isApiFailure) {
|
|
||||||
broadcastAnalysisCompleted(row);
|
|
||||||
scheduleAutoDelete(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post-batch reputation updates (fire-and-forget)
|
// Post-batch reputation updates (fire-and-forget)
|
||||||
postBatchReputationUpdate(
|
postBatchReputationUpdate(
|
||||||
result.rows.filter((r) => {
|
result.rows.filter((r) => {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const workerPool = new Piscina({
|
|||||||
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
||||||
execArgv: process.execArgv,
|
execArgv: process.execArgv,
|
||||||
maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(),
|
maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(),
|
||||||
|
// Each worker processes at most 1 task at a time so the pool itself
|
||||||
|
// acts as the concurrency governor. Combined with per-worker p-limit
|
||||||
|
// inside concurrencyLimiter.ts, this prevents LLM API overload.
|
||||||
|
concurrentTasksPerWorker: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,7 +232,7 @@ export function scheduleAutoDelete(row: MessageRecord): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (config.AUTO_DELETE_FLAGGED_DELAY_MS > 0) {
|
if (config.AUTO_DELETE_FLAGGED_DELAY_MS > 0) {
|
||||||
setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS);
|
setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS).unref();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setImmediate(run);
|
setImmediate(run);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ function updateCounts(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const queuedAt = activeCount + pendingCount;
|
|
||||||
pendingCount++;
|
pendingCount++;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
||||||
@@ -30,17 +29,17 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return llmSemaphore(async () => {
|
return llmSemaphore(async () => {
|
||||||
pendingCount--;
|
|
||||||
activeCount++;
|
|
||||||
|
|
||||||
if (activeCount >= (config.AI_LLM_MAX_CONCURRENT ?? 5)) {
|
|
||||||
logger.warn(
|
|
||||||
{ activeCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
|
||||||
"LLM concurrency limit reached",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
pendingCount--;
|
||||||
|
activeCount++;
|
||||||
|
|
||||||
|
if (activeCount >= (config.AI_LLM_MAX_CONCURRENT ?? 5)) {
|
||||||
|
logger.warn(
|
||||||
|
{ activeCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
||||||
|
"LLM concurrency limit reached",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
activeCount--;
|
activeCount--;
|
||||||
|
|||||||
@@ -277,6 +277,50 @@ async function processIndividualFallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Individual fallback retry queue (used when circuit breaker is active)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Messages awaiting retry when individual CB cools down. */
|
||||||
|
const individualRetryQueue = new LRUCache<string, MessageRecord>({ max: 10000 });
|
||||||
|
let individualRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const INDIVIDUAL_RETRY_CHECK_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a retry for messages that were skipped because the individual
|
||||||
|
* circuit breaker was active. Retries once after cooldown expires.
|
||||||
|
*/
|
||||||
|
function scheduleIndividualRetry(messages: MessageRecord[]): void {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!individualRetryQueue.has(msg.id)) {
|
||||||
|
individualRetryQueue.set(msg.id, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (individualRetryTimer === null) {
|
||||||
|
individualRetryTimer = setTimeout(() => {
|
||||||
|
individualRetryTimer = null;
|
||||||
|
if (Date.now() < individualCooldownUntil) {
|
||||||
|
// Still in cooldown — reschedule
|
||||||
|
scheduleIndividualRetry([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = [...individualRetryQueue.keys()];
|
||||||
|
const msgs: MessageRecord[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const m = individualRetryQueue.get(id);
|
||||||
|
if (m) {
|
||||||
|
individualRetryQueue.delete(id);
|
||||||
|
msgs.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
logger.info({ count: msgs.length }, "Retrying individual fallback messages after circuit breaker cooldown");
|
||||||
|
enqueueIndividualFallbacks(msgs);
|
||||||
|
}
|
||||||
|
}, INDIVIDUAL_RETRY_CHECK_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Enqueue individual fallbacks
|
// Enqueue individual fallbacks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -295,8 +339,9 @@ export function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
|
|||||||
until: new Date(individualCooldownUntil).toISOString(),
|
until: new Date(individualCooldownUntil).toISOString(),
|
||||||
skipped: messages.length,
|
skipped: messages.length,
|
||||||
},
|
},
|
||||||
"Individual fallback circuit breaker active -- messages will be recovered later",
|
"Individual fallback circuit breaker active — messages queued for retry",
|
||||||
);
|
);
|
||||||
|
scheduleIndividualRetry(messages);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
import { llmVision } from "./llmClient.js";
|
import { llmVision } from "./llmClient.js";
|
||||||
import { sanitizeAiContent } from "./moderationPrompt.js";
|
import { sanitizeAiContent } from "./moderationPrompt.js";
|
||||||
import {
|
import {
|
||||||
|
buildAttachmentTextOnlyWarning,
|
||||||
buildCustomEmojiVisionPrompt,
|
buildCustomEmojiVisionPrompt,
|
||||||
buildGeneralImageVisionPrompt,
|
buildGeneralImageVisionPrompt,
|
||||||
buildStickerTextOnlyWarning,
|
buildStickerTextOnlyWarning,
|
||||||
@@ -51,6 +52,7 @@ import { searchSearxng, extractSearchQueries, formatSearchResults } from "./sear
|
|||||||
import { getUserProfile } from "./userProfileStore.js";
|
import { getUserProfile } from "./userProfileStore.js";
|
||||||
import { initializeUserReputation } from "./userReputationStore.js";
|
import { initializeUserReputation } from "./userReputationStore.js";
|
||||||
import { escapeXml, getAnalysisContent, buildReferenceXml } from "./moderationBuilders.js";
|
import { escapeXml, getAnalysisContent, buildReferenceXml } from "./moderationBuilders.js";
|
||||||
|
import { createAbortTimeout, isAbortError } from "./abortHelper.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -292,50 +294,97 @@ async function downloadSingleAttachment(
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
maxDimension: number,
|
maxDimension: number,
|
||||||
imageMap: Map<string, MessageImagePart[]>,
|
imageMap: Map<string, MessageImagePart[]>,
|
||||||
|
mediaAnalysisMap?: Map<string, string[]>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const log = createChildLogger("mediaAnalysis");
|
const log = createChildLogger("mediaAnalysis");
|
||||||
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
|
|
||||||
if (!urlToUse) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
// Collect all available URLs — uploaded_url first (Telegram CDN, faster),
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
// then discord_url as fallback (may have expired CDN signature)
|
||||||
try {
|
const urlsToTry = [
|
||||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
att.uploaded_url,
|
||||||
if (!res.ok || !res.body) return;
|
att.discord_url,
|
||||||
|
].filter((url): url is string => url !== null && url !== undefined);
|
||||||
|
|
||||||
let totalBytes = 0;
|
// No URL at all — record a neutral fallback so downstream knows the
|
||||||
const chunks: Uint8Array[] = [];
|
// attachment existed but was unreachable
|
||||||
const reader = res.body.getReader();
|
if (urlsToTry.length === 0) {
|
||||||
while (true) {
|
if (mediaAnalysisMap) {
|
||||||
const { done, value } = await reader.read();
|
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||||
if (done) break;
|
existing.push(`[attachment: "${att.filename}" dari pesan id=${targetId} — tidak ada URL untuk diunduh]`);
|
||||||
if (value) {
|
mediaAnalysisMap.set(targetId, existing);
|
||||||
totalBytes += value.length;
|
}
|
||||||
if (totalBytes > 10 * 1024 * 1024) { reader.cancel(); return; }
|
return;
|
||||||
chunks.push(value);
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
// Try each URL, with 3 retries per URL (exponential backoff: 1s, 2s)
|
||||||
|
for (const url of urlsToTry) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const { signal, cleanup } = createAbortTimeout(15000);
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
cleanup();
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) {
|
||||||
|
totalBytes += value.length;
|
||||||
|
if (totalBytes > 10 * 1024 * 1024) {
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
res.body?.cancel().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const imageBytes = Buffer.concat(chunks);
|
||||||
|
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||||
|
|
||||||
|
if (!sniffedMime && att.type.startsWith("video/")) {
|
||||||
|
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sniffedMime) return;
|
||||||
|
|
||||||
|
const { data: resizedBuffer, mimeType: resizedMime } = await resizeImageForVision(imageBytes, maxDimension);
|
||||||
|
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||||
|
addImageToMap(imageMap, targetId, {
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: dataUrl },
|
||||||
|
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
return; // success!
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
log.warn(
|
||||||
|
{ attachmentId: att.id, url: url.slice(0, 80), attempt, error: lastError.message },
|
||||||
|
`Download attempt ${attempt + 1}/3 failed`,
|
||||||
|
);
|
||||||
|
if (attempt < 2) await delay(1000 * (attempt + 1)); // backoff: 1s, 2s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const imageBytes = Buffer.concat(chunks);
|
|
||||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
|
||||||
|
|
||||||
if (!sniffedMime && att.type.startsWith("video/")) {
|
|
||||||
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!sniffedMime) return;
|
|
||||||
|
|
||||||
const { data: resizedBuffer, mimeType: resizedMime } = await resizeImageForVision(imageBytes, maxDimension);
|
|
||||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
|
||||||
addImageToMap(imageMap, targetId, {
|
|
||||||
type: "image_url",
|
|
||||||
image_url: { url: dataUrl },
|
|
||||||
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
log.warn({ attachmentId: att.id, error: err instanceof Error ? err.message : String(err) }, "Download failed");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All URLs + all retries exhausted — record a neutral fallback
|
||||||
|
if (mediaAnalysisMap) {
|
||||||
|
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||||
|
existing.push(buildAttachmentTextOnlyWarning(att.filename, targetId));
|
||||||
|
mediaAnalysisMap.set(targetId, existing);
|
||||||
|
}
|
||||||
|
log.warn(
|
||||||
|
{ attachmentId: att.id, filename: att.filename, error: lastError?.message },
|
||||||
|
"All download attempts exhausted for attachment",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractVideoFrames(
|
async function extractVideoFrames(
|
||||||
@@ -344,6 +393,7 @@ async function extractVideoFrames(
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
maxDimension: number,
|
maxDimension: number,
|
||||||
imageMap: Map<string, MessageImagePart[]>,
|
imageMap: Map<string, MessageImagePart[]>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const log = createChildLogger("mediaAnalysis");
|
const log = createChildLogger("mediaAnalysis");
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -351,15 +401,19 @@ async function extractVideoFrames(
|
|||||||
const inputPath = path.join(tmpDir, att.filename || "video.mp4");
|
const inputPath = path.join(tmpDir, att.filename || "video.mp4");
|
||||||
const outputPattern = path.join(tmpDir, "frame-%03d.jpg");
|
const outputPattern = path.join(tmpDir, "frame-%03d.jpg");
|
||||||
try {
|
try {
|
||||||
|
if (signal?.aborted) return;
|
||||||
await writeFile(inputPath, videoBytes);
|
await writeFile(inputPath, videoBytes);
|
||||||
|
if (signal?.aborted) return;
|
||||||
const { stdout: durationStr } = await execFileAsync("/usr/bin/ffprobe", [
|
const { stdout: durationStr } = await execFileAsync("/usr/bin/ffprobe", [
|
||||||
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath,
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath,
|
||||||
], { timeout: 10000 });
|
], { timeout: 10000, signal });
|
||||||
|
if (signal?.aborted) return;
|
||||||
const duration = parseFloat(durationStr.trim()) || 1;
|
const duration = parseFloat(durationStr.trim()) || 1;
|
||||||
const fps = (3 / duration).toFixed(6);
|
const fps = (3 / duration).toFixed(6);
|
||||||
|
if (signal?.aborted) return;
|
||||||
await execFileAsync("/usr/bin/ffmpeg", [
|
await execFileAsync("/usr/bin/ffmpeg", [
|
||||||
"-i", inputPath, "-vf", `fps=${fps}`, "-frames:v", "4", "-vsync", "vfr", "-q:v", "2", outputPattern,
|
"-i", inputPath, "-vf", `fps=${fps}`, "-frames:v", "4", "-vsync", "vfr", "-q:v", "2", outputPattern,
|
||||||
], { timeout: 30000 });
|
], { timeout: 30000, signal });
|
||||||
for (let i = 1; i <= 4; i++) {
|
for (let i = 1; i <= 4; i++) {
|
||||||
try {
|
try {
|
||||||
const framePath = path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`);
|
const framePath = path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`);
|
||||||
@@ -377,10 +431,8 @@ async function extractVideoFrames(
|
|||||||
} catch (ffmpegErr) {
|
} catch (ffmpegErr) {
|
||||||
log.warn({ attachmentId: att.id, error: ffmpegErr instanceof Error ? ffmpegErr.message : String(ffmpegErr) }, "ffmpeg failed");
|
log.warn({ attachmentId: att.id, error: ffmpegErr instanceof Error ? ffmpegErr.message : String(ffmpegErr) }, "ffmpeg failed");
|
||||||
} finally {
|
} finally {
|
||||||
try { await unlink(inputPath); } catch { /* ignore */ }
|
// rm(tmpDir, { recursive: true }) already removes all files inside,
|
||||||
for (let i = 1; i <= 4; i++) {
|
// so individual unlink calls are redundant. Just clean up the whole dir.
|
||||||
try { await unlink(path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,7 +538,7 @@ export async function prepareMediaMessage(
|
|||||||
.filter((a) => a.message_id === targetId && (a.uploaded_url ?? a.discord_url ?? null) && (a.type.startsWith("image/") || a.type.startsWith("video/")))
|
.filter((a) => a.message_id === targetId && (a.uploaded_url ?? a.discord_url ?? null) && (a.type.startsWith("image/") || a.type.startsWith("video/")))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
for (const att of msgAttachments) {
|
for (const att of msgAttachments) {
|
||||||
downloadPromises.push(downloadSingleAttachment(att, targetId, maxDimension, imageMap));
|
downloadPromises.push(downloadSingleAttachment(att, targetId, maxDimension, imageMap, mediaAnalysisMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLs
|
// URLs
|
||||||
@@ -505,8 +557,8 @@ export async function prepareMediaMessage(
|
|||||||
await Promise.all(downloadPromises);
|
await Promise.all(downloadPromises);
|
||||||
if (urlWebTexts.length > 0) webTextMap.set(targetId, urlWebTexts);
|
if (urlWebTexts.length > 0) webTextMap.set(targetId, urlWebTexts);
|
||||||
|
|
||||||
// Vision analysis
|
// Vision analysis — use allSettled so one failure doesn't cascade
|
||||||
await Promise.all(
|
const visionResults = await Promise.allSettled(
|
||||||
Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
|
Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
|
||||||
images.map(async (image) => {
|
images.map(async (image) => {
|
||||||
const summary = await analyzeSingleMediaImage(msgId, image);
|
const summary = await analyzeSingleMediaImage(msgId, image);
|
||||||
@@ -516,6 +568,12 @@ export async function prepareMediaMessage(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Log any vision failures without aborting the batch
|
||||||
|
for (const r of visionResults) {
|
||||||
|
if (r.status === "rejected") {
|
||||||
|
log.warn({ error: r.reason instanceof Error ? r.reason.message : String(r.reason) }, "Individual vision analysis failed (batched)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SearXNG
|
// SearXNG
|
||||||
let searxngXml = "";
|
let searxngXml = "";
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||||
import { getUserProfile } from "./userProfileStore.js";
|
import { getUserProfile } from "./userProfileStore.js";
|
||||||
import { initializeUserReputation } from "./userReputationStore.js";
|
import { initializeUserReputation } from "./userReputationStore.js";
|
||||||
|
import { createAbortTimeout, isAbortError } from "./abortHelper.js";
|
||||||
|
|
||||||
const log = createChildLogger("moderationOrchestrator");
|
const log = createChildLogger("moderationOrchestrator");
|
||||||
|
|
||||||
@@ -40,10 +41,8 @@ interface RetryState {
|
|||||||
lastInvalidContent: string | null;
|
lastInvalidContent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ─── Few-shot correction builder ────────────────────────────────────────────
|
||||||
// Few-shot correction builder
|
const _buildCorrectedFewShotExamples = async (): Promise<string> => {
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function buildCorrectedFewShotExamples(): Promise<string> {
|
|
||||||
try {
|
try {
|
||||||
const corrections = await getRecentCorrectedModerations(5);
|
const corrections = await getRecentCorrectedModerations(5);
|
||||||
if (corrections.length === 0) return "";
|
if (corrections.length === 0) return "";
|
||||||
@@ -62,6 +61,22 @@ async function buildCorrectedFewShotExamples(): Promise<string> {
|
|||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── In-memory cache untuk correctedFewShotExamples ──────────────────────
|
||||||
|
// getCachedFewShotExamples() dipanggil di banyak tempat (setiap sub-batch
|
||||||
|
// dan retry), padahal datanya jarang berubah. Cache sederhana TTL 60 detik
|
||||||
|
// mengurangi redundant DB queries dari O(retries × subBatches) ke O(1).
|
||||||
|
let _fewShotCache: { result: string; expiresAt: number } | null = null;
|
||||||
|
const FEW_SHOT_CACHE_TTL = 60_000; // 60 detik
|
||||||
|
|
||||||
|
async function getCachedFewShotExamples(): Promise<string> {
|
||||||
|
if (_fewShotCache && Date.now() < _fewShotCache.expiresAt) {
|
||||||
|
return _fewShotCache.result;
|
||||||
|
}
|
||||||
|
const result = await _buildCorrectedFewShotExamples();
|
||||||
|
_fewShotCache = { result, expiresAt: Date.now() + FEW_SHOT_CACHE_TTL };
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -208,7 +223,25 @@ async function runTextOnlyBatch(
|
|||||||
}
|
}
|
||||||
const urlArr = Array.from(allUrls).slice(0, 10);
|
const urlArr = Array.from(allUrls).slice(0, 10);
|
||||||
if (urlArr.length === 0) return new Map<string, string>();
|
if (urlArr.length === 0) return new Map<string, string>();
|
||||||
const results = await Promise.allSettled(urlArr.map((url) => fetchUrlSafely(url)));
|
|
||||||
|
// Per-host rate limiting: add a small delay between requests to the same
|
||||||
|
// domain to avoid overwhelming third-party servers with concurrent fetches.
|
||||||
|
const hostGroups = new Map<string, string[]>();
|
||||||
|
for (const url of urlArr) {
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname;
|
||||||
|
const group = hostGroups.get(host) ?? [];
|
||||||
|
group.push(url);
|
||||||
|
hostGroups.set(host, group);
|
||||||
|
} catch { /* invalid URL, skip */ }
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from(hostGroups.values()).flatMap((group) =>
|
||||||
|
group.map((url, idx) => () =>
|
||||||
|
idx > 0 ? delay(200 * idx).then(() => fetchUrlSafely(url)) : fetchUrlSafely(url),
|
||||||
|
),
|
||||||
|
).map((fn) => fn()),
|
||||||
|
);
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (let i = 0; i < urlArr.length; i++) {
|
for (let i = 0; i < urlArr.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
@@ -291,7 +324,7 @@ async function runTextOnlyBatch(
|
|||||||
|
|
||||||
const buildContent = async (state: RetryState): Promise<string> => {
|
const buildContent = async (state: RetryState): Promise<string> => {
|
||||||
const correction = state.lastParseError ? { error: state.lastParseError, preview: state.lastInvalidContent?.slice(0, 800) ?? "<empty>" } : undefined;
|
const correction = state.lastParseError ? { error: state.lastParseError, preview: state.lastInvalidContent?.slice(0, 800) ?? "<empty>" } : undefined;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
const correctedExamples = await getCachedFewShotExamples();
|
||||||
const systemText = buildSystemPromptModular({ contextText, mode: "text", correction, correctedExamples, channelCulture });
|
const systemText = buildSystemPromptModular({ contextText, mode: "text", correction, correctedExamples, channelCulture });
|
||||||
|
|
||||||
const messagesBlock = (await Promise.all(batch.map(async (msg) => {
|
const messagesBlock = (await Promise.all(batch.map(async (msg) => {
|
||||||
@@ -314,20 +347,18 @@ async function runTextOnlyBatch(
|
|||||||
return `${systemText}${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
|
return `${systemText}${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const { signal, cleanup } = createAbortTimeout(timeoutMs);
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
||||||
timeoutId.unref();
|
|
||||||
|
|
||||||
let batchResult: { results: AnalysisResult[]; raw: unknown };
|
let batchResult: { results: AnalysisResult[]; raw: unknown };
|
||||||
try {
|
try {
|
||||||
batchResult = await callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`, abortController.signal);
|
batchResult = await callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`, signal);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
if (isAbortError(err)) {
|
||||||
throw new Error(`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`);
|
throw new Error(`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fan-out results for deduplicated messages
|
// Fan-out results for deduplicated messages
|
||||||
@@ -372,7 +403,7 @@ async function runMediaBatch(
|
|||||||
const channelId = targets[0].channel_id;
|
const channelId = targets[0].channel_id;
|
||||||
const channelCultureObj = channelId ? await getChannelCulture(channelId) : null;
|
const channelCultureObj = channelId ? await getChannelCulture(channelId) : null;
|
||||||
const channelCulture = channelCultureObj?.culture_summary;
|
const channelCulture = channelCultureObj?.culture_summary;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
const correctedExamples = await getCachedFewShotExamples();
|
||||||
const systemText = buildSystemPromptModular({ contextText, mode: "mixed", correctedExamples, channelCulture });
|
const systemText = buildSystemPromptModular({ contextText, mode: "mixed", correctedExamples, channelCulture });
|
||||||
|
|
||||||
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
||||||
@@ -381,26 +412,24 @@ async function runMediaBatch(
|
|||||||
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||||
const batchTimeout = Math.min(Math.max(perMsgTimeout, perMsgTimeout * targets.length), 300_000);
|
const batchTimeout = Math.min(Math.max(perMsgTimeout, perMsgTimeout * targets.length), 300_000);
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const { signal, cleanup } = createAbortTimeout(batchTimeout);
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), batchTimeout);
|
|
||||||
timeoutId.unref();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await callModerationLLM(
|
const result = await callModerationLLM(
|
||||||
async (_state: RetryState) => userContent,
|
async (_state: RetryState) => userContent,
|
||||||
targetIds,
|
targetIds,
|
||||||
`media-batch:${targetIds.length}msgs`,
|
`media-batch:${targetIds.length}msgs`,
|
||||||
abortController.signal,
|
signal,
|
||||||
);
|
);
|
||||||
log.info({ mediaCount: targets.length, resultCount: result.results.length }, "Media batch analysis complete");
|
log.info({ mediaCount: targets.length, resultCount: result.results.length }, "Media batch analysis complete");
|
||||||
return result;
|
return result;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
if (isAbortError(err)) {
|
||||||
throw new Error(`Media batch analysis timed out after ${batchTimeout}ms for ${targets.length} messages`);
|
throw new Error(`Media batch analysis timed out after ${batchTimeout}ms for ${targets.length} messages`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +475,7 @@ export async function runModerationAnalysis(
|
|||||||
const rawContent = target.edited_content ?? target.content;
|
const rawContent = target.edited_content ?? target.content;
|
||||||
if (!rawContent.trim()) { uncachedTargets.push(target); continue; }
|
if (!rawContent.trim()) { uncachedTargets.push(target); continue; }
|
||||||
|
|
||||||
const cacheKey = makeTextModerationCacheKey(rawContent);
|
const cacheKey = makeTextModerationCacheKey(rawContent, target.user_id);
|
||||||
if (seenCacheKeys.has(cacheKey)) {
|
if (seenCacheKeys.has(cacheKey)) {
|
||||||
const previousHit = cacheHits.find((h) => h.messageId !== target.id);
|
const previousHit = cacheHits.find((h) => h.messageId !== target.id);
|
||||||
if (previousHit) {
|
if (previousHit) {
|
||||||
@@ -534,7 +563,7 @@ export async function runModerationAnalysis(
|
|||||||
if (evidence.attachments.length > 0 || evidence.stickers.length > 0 || evidence.embeds.length > 0) continue;
|
if (evidence.attachments.length > 0 || evidence.stickers.length > 0 || evidence.embeds.length > 0) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = makeTextModerationCacheKey(rawContent);
|
const cacheKey = makeTextModerationCacheKey(rawContent, target.user_id);
|
||||||
setCachedTextModeration(cacheKey, {
|
setCachedTextModeration(cacheKey, {
|
||||||
flags: result.flags ?? [],
|
flags: result.flags ?? [],
|
||||||
score: result.score ?? 0,
|
score: result.score ?? 0,
|
||||||
@@ -544,7 +573,7 @@ export async function runModerationAnalysis(
|
|||||||
confidence: result.confidence ?? result.score ?? 0,
|
confidence: result.confidence ?? result.score ?? 0,
|
||||||
recommendedAction: result.recommendedAction ?? "none",
|
recommendedAction: result.recommendedAction ?? "none",
|
||||||
status: result.status,
|
status: result.status,
|
||||||
}).catch(() => {});
|
}).catch((e) => log.error({ error: e instanceof Error ? e.message : String(e) }, "Failed to cache text moderation result"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allResults = [...cacheHits, ...textBatchResult.results, ...mediaBatchResult.results];
|
const allResults = [...cacheHits, ...textBatchResult.results, ...mediaBatchResult.results];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
import { createAbortTimeout } from "./abortHelper.js";
|
||||||
|
|
||||||
const log = createChildLogger("searxng-search");
|
const log = createChildLogger("searxng-search");
|
||||||
|
|
||||||
@@ -8,15 +9,97 @@ const MAX_RESULTS = 3;
|
|||||||
const TIMEOUT_MS = 8000;
|
const TIMEOUT_MS = 8000;
|
||||||
const CACHE_TTL = 86400; // 24 hours
|
const CACHE_TTL = 86400; // 24 hours
|
||||||
const CACHE_PREFIX = "searxng:";
|
const CACHE_PREFIX = "searxng:";
|
||||||
|
/** How often to attempt reconnection when Redis is down (ms). */
|
||||||
|
const RECONNECT_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
let redis: Redis | null = null;
|
let redis: Redis | null = null;
|
||||||
|
let _initialized = false;
|
||||||
|
let _redisUrl = "";
|
||||||
|
/** Tracks whether Redis is currently healthy (connected + responding). */
|
||||||
|
let _redisHealthy = false;
|
||||||
|
let _lastLogAt = 0; // throttle repeated warn logs to once per 60s
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health tracking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the SearXNG Redis cache is connected and healthy.
|
||||||
|
* Use this for health-check endpoints or status dashboards.
|
||||||
|
*/
|
||||||
|
export function isSearxngCacheAvailable(): boolean {
|
||||||
|
return _redisHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable status string for logging / health endpoints.
|
||||||
|
*/
|
||||||
|
export function getSearxngCacheStatus(): string {
|
||||||
|
if (!_initialized) return "not-initialized";
|
||||||
|
if (!redis) return "no-url";
|
||||||
|
return _redisHealthy ? "healthy" : "disconnected";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reconnection helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a one-shot reconnection attempt after RECONNECT_INTERVAL_MS.
|
||||||
|
* Only one reconnection timer runs at a time.
|
||||||
|
*/
|
||||||
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleReconnect(): void {
|
||||||
|
if (_reconnectTimer) return; // already scheduled
|
||||||
|
_reconnectTimer = setTimeout(async () => {
|
||||||
|
_reconnectTimer = null;
|
||||||
|
if (!redis || _redisHealthy) return; // nothing to do
|
||||||
|
|
||||||
|
log.info("SearXNG Redis: attempting reconnection...");
|
||||||
|
try {
|
||||||
|
// ioredis reconnects automatically if `lazyConnect` is false,
|
||||||
|
// but we set it to true, so we need to manually call connect().
|
||||||
|
await redis.connect();
|
||||||
|
// If we get here, connection succeeded
|
||||||
|
_redisHealthy = true;
|
||||||
|
log.info("SearXNG Redis: reconnected successfully ✅");
|
||||||
|
} catch {
|
||||||
|
_redisHealthy = false;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _lastLogAt > 60_000) {
|
||||||
|
log.warn(
|
||||||
|
{ nextRetryMs: RECONNECT_INTERVAL_MS },
|
||||||
|
"SearXNG Redis: reconnection failed — will retry",
|
||||||
|
);
|
||||||
|
_lastLogAt = now;
|
||||||
|
}
|
||||||
|
// Schedule another attempt
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}, RECONNECT_INTERVAL_MS);
|
||||||
|
_reconnectTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Redis connection for SearXNG cache.
|
* Initialize Redis connection for SearXNG cache.
|
||||||
* Safe to call multiple times — only creates one connection.
|
* Safe to call multiple times — only creates one connection.
|
||||||
|
* Returns true if Redis cache is available, false if falling back to no-cache.
|
||||||
*/
|
*/
|
||||||
export function initSearxngCache(redisUrl: string): void {
|
export function initSearxngCache(redisUrl: string): boolean {
|
||||||
if (redis) return;
|
if (_initialized) return _redisHealthy;
|
||||||
|
_initialized = true;
|
||||||
|
_redisUrl = redisUrl;
|
||||||
|
|
||||||
|
if (!redisUrl) {
|
||||||
|
log.warn("No REDIS_URL provided — SearXNG cache disabled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
redis = new Redis(redisUrl, {
|
redis = new Redis(redisUrl, {
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
retryStrategy(times) {
|
retryStrategy(times) {
|
||||||
@@ -26,16 +109,92 @@ export function initSearxngCache(redisUrl: string): void {
|
|||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
enableReadyCheck: false,
|
enableReadyCheck: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
redis.on("error", (err) => {
|
redis.on("error", (err) => {
|
||||||
log.warn({ err: err.message }, "SearXNG Redis cache error");
|
const wasHealthy = _redisHealthy;
|
||||||
|
_redisHealthy = false;
|
||||||
|
if (wasHealthy) {
|
||||||
|
// State transition: healthy → unhealthy — always log
|
||||||
|
log.warn(
|
||||||
|
{ error: err.message },
|
||||||
|
"SearXNG Redis: connection lost — falling back to no-cache",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Already unhealthy — throttle repeated error logs
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _lastLogAt > 60_000) {
|
||||||
|
log.warn(
|
||||||
|
{ error: err.message },
|
||||||
|
"SearXNG Redis: still disconnected",
|
||||||
|
);
|
||||||
|
_lastLogAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redis.on("ready", () => {
|
||||||
|
if (!_redisHealthy) {
|
||||||
|
_redisHealthy = true;
|
||||||
|
log.info("SearXNG Redis: connected and healthy ✅");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
redis.connect().catch(() => {
|
redis.connect().catch(() => {
|
||||||
log.warn("SearXNG Redis cache unavailable — falling back to no-cache");
|
_redisHealthy = false;
|
||||||
redis = null;
|
log.warn("SearXNG Redis: initial connection failed — running without cache");
|
||||||
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("SearXNG Redis cache initialized");
|
log.info("SearXNG Redis cache initialized");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache operations with visible failure logging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function cacheGet(key: string): Promise<string | null> {
|
||||||
|
if (!redis || !_redisHealthy) return null;
|
||||||
|
try {
|
||||||
|
const result = await redis.get(key);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// Log once per minute to avoid log spam
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _lastLogAt > 60_000) {
|
||||||
|
log.warn(
|
||||||
|
{ error: err instanceof Error ? err.message : String(err) },
|
||||||
|
"SearXNG Redis: cache read failed",
|
||||||
|
);
|
||||||
|
_lastLogAt = now;
|
||||||
|
}
|
||||||
|
_redisHealthy = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, value: string, ttlSeconds: number): void {
|
||||||
|
if (!redis || !_redisHealthy) return;
|
||||||
|
redis.setex(key, ttlSeconds, value).catch((err) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - _lastLogAt > 60_000) {
|
||||||
|
log.warn(
|
||||||
|
{ error: err instanceof Error ? err.message : String(err) },
|
||||||
|
"SearXNG Redis: cache write failed",
|
||||||
|
);
|
||||||
|
_lastLogAt = now;
|
||||||
|
}
|
||||||
|
_redisHealthy = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface SearxngResult {
|
export interface SearxngResult {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -53,33 +212,30 @@ export async function searchSearxng(
|
|||||||
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
|
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
if (redis) {
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
log.debug({ query, category }, "SearXNG cache HIT");
|
||||||
try {
|
try {
|
||||||
const cached = await redis.get(cacheKey);
|
return JSON.parse(cached) as SearxngResult[];
|
||||||
if (cached) {
|
|
||||||
log.debug({ query, category }, "SearXNG cache HIT");
|
|
||||||
return JSON.parse(cached) as SearxngResult[];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Cache read failed, continue to API
|
// Corrupted cache entry — continue to API
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss — hit SearXNG API
|
// Cache miss — hit SearXNG API
|
||||||
try {
|
try {
|
||||||
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
|
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
|
||||||
const controller = new AbortController();
|
const { signal, cleanup } = createAbortTimeout(TIMEOUT_MS);
|
||||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
signal: controller.signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
clearTimeout(timeoutId);
|
cleanup();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
log.warn({ status: response.status, query }, "SearXNG search failed");
|
log.warn({ status: response.status, query }, "SearXNG search failed");
|
||||||
@@ -97,11 +253,7 @@ export async function searchSearxng(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Store in cache (fire and forget — don't block on write)
|
// Store in cache (fire and forget — don't block on write)
|
||||||
if (redis) {
|
cacheSet(cacheKey, JSON.stringify(mapped), CACHE_TTL);
|
||||||
redis.setex(cacheKey, CACHE_TTL, JSON.stringify(mapped)).catch(() => {
|
|
||||||
// Cache write failed silently
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug({ query, category, resultCount: mapped.length }, "SearXNG search OK");
|
log.debug({ query, category, resultCount: mapped.length }, "SearXNG search OK");
|
||||||
return mapped;
|
return mapped;
|
||||||
@@ -114,6 +266,10 @@ export async function searchSearxng(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract meaningful search queries from message content.
|
* Extract meaningful search queries from message content.
|
||||||
* Uses multiple strategies to find terms worth searching.
|
* Uses multiple strategies to find terms worth searching.
|
||||||
@@ -179,6 +335,10 @@ export function extractSearchQueries(content: string): string[] {
|
|||||||
return Array.from(queries).slice(0, 3);
|
return Array.from(queries).slice(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format SearXNG results as XML for LLM context.
|
* Format SearXNG results as XML for LLM context.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ export function buildStickerTextOnlyWarning(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a neutral fallback text when an image/video attachment cannot be
|
||||||
|
* downloaded or analyzed. Unlike the sticker warning, this is deliberately
|
||||||
|
* neutral — it only records *that* an attachment existed, without any
|
||||||
|
* instruction to the LLM about how to treat it. The absence of visual
|
||||||
|
* data already means the LLM must rely on message content alone.
|
||||||
|
*
|
||||||
|
* Returns a formatted string for inclusion in the media context block.
|
||||||
|
*/
|
||||||
|
export function buildAttachmentTextOnlyWarning(
|
||||||
|
filename: string,
|
||||||
|
messageId: string,
|
||||||
|
): string {
|
||||||
|
const fallback = `[attachment: "${filename}" dari pesan id=${messageId} — tidak tersedia untuk analisis visual]`;
|
||||||
|
logger.debug({ filename, messageId }, "Built attachment text-only fallback");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt used when a custom emoji image was successfully downloaded
|
* Prompt used when a custom emoji image was successfully downloaded
|
||||||
* and is being sent to the vision LLM as a base64 image.
|
* and is being sent to the vision LLM as a base64 image.
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export async function getCachedText(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert or update a text analysis cache entry.
|
* Insert or update a text analysis cache entry.
|
||||||
|
*
|
||||||
|
* NOTE: The INSERT ... ON CONFLICT pattern is intentional — for new cache
|
||||||
|
* keys we always INSERT rather than checking existence first, so there is
|
||||||
|
* no TOCTOU race. The ON CONFLICT DO UPDATE handles the case where another
|
||||||
|
* worker inserted the same key between our check and our insert.
|
||||||
*/
|
*/
|
||||||
export async function upsertCachedText(
|
export async function upsertCachedText(
|
||||||
text: string,
|
text: string,
|
||||||
@@ -291,12 +296,16 @@ export async function deleteCachedMediaAnalysis(
|
|||||||
/**
|
/**
|
||||||
* Generate a deterministic cache key for a per-user moderation result.
|
* Generate a deterministic cache key for a per-user moderation result.
|
||||||
*
|
*
|
||||||
* Format: user_mod:<userId>:<sha256(content).slice(0,16)>
|
* Format: text_mod:<userId>:<sha256(content).slice(0,16)>
|
||||||
* Two users sending the same text get separate cache entries so that
|
* By including userId, two users sending the same text get separate
|
||||||
* per-user action history (e.g. repeated spam) can be tracked later.
|
* cache entries so per-user context (reputation, username flags, etc.)
|
||||||
|
* is respected.
|
||||||
*/
|
*/
|
||||||
export function makeTextModerationCacheKey(content: string): string {
|
export function makeTextModerationCacheKey(content: string, userId?: string): string {
|
||||||
const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||||
|
if (userId) {
|
||||||
|
return `text_mod:${userId}:${hash}`;
|
||||||
|
}
|
||||||
return `text_mod:${hash}`;
|
return `text_mod:${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolve } from "node:dns/promises";
|
import { resolve } from "node:dns/promises";
|
||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
import { createAbortTimeout } from "./abortHelper.js";
|
||||||
|
|
||||||
const log = createChildLogger("urlFetcher");
|
const log = createChildLogger("urlFetcher");
|
||||||
|
|
||||||
@@ -15,52 +16,103 @@ export interface FetchedUrlContext {
|
|||||||
|
|
||||||
const MAX_FETCH_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_FETCH_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/gi;
|
const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')?\]\s]/gi;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// SSRF Protection with DNS Rebinding Defense
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Strategy: Resolve the hostname to IP addresses BEFORE fetching, then fetch
|
||||||
|
// directly from a pinned IP (using a Host header for virtual hosting).
|
||||||
|
// This prevents DNS rebinding where a domain alternates between a public IP
|
||||||
|
// and an internal IP (127.0.0.1, 10.x.x.x) between the check and the fetch.
|
||||||
|
//
|
||||||
|
// Edge cases handled:
|
||||||
|
// - No DNS records → reject (cannot fetch)
|
||||||
|
// - Multiple IPs (round-robin DNS) → pick first public one
|
||||||
|
// - All IPs are internal → reject
|
||||||
|
// - Direct IP literal → validate and pass through
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface PinnedAddress {
|
||||||
|
/** The original hostname from the URL (used in Host header) */
|
||||||
|
hostname: string;
|
||||||
|
/** The pinned, validated IP address to connect to (already vetted as safe) */
|
||||||
|
ip: string;
|
||||||
|
/** The port from the original URL */
|
||||||
|
port: string;
|
||||||
|
/** The protocol (http: or https:) */
|
||||||
|
protocol: string;
|
||||||
|
/** The pathname + search + hash (everything after host:port) */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(ip: string): boolean {
|
||||||
|
return (
|
||||||
|
ip === "127.0.0.1" ||
|
||||||
|
ip === "::1" ||
|
||||||
|
ip === "0.0.0.0" ||
|
||||||
|
ip.startsWith("192.168.") ||
|
||||||
|
ip.startsWith("10.") ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
|
||||||
|
ip.startsWith("169.254.") || // link-local
|
||||||
|
ip.startsWith("fc") || // IPv6 unique local (fc00::/7)
|
||||||
|
ip.startsWith("fd") // IPv6 unique local
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basic SSRF protection.
|
* Resolve a hostname to a pinned IP address.
|
||||||
* Note: A sophisticated attacker could still use DNS rebinding.
|
* Returns the first public IP found, or null if all resolved IPs are private.
|
||||||
|
* Also returns null if the host is a private IP literal.
|
||||||
|
*
|
||||||
|
* This function is the sole gate — once a safe IP is returned, the caller
|
||||||
|
* MUST use it directly without re-resolving the hostname.
|
||||||
*/
|
*/
|
||||||
async function isSafeUrl(urlStr: string): Promise<boolean> {
|
async function resolveAndPinAddress(urlStr: string): Promise<PinnedAddress | null> {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(urlStr);
|
const parsed = new URL(urlStr);
|
||||||
const host = parsed.hostname;
|
const host = parsed.hostname;
|
||||||
|
const protocol = parsed.protocol; // "http:" or "https:"
|
||||||
|
const port = parsed.port || (protocol === "https:" ? "443" : "80");
|
||||||
|
const path = parsed.pathname + parsed.search + parsed.hash;
|
||||||
|
|
||||||
// Block obvious local IPs/hostnames
|
// Block private IP literals immediately
|
||||||
|
if (isIP(host)) {
|
||||||
|
if (isPrivateIP(host)) return null;
|
||||||
|
// Direct public IP literal — can fetch directly
|
||||||
|
return { hostname: host, ip: host, port, protocol, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block obvious private hostnames
|
||||||
if (
|
if (
|
||||||
host === "localhost" ||
|
host === "localhost" ||
|
||||||
host === "127.0.0.1" ||
|
host === "localhost.localdomain" ||
|
||||||
host === "::1" ||
|
host.endsWith(".local") ||
|
||||||
host.startsWith("192.168.") ||
|
host.endsWith(".internal")
|
||||||
host.startsWith("10.") ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host)
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try resolving to check if it resolves to a local IP
|
// Resolve hostname to IP addresses
|
||||||
if (!isIP(host)) {
|
let addresses: string[];
|
||||||
try {
|
try {
|
||||||
const addresses = await resolve(host);
|
addresses = await resolve(host);
|
||||||
for (const ip of addresses) {
|
} catch {
|
||||||
if (
|
// DNS resolution failed — can't verify safety
|
||||||
ip === "127.0.0.1" ||
|
return null;
|
||||||
ip.startsWith("192.168.") ||
|
|
||||||
ip.startsWith("10.") ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// If DNS fails, we can't fetch it anyway
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
if (addresses.length === 0) return null;
|
||||||
} catch (err) {
|
|
||||||
return false;
|
// Pick the first non-private IP
|
||||||
|
const publicIp = addresses.find((ip) => !isPrivateIP(ip));
|
||||||
|
if (!publicIp) return null;
|
||||||
|
|
||||||
|
// We now have a pinned, verified safe IP.
|
||||||
|
// The caller MUST use this IP directly for the fetch.
|
||||||
|
return { hostname: host, ip: publicIp, port, protocol, path };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,20 +160,27 @@ export async function fetchUrlSafely(
|
|||||||
return { url, type: "error", error: "Max redirect/meta depth reached" };
|
return { url, type: "error", error: "Max redirect/meta depth reached" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isSafeUrl(url))) {
|
// Resolve + pin IP address FIRST (defence against DNS rebinding).
|
||||||
|
// The pinned IP is used directly — we never re-resolve the hostname.
|
||||||
|
const pinned = await resolveAndPinAddress(url);
|
||||||
|
if (!pinned) {
|
||||||
return { url, type: "error", error: "Unsafe URL blocked" };
|
return { url, type: "error", error: "Unsafe URL blocked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
// Reconstruct the URL using the pinned IP directly, keeping original Host
|
||||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const pinnedUrl = `${pinned.protocol}//${pinned.ip}:${pinned.port}${pinned.path}`;
|
||||||
|
|
||||||
|
const { signal, cleanup } = createAbortTimeout(FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(pinnedUrl, {
|
||||||
signal: controller.signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 DiscordBot/2.0",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 DiscordBot/2.0",
|
||||||
Accept: "image/webp,image/apng,image/*,*/*;q=0.8",
|
Accept: "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
// Use original hostname so virtual hosting still works
|
||||||
|
Host: pinned.hostname,
|
||||||
},
|
},
|
||||||
// Do not follow more than a few redirects natively, fetch handles up to 20 by default
|
// Do not follow more than a few redirects natively, fetch handles up to 20 by default
|
||||||
});
|
});
|
||||||
@@ -190,7 +249,7 @@ export async function fetchUrlSafely(
|
|||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,10 @@ async function learnUserProfile(
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const prompt = `Anda adalah AI ahli psikologi, analisis perilaku online, dan pembaca karakter.
|
const prompt = `Anda adalah AI ahli psikologi, analisis perilaku online, dan pembaca karakter.
|
||||||
Tugas Anda adalah merangkum profil kepribadian SEORANG PRIBADI — bukan sekadar statistik
|
Tugas Anda adalah merangkum profil kepribadian SEORANG PRIBADI berdasarkan riwayat pesan-pesan mereka di server Discord.
|
||||||
gaya bicara — berdasarkan riwayat pesan-pesan mereka di server Discord.
|
|
||||||
Buatlah ringkasan yang KAYA AKAN PERSONALITAS sehingga pembaca merasa "mengenal" orang ini.
|
Buatlah ringkasan yang KAYA AKAN PERSONALITAS sehingga pembaca merasa "mengenal" orang ini.
|
||||||
|
|
||||||
Pesan-pesan terakhir dari user "${userId}" (hanya pesan bersih/clean):
|
Pesan-pesan terakhir dari user (hanya pesan bersih/clean):
|
||||||
<messages>
|
<messages>
|
||||||
${messagesText}
|
${messagesText}
|
||||||
</messages>
|
</messages>
|
||||||
@@ -122,7 +121,18 @@ atau konten SARA, itu akan SANGAT tidak sesuai dengan karakternya dan patut dicu
|
|||||||
const text = completion.choices[0]?.message?.content?.trim();
|
const text = completion.choices[0]?.message?.content?.trim();
|
||||||
if (!text) throw new Error("Empty response from LLM");
|
if (!text) throw new Error("Empty response from LLM");
|
||||||
|
|
||||||
await updateUserProfile(userId, guildId, text);
|
// Sanitize the AI-generated profile before saving to prevent
|
||||||
|
// prompt injection when the profile is later injected into prompts.
|
||||||
|
// Strip markdown code fences and XML special chars.
|
||||||
|
const sanitized = text
|
||||||
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
|
.replace(/[<>&"']/g, (ch) => {
|
||||||
|
const entities: Record<string, string> = { "<": "<", ">": ">", "&": "&", '"': """, "'": "'" };
|
||||||
|
return entities[ch] || ch;
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
await updateUserProfile(userId, guildId, sanitized);
|
||||||
log.info(
|
log.info(
|
||||||
{ userId, guildId },
|
{ userId, guildId },
|
||||||
"Successfully learned and updated user profile",
|
"Successfully learned and updated user profile",
|
||||||
|
|||||||
@@ -12,19 +12,25 @@ let rawPool: Pool | null = null;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the PostgreSQL database connection.
|
* Initialize the PostgreSQL database connection.
|
||||||
|
* When called from a Piscina worker thread, pool min/max are reduced to
|
||||||
|
* avoid exhausting PG connections across many worker processes.
|
||||||
*/
|
*/
|
||||||
export async function initializeDatabase() {
|
export async function initializeDatabase() {
|
||||||
if (db !== null) {
|
if (db !== null) {
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWorker = typeof process.env.PISCINA_WORKER !== "undefined";
|
||||||
|
const poolMin = isWorker ? 1 : config.POSTGRES_POOL_MIN;
|
||||||
|
const poolMax = isWorker ? 2 : config.POSTGRES_POOL_MAX;
|
||||||
|
|
||||||
let pool: Pool;
|
let pool: Pool;
|
||||||
|
|
||||||
if (config.DATABASE_URL) {
|
if (config.DATABASE_URL) {
|
||||||
pool = new Pool({
|
pool = new Pool({
|
||||||
connectionString: config.DATABASE_URL,
|
connectionString: config.DATABASE_URL,
|
||||||
min: config.POSTGRES_POOL_MIN,
|
min: poolMin,
|
||||||
max: config.POSTGRES_POOL_MAX,
|
max: poolMax,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pool = new Pool({
|
pool = new Pool({
|
||||||
@@ -33,8 +39,8 @@ export async function initializeDatabase() {
|
|||||||
user: config.POSTGRES_USER,
|
user: config.POSTGRES_USER,
|
||||||
password: config.POSTGRES_PASSWORD,
|
password: config.POSTGRES_PASSWORD,
|
||||||
database: config.POSTGRES_DB,
|
database: config.POSTGRES_DB,
|
||||||
min: config.POSTGRES_POOL_MIN,
|
min: poolMin,
|
||||||
max: config.POSTGRES_POOL_MAX,
|
max: poolMax,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
declare module 'astro:content' {
|
||||||
|
export interface RenderResult {
|
||||||
|
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||||
|
headings: import('astro').MarkdownHeading[];
|
||||||
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
|
}
|
||||||
|
interface Render {
|
||||||
|
'.md': Promise<RenderResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedContent {
|
||||||
|
html: string;
|
||||||
|
metadata?: {
|
||||||
|
imagePaths: Array<string>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||||
|
|
||||||
|
export type CollectionKey = keyof DataEntryMap;
|
||||||
|
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||||
|
|
||||||
|
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||||
|
|
||||||
|
export type ReferenceDataEntry<
|
||||||
|
C extends CollectionKey,
|
||||||
|
E extends keyof DataEntryMap[C] = string,
|
||||||
|
> = {
|
||||||
|
collection: C;
|
||||||
|
id: E;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||||
|
collection: C;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||||
|
): Promise<E[]>;
|
||||||
|
export function getCollection<C extends keyof DataEntryMap>(
|
||||||
|
collection: C,
|
||||||
|
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter?: LiveLoaderCollectionFilterType<C>,
|
||||||
|
): Promise<
|
||||||
|
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
entry: ReferenceDataEntry<C, E>,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getEntry<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
E extends keyof DataEntryMap[C] | (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
id: E,
|
||||||
|
): E extends keyof DataEntryMap[C]
|
||||||
|
? string extends keyof DataEntryMap[C]
|
||||||
|
? Promise<DataEntryMap[C][E]> | undefined
|
||||||
|
: Promise<DataEntryMap[C][E]>
|
||||||
|
: Promise<CollectionEntry<C> | undefined>;
|
||||||
|
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||||
|
collection: C,
|
||||||
|
filter: string | LiveLoaderEntryFilterType<C>,
|
||||||
|
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||||
|
|
||||||
|
/** Resolve an array of entry references from the same collection */
|
||||||
|
export function getEntries<C extends keyof DataEntryMap>(
|
||||||
|
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||||
|
): Promise<CollectionEntry<C>[]>;
|
||||||
|
|
||||||
|
export function render<C extends keyof DataEntryMap>(
|
||||||
|
entry: DataEntryMap[C][string],
|
||||||
|
): Promise<RenderResult>;
|
||||||
|
|
||||||
|
export function reference<
|
||||||
|
C extends
|
||||||
|
| keyof DataEntryMap
|
||||||
|
// Allow generic `string` to avoid excessive type errors in the config
|
||||||
|
// if `dev` is not running to update as you edit.
|
||||||
|
// Invalid collection names will be caught at build time.
|
||||||
|
| (string & {}),
|
||||||
|
>(
|
||||||
|
collection: C,
|
||||||
|
): import('astro/zod').ZodPipe<
|
||||||
|
import('astro/zod').ZodString,
|
||||||
|
import('astro/zod').ZodTransform<
|
||||||
|
C extends keyof DataEntryMap
|
||||||
|
? {
|
||||||
|
collection: C;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
: never,
|
||||||
|
string
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||||
|
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||||
|
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||||
|
>;
|
||||||
|
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||||
|
type InferLoaderSchema<
|
||||||
|
C extends keyof DataEntryMap,
|
||||||
|
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||||
|
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||||
|
? import('astro/zod').infer<L['schema']>
|
||||||
|
: any;
|
||||||
|
|
||||||
|
type DataEntryMap = {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||||
|
infer TData,
|
||||||
|
infer TEntryFilter,
|
||||||
|
infer TCollectionFilter,
|
||||||
|
infer TError
|
||||||
|
>
|
||||||
|
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||||
|
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||||
|
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||||
|
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||||
|
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||||
|
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||||
|
|
||||||
|
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||||
|
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||||
|
: import('astro/zod').infer<
|
||||||
|
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||||
|
>;
|
||||||
|
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||||
|
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||||
|
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||||
|
LiveContentConfig['collections'][C]['loader']
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ContentConfig = never;
|
||||||
|
export type LiveContentConfig = never;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"pid": 866059,
|
||||||
|
"port": 3000,
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"urls": {
|
||||||
|
"local": [
|
||||||
|
"http://localhost:3000/"
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
"http://192.168.1.65:3000/",
|
||||||
|
"http://100.114.19.66:3000/",
|
||||||
|
"http://172.27.0.1:3000/",
|
||||||
|
"http://172.26.0.1:3000/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"background": false,
|
||||||
|
"startedAt": "2026-07-01T15:35:45.072Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_variables": {
|
||||||
|
"lastUpdateCheck": 1782920146192
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import react from "@astrojs/react";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// BETE – Astro Configuration
|
||||||
|
// Tailwind v4 ditangani via PostCSS (postcss.config.js)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [react()],
|
||||||
|
|
||||||
|
output: "static",
|
||||||
|
|
||||||
|
// Dev server
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
preview: {
|
||||||
|
host: true,
|
||||||
|
port: 3000,
|
||||||
|
allowedHosts: [
|
||||||
|
"imphnen.asepharyana.my.id",
|
||||||
|
"imphnen.asepharyana.tech",
|
||||||
|
"imphnen.asepharyana.web.id",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vite config
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
allowedHosts: [
|
||||||
|
"imphnen.asepharyana.my.id",
|
||||||
|
"imphnen.asepharyana.tech",
|
||||||
|
"imphnen.asepharyana.web.id",
|
||||||
|
],
|
||||||
|
watch: {
|
||||||
|
// Penting: Astro punya public/ dir sendiri, jangan bentrok
|
||||||
|
ignored: ["!**/node_modules/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// PostCSS otomatis terdeteksi dari root project
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="id">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#23a1eb" />
|
|
||||||
<title>IMPHNEN — Discord Moderation</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -4,18 +4,20 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "astro dev --host 0.0.0.0",
|
||||||
"build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build",
|
"build": "astro build",
|
||||||
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
"preview": "astro preview --host 0.0.0.0 --port 3000",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check --diagnostic-level=error src/",
|
"lint": "biome check --diagnostic-level=error src/",
|
||||||
"format": "biome format --write src/"
|
"format": "biome format --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/react": "^6.0.0",
|
||||||
"@bete/shared": "workspace:*",
|
"@bete/shared": "workspace:*",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"astro": "^7.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.4.0",
|
"framer-motion": "^12.4.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
@@ -28,11 +30,9 @@
|
|||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"vite": "^8.0.13"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ─── App.client.tsx — Astro React island entry point ────────────────────────
|
||||||
|
// DILOAD OLEH Astro client:only="react"
|
||||||
|
// Menyediakan <div id="root"> dan mount App dengan provider yang diperlukan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import App from "./App";
|
||||||
|
import { ToastProvider } from "./shared/ui";
|
||||||
|
|
||||||
|
export default function AppClient() {
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
+324
-88
@@ -1,5 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
// ─── App.tsx — The God Component ──────────────────────────────────────────────
|
||||||
|
// TODO: Decompose into smaller focused components (M20).
|
||||||
|
// This component currently handles auth, socket lifecycle, speaker tracking,
|
||||||
|
// voice control, media, PTT, command palette, and tab navigation.
|
||||||
|
// Each concern should be extracted into its own hook or sub-component.
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
||||||
|
import { AuthOverlay } from "./features/auth";
|
||||||
import { DashboardPanel } from "./features/dashboard";
|
import { DashboardPanel } from "./features/dashboard";
|
||||||
import { LivePanel } from "./features/live";
|
import { LivePanel } from "./features/live";
|
||||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||||
@@ -10,26 +19,118 @@ import {
|
|||||||
mergeMessages,
|
mergeMessages,
|
||||||
useMessages,
|
useMessages,
|
||||||
} from "./features/messages/hooks/useMessages";
|
} from "./features/messages/hooks/useMessages";
|
||||||
import { getAppConfig } from "./shared/api/client";
|
import { SettingsPanel } from "./features/settings";
|
||||||
|
import { useNotificationBadge } from "./hooks/useNotificationBadge";
|
||||||
|
import { useTheme } from "./hooks/useTheme";
|
||||||
|
import {
|
||||||
|
getAppConfig,
|
||||||
|
getSessionToken,
|
||||||
|
getAdminPassword,
|
||||||
|
clearSessionToken,
|
||||||
|
clearAdminPassword,
|
||||||
|
} from "./shared/api/client";
|
||||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||||
import { useUIState } from "./shared/hooks/useUIState";
|
import { useUIState } from "./shared/hooks/useUIState";
|
||||||
|
import { CommandPalette } from "./shared/ui/CommandPalette";
|
||||||
|
import { ErrorBoundary } from "./shared/ui/error-boundary";
|
||||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||||
|
import type { DashboardTab } from "./entities/ui/types.js";
|
||||||
import { useDashboardSocket } from "./shared/ws/socket";
|
import { useDashboardSocket } from "./shared/ws/socket";
|
||||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||||
|
|
||||||
|
type AuthState = "loading" | "authenticated" | "unauthenticated";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { uiState, patchUIState } = useUIState();
|
const { uiState, patchUIState } = useUIState();
|
||||||
|
const { theme, mode, isDark, toggle: toggleTheme, setMode } = useTheme();
|
||||||
const voice = useVoiceControl();
|
const voice = useVoiceControl();
|
||||||
const media = useMediaControl();
|
const media = useMediaControl();
|
||||||
const messages = useMessages();
|
const messages = useMessages();
|
||||||
const [activeSpeakers, setActiveSpeakers] = useState<
|
const [activeSpeakers, setActiveSpeakers] = useState<
|
||||||
(ActiveSpeaker & { heardAt?: number })[]
|
(ActiveSpeaker & { heardAt?: number })[]>([]);
|
||||||
>([]);
|
|
||||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||||
|
|
||||||
|
// ── Command palette state ────────────────────────────────────────────────
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [paletteMode, setPaletteMode] = useState<"search" | "shortcuts" | null>(null);
|
||||||
|
|
||||||
|
// ── Auth state ─────────────────────────────────────────────────────────────
|
||||||
|
const [authState, setAuthState] = useState<AuthState>("loading");
|
||||||
|
const [dashboardIsPublic, setDashboardIsPublic] = useState(false);
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
|
const configRetryRef = useRef(0);
|
||||||
|
const configTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const MAX_CONFIG_RETRIES = 3;
|
||||||
|
|
||||||
|
// ── Notification badge ─────────────────────────────────────────────────────
|
||||||
|
const activeTab: DashboardTab = (uiState.activeTab as DashboardTab) || "messages";
|
||||||
|
const notifBadge = useNotificationBadge(activeTab);
|
||||||
|
|
||||||
|
// On mount: check config for public/private mode, and check stored session token
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear legacy admin-password from localStorage — only use token auth now
|
||||||
|
clearAdminPassword();
|
||||||
|
|
||||||
|
// Validate existing token by calling config endpoint
|
||||||
|
// If server returns 401, clear the invalid token
|
||||||
|
const attempt = () => {
|
||||||
|
getAppConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
configRetryRef.current = 0;
|
||||||
|
setConfigError(null);
|
||||||
|
setMonitorGuildId(cfg.monitorGuildId ?? "");
|
||||||
|
setDashboardIsPublic(cfg.dashboardIsPublic);
|
||||||
|
|
||||||
|
// Check if we have a session token (new auth) or legacy password (backward compat)
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
const storedPassword = getAdminPassword();
|
||||||
|
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
|
||||||
|
setAuthState("authenticated");
|
||||||
|
} else {
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// If server responds with 401, token is invalid — clear it
|
||||||
|
if (err?.statusCode === 401 || err?.status === 401) {
|
||||||
|
clearSessionToken();
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configRetryRef.current += 1;
|
||||||
|
const isNetwork =
|
||||||
|
err instanceof TypeError &&
|
||||||
|
(err.message === "Failed to fetch" ||
|
||||||
|
err.message.includes("NetworkError") ||
|
||||||
|
err.message.includes("network"));
|
||||||
|
|
||||||
|
if (isNetwork && configRetryRef.current < MAX_CONFIG_RETRIES) {
|
||||||
|
// Retry with backoff: 1s, 2s, 3s
|
||||||
|
const delay = configRetryRef.current * 1000;
|
||||||
|
configTimeoutRef.current = setTimeout(attempt, delay);
|
||||||
|
} else {
|
||||||
|
// Final failure — show auth overlay with retry button
|
||||||
|
setConfigError(
|
||||||
|
isNetwork
|
||||||
|
? "Cannot reach server. Check your connection and try again."
|
||||||
|
: "Failed to load configuration.",
|
||||||
|
);
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
attempt();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (configTimeoutRef.current) {
|
||||||
|
clearTimeout(configTimeoutRef.current);
|
||||||
|
configTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const audio = useAudioPlayback();
|
const audio = useAudioPlayback();
|
||||||
const activeTab = uiState.activeTab || "messages";
|
|
||||||
const selectedVoiceGuild =
|
const selectedVoiceGuild =
|
||||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||||
|
|
||||||
@@ -77,7 +178,7 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
onVoiceActiveUser: (data) => {
|
onVoiceActiveUser: (data) => {
|
||||||
if (data.userId) audio.registerUserId(data.userId);
|
if (data.userId) audio.registerUserId(data.userId);
|
||||||
setActiveSpeakers((prev) =>
|
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) =>
|
||||||
updateSpeakerList(prev, {
|
updateSpeakerList(prev, {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -86,12 +187,25 @@ export default function App() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onVoiceRecordingStarted: () =>
|
onVoiceRecordingStarted: (data) =>
|
||||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
window.dispatchEvent(
|
||||||
onVoiceRecordingStopped: () =>
|
new CustomEvent("voice_recording_started", { detail: data }),
|
||||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
),
|
||||||
|
onVoiceRecordingStopped: (data) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_recording_stopped", { detail: data }),
|
||||||
|
),
|
||||||
|
onVoiceAnalyzed: (data) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_analyzed", { detail: data }),
|
||||||
|
),
|
||||||
onMessageCreated: (m) =>
|
onMessageCreated: (m) =>
|
||||||
messages.setMessages((prev) => mergeMessages(prev, [m])),
|
messages.setMessages((prev) => {
|
||||||
|
// Skip if message already exists with same status (dedup)
|
||||||
|
const existing = prev.find((i) => i.id === m.id);
|
||||||
|
if (existing && existing.ai_status === m.ai_status) return prev;
|
||||||
|
return mergeMessages(prev, [m]);
|
||||||
|
}),
|
||||||
onMessageUpdated: (m) =>
|
onMessageUpdated: (m) =>
|
||||||
messages.setMessages((prev) =>
|
messages.setMessages((prev) =>
|
||||||
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
||||||
@@ -103,7 +217,12 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onMessageAnalyzed: (msg) => {
|
onMessageAnalyzed: (msg) => {
|
||||||
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
messages.setMessages((prev) => {
|
||||||
|
// Skip if message already analyzed with same status (dedup)
|
||||||
|
const existing = prev.find((i) => i.id === msg.id);
|
||||||
|
if (existing && existing.ai_status === msg.ai_status) return prev;
|
||||||
|
return mergeMessages(prev, [msg]);
|
||||||
|
});
|
||||||
const status = msg.ai_status;
|
const status = msg.ai_status;
|
||||||
if (status === "flagged") {
|
if (status === "flagged") {
|
||||||
const username = msg.username || msg.user_id || "unknown";
|
const username = msg.username || msg.user_id || "unknown";
|
||||||
@@ -134,17 +253,6 @@ export default function App() {
|
|||||||
|
|
||||||
const transmit = useAudioTransmit(socket.socketRef);
|
const transmit = useAudioTransmit(socket.socketRef);
|
||||||
|
|
||||||
// Load app config on mount
|
|
||||||
useEffect(() => {
|
|
||||||
getAppConfig()
|
|
||||||
.then((c) => {
|
|
||||||
if (c.monitorGuildId) {
|
|
||||||
setMonitorGuildId(c.monitorGuildId);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load voice channels when guild changes (Live tab)
|
// Load voice channels when guild changes (Live tab)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedVoiceGuild)
|
if (selectedVoiceGuild)
|
||||||
@@ -158,18 +266,24 @@ export default function App() {
|
|||||||
}, [monitorGuildId, messages.fetchMessages]);
|
}, [monitorGuildId, messages.fetchMessages]);
|
||||||
|
|
||||||
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||||
|
const monitorGuildRef = useRef(monitorGuildId);
|
||||||
|
monitorGuildRef.current = monitorGuildId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!monitorGuildId) return;
|
const currentGuild = monitorGuildRef.current;
|
||||||
|
if (!currentGuild) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
messages.fetchMessages(monitorGuildRef.current).catch(() => undefined);
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
return () => clearInterval(interval);
|
return () => {
|
||||||
}, [monitorGuildId, messages.fetchMessages]);
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [monitorGuildId]);
|
||||||
|
|
||||||
// Stale speaker pruning — remove speakers not heard from in 30s
|
// Stale speaker pruning — remove speakers not heard from in 30s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setActiveSpeakers((prev) => {
|
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const pruned = prev.filter(
|
const pruned = prev.filter(
|
||||||
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
||||||
@@ -207,70 +321,192 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [transmit]);
|
}, [transmit]);
|
||||||
|
|
||||||
|
// ── Command palette keyboard shortcut handler ──────────────────────────────
|
||||||
|
const handlePaletteOpen = useCallback((mode: "search" | "shortcuts") => {
|
||||||
|
setPaletteMode(mode);
|
||||||
|
setPaletteOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaletteClose = useCallback(() => {
|
||||||
|
setPaletteOpen(false);
|
||||||
|
setPaletteMode(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaletteNavigate = useCallback(
|
||||||
|
(tab: string) => {
|
||||||
|
patchUIState({ activeTab: tab as DashboardTab });
|
||||||
|
handlePaletteClose();
|
||||||
|
},
|
||||||
|
[patchUIState, handlePaletteClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tab navigation handler ─────────────────────────────────────────────────
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(tab: DashboardTab) => {
|
||||||
|
patchUIState({ activeTab: tab });
|
||||||
|
},
|
||||||
|
[patchUIState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render main content based on active tab ────────────────────────────────
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case "live":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Live panel crashed">
|
||||||
|
<LivePanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
voiceChannels={voice.voiceChannels}
|
||||||
|
selectedGuild={selectedVoiceGuild}
|
||||||
|
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||||
|
micLevel={0}
|
||||||
|
status={voice.voiceStatus}
|
||||||
|
voiceLoading={voice.loading}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
levels={audio.levels}
|
||||||
|
isListening={audio.isListening}
|
||||||
|
isStreaming={transmit.isStreaming}
|
||||||
|
mediaState={media.mediaState}
|
||||||
|
mediaLoading={media.loading}
|
||||||
|
onGuildChange={(id) =>
|
||||||
|
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
||||||
|
}
|
||||||
|
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||||
|
onJoin={() =>
|
||||||
|
voice.joinVoice(
|
||||||
|
selectedVoiceGuild,
|
||||||
|
uiState.selectedVoiceChannel || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDisconnect={() => voice.leaveVoice()}
|
||||||
|
onListenToggle={audio.toggleListening}
|
||||||
|
onStreamingToggle={transmit.toggle}
|
||||||
|
onQueueMusic={(s) => media.enqueue(s, "music")}
|
||||||
|
onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||||
|
onSkip={media.skip}
|
||||||
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
case "dashboard":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Dashboard panel crashed">
|
||||||
|
<DashboardPanel />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
case "settings":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Settings panel crashed">
|
||||||
|
<SettingsPanel
|
||||||
|
themeMode={mode}
|
||||||
|
isDark={isDark}
|
||||||
|
onThemeModeChange={setMode}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Messages panel crashed">
|
||||||
|
<MessagesPanel
|
||||||
|
guildName={monitorGuildName}
|
||||||
|
messages={messages.messages}
|
||||||
|
onReanalyze={messages.reanalyze}
|
||||||
|
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
||||||
|
onLoadMore={messages.loadMore}
|
||||||
|
hasMore={messages.hasMore}
|
||||||
|
loadingMore={messages.loadingMore}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render: Auth loading ─────────────────────────────────────────────────
|
||||||
|
if (authState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{configError
|
||||||
|
? "Connection lost — retrying..."
|
||||||
|
: `Connecting to server${".".repeat(configRetryRef.current)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Auth overlay ─────────────────────────────────────────────────
|
||||||
|
if (authState === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<AuthOverlay
|
||||||
|
isPublic={dashboardIsPublic}
|
||||||
|
onAuthenticated={() => setAuthState("authenticated")}
|
||||||
|
configError={configError}
|
||||||
|
onRetryConfig={() => {
|
||||||
|
setConfigError(null);
|
||||||
|
setAuthState("loading");
|
||||||
|
configRetryRef.current = 0;
|
||||||
|
// Re-trigger the config fetch by forcing remount via key trick
|
||||||
|
// Actually: just re-run attempt logic
|
||||||
|
getAppConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
setMonitorGuildId(cfg.monitorGuildId ?? "");
|
||||||
|
setDashboardIsPublic(cfg.dashboardIsPublic);
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
const storedPassword = getAdminPassword();
|
||||||
|
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
|
||||||
|
setAuthState("authenticated");
|
||||||
|
} else {
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConfigError("Server still unreachable. Try again later.");
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Main app (authenticated) ─────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<>
|
||||||
activeTab={activeTab}
|
<DashboardLayout
|
||||||
wsStatus={socket.status}
|
activeTab={activeTab}
|
||||||
voiceStatus={voice.voiceStatus}
|
wsStatus={socket.status}
|
||||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
voiceStatus={voice.voiceStatus}
|
||||||
recentMessages={messages.messages}
|
themeMode={mode}
|
||||||
guildId={monitorGuildId}
|
isDark={isDark}
|
||||||
channelId={
|
onTabChange={handleTabChange}
|
||||||
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
onThemeToggle={toggleTheme}
|
||||||
}
|
recentMessages={messages.messages}
|
||||||
>
|
guildId={monitorGuildId}
|
||||||
{activeTab === "live" ? (
|
channelId={
|
||||||
<LivePanel
|
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||||
guilds={voice.guilds}
|
}
|
||||||
voiceChannels={voice.voiceChannels}
|
notificationCount={notifBadge.count}
|
||||||
selectedGuild={selectedVoiceGuild}
|
>
|
||||||
selectedChannel={uiState.selectedVoiceChannel || ""}
|
<AnimatePresence mode="wait">
|
||||||
micLevel={0}
|
{renderContent()}
|
||||||
status={voice.voiceStatus}
|
</AnimatePresence>
|
||||||
voiceLoading={voice.loading}
|
</DashboardLayout>
|
||||||
activeSpeakers={activeSpeakers}
|
|
||||||
levels={audio.levels}
|
|
||||||
isListening={audio.isListening}
|
|
||||||
isStreaming={transmit.isStreaming}
|
|
||||||
mediaState={media.mediaState}
|
|
||||||
mediaLoading={media.loading}
|
|
||||||
onGuildChange={(id) =>
|
|
||||||
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
|
||||||
}
|
|
||||||
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
|
||||||
onJoin={() =>
|
|
||||||
voice.joinVoice(
|
|
||||||
selectedVoiceGuild,
|
|
||||||
uiState.selectedVoiceChannel || "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDisconnect={() => voice.leaveVoice()}
|
|
||||||
onListenToggle={audio.toggleListening}
|
|
||||||
onStreamingToggle={transmit.toggle}
|
|
||||||
onQueueMusic={(s) => media.enqueue(s, "music")}
|
|
||||||
onStartScreen={(s) => media.enqueue(s, "screen")}
|
|
||||||
onSkip={media.skip}
|
|
||||||
onStop={media.stop}
|
|
||||||
onVolumeChange={media.setVolume}
|
|
||||||
/>
|
|
||||||
) : activeTab === "dashboard" ? (
|
|
||||||
<DashboardPanel />
|
|
||||||
) : (
|
|
||||||
<MessagesPanel
|
|
||||||
guildName={monitorGuildName}
|
|
||||||
messages={messages.messages}
|
|
||||||
onReanalyze={messages.reanalyze}
|
|
||||||
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
|
||||||
onLoadMore={messages.loadMore}
|
|
||||||
hasMore={messages.hasMore}
|
|
||||||
loadingMore={messages.loadingMore}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MobileTabBar
|
<MobileTabBar
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
onTabChange={handleTabChange}
|
||||||
/>
|
/>
|
||||||
<ModerationAlertListener />
|
<ModerationAlertListener />
|
||||||
</DashboardLayout>
|
<CommandPalette
|
||||||
|
isOpen={paletteOpen}
|
||||||
|
mode={paletteMode}
|
||||||
|
onClose={handlePaletteClose}
|
||||||
|
onNavigate={handlePaletteNavigate}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ export interface UIState {
|
|||||||
selectedTextChannel?: string;
|
selectedTextChannel?: string;
|
||||||
selectedAnalyticsGuild?: string;
|
selectedAnalyticsGuild?: string;
|
||||||
selectedAnalyticsChannel?: string;
|
selectedAnalyticsChannel?: string;
|
||||||
activeTab?: "live" | "messages" | "dashboard";
|
activeTab?: DashboardTab;
|
||||||
isListening?: boolean;
|
isListening?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardTab = "live" | "messages" | "dashboard";
|
export type DashboardTab = "live" | "messages" | "dashboard" | "settings";
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
monitorGuildId: string | null;
|
monitorGuildId: string | null;
|
||||||
|
dashboardIsPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from GET /api/admin/settings */
|
||||||
|
export interface AdminSettings {
|
||||||
|
dashboardIsPublic: boolean;
|
||||||
|
envDashboardIsPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AdminSettings } from "../../shared/api/client";
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
updateAdminSettings,
|
||||||
|
clearSessionToken,
|
||||||
|
logout,
|
||||||
|
} from "../../shared/api/client";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../shared/ui";
|
||||||
|
|
||||||
|
export function AdminPanel() {
|
||||||
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
// Call server-side logout to increment token version
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch {
|
||||||
|
// Even if server call fails, still clear local state for security
|
||||||
|
}
|
||||||
|
// Clear local token and legacy password
|
||||||
|
clearSessionToken();
|
||||||
|
localStorage.removeItem("admin-password");
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getAdminSettings();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load settings");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePublic = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
const newValue = !settings.dashboardIsPublic;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminSettings({
|
||||||
|
dashboardIsPublic: newValue,
|
||||||
|
});
|
||||||
|
setSettings(updated);
|
||||||
|
setSuccess(
|
||||||
|
newValue
|
||||||
|
? "Dashboard is now public — accessible without password."
|
||||||
|
: "Dashboard is now private — admin password required.",
|
||||||
|
);
|
||||||
|
setTimeout(() => setSuccess(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||||
|
<CardDescription>Loading settings...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !settings) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||||
|
<CardDescription className="text-destructive">{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={fetchSettings} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = settings?.dashboardIsPublic ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={cardStagger} initial="initial" animate="animate">
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card className="border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Admin Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage dashboard visibility and runtime configuration.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={fetchSettings}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* ── Success / Error messages ── */}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Dashboard Visibility ── */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPublic ? (
|
||||||
|
<Globe className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-4 w-4 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
Dashboard Visibility:{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isPublic ? "text-emerald-500" : "text-amber-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isPublic
|
||||||
|
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
|
||||||
|
: "Admin password is required to access any part of the dashboard."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleTogglePublic}
|
||||||
|
disabled={saving}
|
||||||
|
variant={isPublic ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : isPublic ? (
|
||||||
|
<>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Make Private
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Make Public
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Status indicators ── */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Runtime</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isPublic ? "bg-emerald-400" : "bg-amber-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Env Default
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
settings?.envDashboardIsPublic
|
||||||
|
? "bg-emerald-400"
|
||||||
|
: "bg-amber-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{settings?.envDashboardIsPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Logout ── */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Info card ── */}
|
||||||
|
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<strong>Admin password</strong> is configured via the
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
ADMIN_PASSWORD
|
||||||
|
</code>
|
||||||
|
environment variable. For security, it cannot be changed
|
||||||
|
through this panel — update it in your deployment
|
||||||
|
configuration and restart the service.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Runtime settings are persisted across restarts in the
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
data/settings.json
|
||||||
|
</code>
|
||||||
|
file. Changes take effect immediately, no restart needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Lock } from "lucide-react";
|
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { login } from "../../shared/api/client.js";
|
import { login, setSessionToken } from "../../shared/api/client.js";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,67 +14,148 @@ import {
|
|||||||
|
|
||||||
interface AuthOverlayProps {
|
interface AuthOverlayProps {
|
||||||
onAuthenticated: () => void;
|
onAuthenticated: () => void;
|
||||||
|
isPublic: boolean;
|
||||||
|
configError?: string | null;
|
||||||
|
onRetryConfig?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
export function AuthOverlay({
|
||||||
|
onAuthenticated,
|
||||||
|
isPublic,
|
||||||
|
configError,
|
||||||
|
onRetryConfig,
|
||||||
|
}: AuthOverlayProps) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsNetworkError(false);
|
||||||
try {
|
try {
|
||||||
await login(password);
|
const result = await login(password);
|
||||||
localStorage.setItem("admin-password", password);
|
// Store session token (new auth method)
|
||||||
|
if (result.token) {
|
||||||
|
setSessionToken(result.token);
|
||||||
|
}
|
||||||
|
// Clean up legacy stored password from localStorage if it was there
|
||||||
|
// from a previous session (before JWT migration)
|
||||||
|
localStorage.removeItem("admin-password");
|
||||||
onAuthenticated();
|
onAuthenticated();
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("Invalid password");
|
const isNetwork =
|
||||||
|
err instanceof TypeError &&
|
||||||
|
(err.message === "Failed to fetch" ||
|
||||||
|
err.message.includes("NetworkError") ||
|
||||||
|
err.message.includes("network"));
|
||||||
|
setIsNetworkError(isNetwork);
|
||||||
|
setError(
|
||||||
|
isNetwork
|
||||||
|
? "Cannot reach server — check your connection or try again."
|
||||||
|
: "Invalid password",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Retry config fetch (initial loading state) ──────────────────────────────
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setRetryCount((r) => r + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
className="flex items-center justify-center p-4"
|
className="flex min-h-screen items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex items-center justify-center">
|
<div className="mx-auto mb-4 flex items-center justify-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<Lock className="h-6 w-6" />
|
{isPublic ? (
|
||||||
|
<Shield className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-6 w-6" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Admin Access Required</CardTitle>
|
<CardTitle>
|
||||||
|
{isPublic ? "Admin Authentication" : "Admin Access Required"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter the admin password to access Voice and Media controls.
|
{isPublic
|
||||||
|
? "Enter the admin password to manage settings and perform administrative actions."
|
||||||
|
: "Enter the admin password to access the dashboard."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{configError && (
|
||||||
|
<div className="mb-4 flex flex-col items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-center">
|
||||||
|
<WifiOff className="h-6 w-6 text-amber-500" />
|
||||||
|
<p className="text-xs text-amber-600">{configError}</p>
|
||||||
|
{onRetryConfig && (
|
||||||
|
<Button
|
||||||
|
onClick={onRetryConfig}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 border-amber-500/30 text-amber-600 hover:bg-amber-500/10"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Retry Connection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter password"
|
placeholder="Enter admin password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-lg p-2 text-xs ${
|
||||||
|
isNetworkError
|
||||||
|
? "bg-amber-500/10 text-amber-600"
|
||||||
|
: "text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isNetworkError ? (
|
||||||
|
<WifiOff className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading || !password}
|
disabled={loading || !password}
|
||||||
>
|
>
|
||||||
{loading ? "Authenticating..." : "Unlock Controls"}
|
{loading ? "Authenticating..." : "Unlock"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{isPublic && (
|
||||||
|
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||||
|
<Unlock className="inline h-3 w-3 mr-1" />
|
||||||
|
The dashboard is in public mode — most data is visible without
|
||||||
|
authentication. Admin password is only needed for management actions.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Settings } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AdminPanel } from "../../features/admin/AdminPanel";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||||
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
||||||
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
||||||
@@ -87,6 +89,10 @@ export function DashboardPanel() {
|
|||||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||||
<TabsTrigger value="users">Users</TabsTrigger>
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
<TabsTrigger value="channels">Channels</TabsTrigger>
|
<TabsTrigger value="channels">Channels</TabsTrigger>
|
||||||
|
<TabsTrigger value="admin" className="flex items-center gap-1.5">
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Admin
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="stats">
|
<TabsContent value="stats">
|
||||||
@@ -120,6 +126,10 @@ export function DashboardPanel() {
|
|||||||
onSelectChannel={setSelectedChannelId}
|
onSelectChannel={setSelectedChannelId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="admin">
|
||||||
|
<AdminPanel />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recordings.map((rec) => (
|
{recordings.map((rec) => (
|
||||||
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
|
<div key={rec.id} className="rounded-xl border border-border bg-card">
|
||||||
<div className="flex items-center gap-4 p-4">
|
<div className="flex items-center gap-4 p-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
<Mic className="h-5 w-5" />
|
<Mic className="h-5 w-5" />
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
|||||||
href={image.url}
|
href={image.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
className="group overflow-hidden rounded-xl border border-primary/20 bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video overflow-hidden">
|
<div className="relative aspect-video overflow-hidden">
|
||||||
{image.kind === "sticker" ? (
|
{image.kind === "sticker" ? (
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
title={`:${name}:`}
|
title={`:${name}:`}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
lastIndex = regex.lastIndex;
|
lastIndex = regex.lastIndex;
|
||||||
@@ -80,13 +84,13 @@ function parseStringList(value?: string | null): string[] {
|
|||||||
function severityColor(severity: string) {
|
function severityColor(severity: string) {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "critical":
|
case "critical":
|
||||||
return "bg-red-100 text-red-700 border-red-200";
|
return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800";
|
||||||
case "high":
|
case "high":
|
||||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
return "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800";
|
||||||
case "medium":
|
case "medium":
|
||||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
|
||||||
case "low":
|
case "low":
|
||||||
return "bg-blue-100 text-blue-700 border-blue-200";
|
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800";
|
||||||
default:
|
default:
|
||||||
return "bg-muted text-muted-foreground border-border";
|
return "bg-muted text-muted-foreground border-border";
|
||||||
}
|
}
|
||||||
@@ -333,6 +337,10 @@ function MessageRow({
|
|||||||
alt={sticker.name || "sticker"}
|
alt={sticker.name || "sticker"}
|
||||||
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
|
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
||||||
@@ -360,6 +368,10 @@ function MessageRow({
|
|||||||
alt={img.name}
|
alt={img.name}
|
||||||
className="h-16 w-16 object-cover transition-transform hover:scale-105"
|
className="h-16 w-16 object-cover transition-transform hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -380,7 +392,7 @@ function MessageRow({
|
|||||||
key={vid.url}
|
key={vid.url}
|
||||||
src={vid.url}
|
src={vid.url}
|
||||||
controls
|
controls
|
||||||
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
|
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -409,8 +421,8 @@ function MessageRow({
|
|||||||
<div
|
<div
|
||||||
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
||||||
aiStatus === "flagged"
|
aiStatus === "flagged"
|
||||||
? "border-l-pink-400 bg-pink-50/40"
|
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
|
||||||
: "border-l-emerald-400 bg-emerald-50/40"
|
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 text-[11px]">
|
<div className="flex items-start gap-2 text-[11px]">
|
||||||
@@ -431,7 +443,7 @@ function MessageRow({
|
|||||||
|
|
||||||
{/* AI Error */}
|
{/* AI Error */}
|
||||||
{message.ai_error ? (
|
{message.ai_error ? (
|
||||||
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
|
<div className="rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
|
||||||
AI error: {message.ai_error}
|
AI error: {message.ai_error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -451,7 +463,7 @@ function MessageRow({
|
|||||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||||
</Button>
|
</Button>
|
||||||
{aiStatus === "error" && (
|
{aiStatus === "error" && (
|
||||||
<span className="text-[11px] text-pink-600/70">
|
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
|
||||||
Click to retry analysis
|
Click to retry analysis
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -483,7 +495,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
|||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||||
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
|
firstMsg.deleted_at ? "border-red-200 dark:border-red-900/50 opacity-60" : "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 p-4">
|
<div className="flex gap-3 p-4">
|
||||||
@@ -495,6 +507,10 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
|||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.src = "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Sun,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ThemeMode } from "../../hooks/useTheme";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
updateAdminSettings,
|
||||||
|
clearSessionToken,
|
||||||
|
} from "../../shared/api/client";
|
||||||
|
import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
|
||||||
|
|
||||||
|
/* ─── Storage keys ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NOTIF_ENABLED_KEY = "bete-notif-enabled";
|
||||||
|
const NOTIF_SOUND_KEY = "bete-notif-sound";
|
||||||
|
|
||||||
|
/* ─── Types ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface NotificationPrefs {
|
||||||
|
enabled: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifPrefs(): NotificationPrefs {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
|
||||||
|
const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
|
||||||
|
return {
|
||||||
|
enabled: raw !== "false", // default true
|
||||||
|
sound: soundRaw !== "false", // default true
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { enabled: true, sound: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Props ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
isDark: boolean;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Component ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
themeMode,
|
||||||
|
isDark,
|
||||||
|
onThemeModeChange,
|
||||||
|
}: SettingsPanelProps) {
|
||||||
|
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs>(loadNotifPrefs);
|
||||||
|
const [adminSettings, setAdminSettings] = useState<AdminSettingsType | null>(null);
|
||||||
|
const [adminSaving, setAdminSaving] = useState(false);
|
||||||
|
const [adminError, setAdminError] = useState<string | null>(null);
|
||||||
|
const [adminSuccess, setAdminSuccess] = useState<string | null>(null);
|
||||||
|
const adminSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Load admin settings on mount
|
||||||
|
useEffect(() => {
|
||||||
|
getAdminSettings()
|
||||||
|
.then(setAdminSettings)
|
||||||
|
.catch(() => {
|
||||||
|
// Not authenticated — ignore
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePublic = async () => {
|
||||||
|
if (!adminSettings) return;
|
||||||
|
const newValue = !adminSettings.dashboardIsPublic;
|
||||||
|
setAdminSaving(true);
|
||||||
|
setAdminError(null);
|
||||||
|
setAdminSuccess(null);
|
||||||
|
// Clear any existing auto-clear timer
|
||||||
|
if (adminSuccessTimerRef.current) {
|
||||||
|
clearTimeout(adminSuccessTimerRef.current);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminSettings({ dashboardIsPublic: newValue });
|
||||||
|
setAdminSettings(updated);
|
||||||
|
setAdminSuccess(
|
||||||
|
newValue
|
||||||
|
? "Dashboard is now public — accessible without password."
|
||||||
|
: "Dashboard is now private — admin password required.",
|
||||||
|
);
|
||||||
|
// Auto-clear success message after 4s
|
||||||
|
adminSuccessTimerRef.current = setTimeout(() => setAdminSuccess(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setAdminError(err instanceof Error ? err.message : "Failed to update");
|
||||||
|
} finally {
|
||||||
|
setAdminSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearSessionToken();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNotif = useCallback(
|
||||||
|
(patch: Partial<NotificationPrefs>) => {
|
||||||
|
setNotifPrefs((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NOTIF_ENABLED_KEY, String(next.enabled));
|
||||||
|
localStorage.setItem(NOTIF_SOUND_KEY, String(next.sound));
|
||||||
|
} catch {
|
||||||
|
/* quota */
|
||||||
|
}
|
||||||
|
// Dispatch event so other components can react
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("notif_prefs_changed", { detail: next }),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeOptions: Array<{
|
||||||
|
value: ThemeMode;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
desc: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Light",
|
||||||
|
icon: Sun,
|
||||||
|
desc: "Always use light theme",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Dark",
|
||||||
|
icon: Moon,
|
||||||
|
desc: "Always use dark theme",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "system",
|
||||||
|
label: "System",
|
||||||
|
icon: Monitor,
|
||||||
|
desc: "Follow system preference",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="mx-auto max-w-2xl space-y-6"
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{/* ── Theme section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
Theme
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{themeOptions.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const isActive = themeMode === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => onThemeModeChange(opt.value)}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`h-6 w-6 ${
|
||||||
|
opt.value === "dark" && !isActive
|
||||||
|
? "text-indigo-400"
|
||||||
|
: opt.value === "light" && !isActive
|
||||||
|
? "text-amber-500"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold">{opt.label}</span>
|
||||||
|
<span className="text-xs">{opt.desc}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Current: <span className="font-medium text-foreground capitalize">{isDark ? "Dark" : "Light"}</span>
|
||||||
|
{themeMode === "system" && " (follows system)"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Notifications section ────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Toggle — enable/disable all notifs */}
|
||||||
|
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{notifPrefs.enabled ? (
|
||||||
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Moderation alerts
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show toast when a message is flagged by AI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notifPrefs.enabled}
|
||||||
|
onClick={() => updateNotif({ enabled: !notifPrefs.enabled })}
|
||||||
|
className={`
|
||||||
|
relative h-6 w-11 rounded-full transition-colors
|
||||||
|
${notifPrefs.enabled ? "bg-primary" : "bg-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
|
||||||
|
${notifPrefs.enabled ? "translate-x-5" : "translate-x-0"}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Toggle — sound */}
|
||||||
|
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{notifPrefs.sound ? (
|
||||||
|
<Volume2 className="h-5 w-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<VolumeX className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Sound effects
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Play a sound when new moderation alerts arrive
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notifPrefs.sound}
|
||||||
|
onClick={() => updateNotif({ sound: !notifPrefs.sound })}
|
||||||
|
className={`
|
||||||
|
relative h-6 w-11 rounded-full transition-colors
|
||||||
|
${notifPrefs.sound ? "bg-primary" : "bg-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
|
||||||
|
${notifPrefs.sound ? "translate-x-5" : "translate-x-0"}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Admin section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card className="border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Admin Settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Dashboard visibility toggle */}
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{adminSettings?.dashboardIsPublic ? (
|
||||||
|
<Globe className="mt-0.5 h-5 w-5 text-emerald-500 dark:text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Lock className="mt-0.5 h-5 w-5 text-amber-500 dark:text-amber-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Dashboard Visibility:{" "}
|
||||||
|
<span className={adminSettings?.dashboardIsPublic ? "text-emerald-500 dark:text-emerald-400" : "text-amber-500 dark:text-amber-400"}>
|
||||||
|
{adminSettings?.dashboardIsPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{adminSettings?.dashboardIsPublic
|
||||||
|
? "Anyone can view the dashboard. Admin password still required for management."
|
||||||
|
: "Admin password required to access the dashboard."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleTogglePublic}
|
||||||
|
disabled={adminSaving}
|
||||||
|
variant={adminSettings?.dashboardIsPublic ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{adminSaving ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : adminSettings?.dashboardIsPublic ? (
|
||||||
|
"Make Private"
|
||||||
|
) : (
|
||||||
|
"Make Public"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{adminError && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">{adminError}</p>
|
||||||
|
)}
|
||||||
|
{adminSuccess && (
|
||||||
|
<p className="mt-2 text-xs text-emerald-500 dark:text-emerald-400">{adminSuccess}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Runtime</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.dashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
|
||||||
|
<span className="text-sm font-medium">{adminSettings?.dashboardIsPublic ? "Public" : "Private"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Env Default</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.envDashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
|
||||||
|
<span className="text-sm font-medium">{adminSettings?.envDashboardIsPublic ? "Public" : "Private"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="flex justify-end border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="rounded-lg bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Admin password is set via the <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">ADMIN_PASSWORD</code> env var.
|
||||||
|
Runtime settings are persisted in <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">data/settings.json</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── About section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-muted-foreground">About</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bete Dashboard v1.0 — Discord AI Moderation & Voice Recording
|
||||||
|
System.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Theme settings are saved locally. Notification preferences are
|
||||||
|
persisted across sessions.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks incoming moderation_alert events and maintains a badge counter.
|
||||||
|
* Clears when the user navigates to the messages tab.
|
||||||
|
*/
|
||||||
|
export function useNotificationBadge(activeTab: string) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const prevActiveTab = useRef(activeTab);
|
||||||
|
|
||||||
|
// Clear badge when user switches TO messages tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "messages" && prevActiveTab.current !== "messages") {
|
||||||
|
setCount(0);
|
||||||
|
}
|
||||||
|
prevActiveTab.current = activeTab;
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const increment = useCallback(() => {
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for moderation_alert custom events
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => increment();
|
||||||
|
window.addEventListener("moderation_alert", handler);
|
||||||
|
return () => window.removeEventListener("moderation_alert", handler);
|
||||||
|
}, [increment]);
|
||||||
|
|
||||||
|
return { count, clear: () => setCount(0) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark";
|
||||||
|
export type ThemeMode = Theme | "system";
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "bete-dashboard-theme";
|
||||||
|
|
||||||
|
function getSystemTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThemeMode(): ThemeMode {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark" || stored === "system")
|
||||||
|
return stored;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(mode: ThemeMode): Theme {
|
||||||
|
return mode === "system" ? getSystemTheme() : mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute("data-theme", theme);
|
||||||
|
// Also toggle Tailwind dark class for utility-based approach
|
||||||
|
if (theme === "dark") {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(loadThemeMode);
|
||||||
|
|
||||||
|
const theme = useMemo(() => resolveTheme(mode), [mode]);
|
||||||
|
|
||||||
|
const setMode = useCallback((newMode: ThemeMode) => {
|
||||||
|
setModeState(newMode);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, newMode);
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setMode(theme === "dark" ? "light" : "dark");
|
||||||
|
}, [theme, setMode]);
|
||||||
|
|
||||||
|
// Apply theme on mount and when mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Listen for system preference changes when in "system" mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "system") return;
|
||||||
|
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handler = () => {
|
||||||
|
applyTheme(resolveTheme("system"));
|
||||||
|
};
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
return { theme, mode, setMode, toggle, isDark: theme === "dark" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
// ─── BaseLayout.astro — BETE's eternal shell ─────────────────────────────────
|
||||||
|
// Handles: anti-FOUC theme, font loading, global CSS, meta tags.
|
||||||
|
// All interactive content is delegated to React islands via <slot/>.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Font dari design system: Outfit (menggantikan Poppins)
|
||||||
|
const FONT_HREF =
|
||||||
|
"https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap";
|
||||||
|
|
||||||
|
const FALLBACK_TITLE = "IMPHNEN — Discord Moderation";
|
||||||
|
const FALLBACK_DESC =
|
||||||
|
"Real-time Discord AI Moderation & Voice Recording Dashboard";
|
||||||
|
const FALLBACK_IMAGE =
|
||||||
|
"https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" id="meta-theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>{FALLBACK_TITLE}</title>
|
||||||
|
<meta name="title" content={FALLBACK_TITLE} />
|
||||||
|
<meta name="description" content={FALLBACK_DESC} />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={FALLBACK_TITLE} />
|
||||||
|
<meta property="og:description" content={FALLBACK_DESC} />
|
||||||
|
<meta property="og:image" content={FALLBACK_IMAGE} />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:title" content={FALLBACK_TITLE} />
|
||||||
|
<meta property="twitter:description" content={FALLBACK_DESC} />
|
||||||
|
<meta property="twitter:image" content={FALLBACK_IMAGE} />
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href={FALLBACK_IMAGE} />
|
||||||
|
|
||||||
|
<!-- Font Preconnect -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href={FONT_HREF} rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- ── Anti-FOUC: apply saved theme BEFORE React renders ────────────── -->
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("bete-dashboard-theme");
|
||||||
|
var theme = "light";
|
||||||
|
if (stored === "dark") {
|
||||||
|
theme = "dark";
|
||||||
|
} else if (stored === "system") {
|
||||||
|
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.setAttribute("data-theme", theme);
|
||||||
|
if (theme === "dark") root.classList.add("dark");
|
||||||
|
// Sync meta theme-color
|
||||||
|
var meta = document.getElementById("meta-theme-color");
|
||||||
|
if (meta) {
|
||||||
|
meta.setAttribute(
|
||||||
|
"content",
|
||||||
|
theme === "dark" ? "#0f0f12" : "#ffffff",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage unavailable — safe to ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- React island: seluruh app di-render oleh React -->
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
import { ToastProvider } from "./shared/ui";
|
|
||||||
import "./styles.css";
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
throw new Error("Root element not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(root).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ToastProvider>
|
|
||||||
<App />
|
|
||||||
</ToastProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
// ─── index.astro — BETE's main entry point ──────────────────────────────────
|
||||||
|
// Shell statis: semua interaktivitas di-delegate ke React island
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
import AppClient from "../App.client";
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
<!-- React island: seluruh SPA di-render oleh React -->
|
||||||
|
<AppClient client:only="react" />
|
||||||
|
</BaseLayout>
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
VoiceRecordingListResponse,
|
VoiceRecordingListResponse,
|
||||||
} from "../../entities/recording/types.js";
|
} from "../../entities/recording/types.js";
|
||||||
import type {
|
import type {
|
||||||
|
AdminSettings,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DashboardTab,
|
DashboardTab,
|
||||||
UIState,
|
UIState,
|
||||||
@@ -49,14 +50,53 @@ class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache admin password in memory — read from localStorage once on first call
|
// Cache admin session token in memory — read from sessionStorage once on first call
|
||||||
let _cachedPassword: string | null = null;
|
// NOTE: _cachedToken is intentionally removed; we read directly from sessionStorage
|
||||||
|
// to support multi-tab sync (L8 fix).
|
||||||
|
|
||||||
function getAdminPassword(): string | null {
|
/**
|
||||||
if (_cachedPassword === null) {
|
* Get the current session token from sessionStorage.
|
||||||
_cachedPassword = localStorage.getItem("admin-password");
|
* Always reads directly from sessionStorage to support multi-tab sync.
|
||||||
}
|
* Returns null if not authenticated.
|
||||||
return _cachedPassword;
|
*/
|
||||||
|
export function getSessionToken(): string | null {
|
||||||
|
return sessionStorage.getItem("admin-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a session token after successful login.
|
||||||
|
*/
|
||||||
|
export function setSessionToken(token: string): void {
|
||||||
|
sessionStorage.setItem("admin-token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the session token (logout).
|
||||||
|
*/
|
||||||
|
export function clearSessionToken(): void {
|
||||||
|
sessionStorage.removeItem("admin-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getSessionToken() instead.
|
||||||
|
* Kept for backward compatibility during migration.
|
||||||
|
*/
|
||||||
|
export function getAdminPassword(): string | null {
|
||||||
|
return localStorage.getItem("admin-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use setSessionToken() instead.
|
||||||
|
*/
|
||||||
|
export function setAdminPassword(password: string): void {
|
||||||
|
localStorage.setItem("admin-password", password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use clearSessionToken() instead.
|
||||||
|
*/
|
||||||
|
export function clearAdminPassword(): void {
|
||||||
|
localStorage.removeItem("admin-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchParams(
|
function buildSearchParams(
|
||||||
@@ -76,16 +116,32 @@ export async function request<T>(
|
|||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
timeoutMs?: number,
|
timeoutMs?: number,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const password = getAdminPassword();
|
const token = getSessionToken();
|
||||||
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
|
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
|
||||||
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
logger.debug("Request", { method: init?.method ?? "GET", url });
|
logger.debug("Request", { method: init?.method ?? "GET", url });
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Only set Content-Type for non-FormData bodies
|
||||||
|
// FormData sets its own Content-Type (multipart/form-data with boundary)
|
||||||
|
if (!(init?.body instanceof FormData)) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer Bearer token (new auth method)
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
// Fallback: X-Admin-Password (for backward compatibility)
|
||||||
|
const password = getAdminPassword();
|
||||||
|
if (password) {
|
||||||
|
headers["X-Admin-Password"] = password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(password ? { "X-Admin-Password": password } : {}),
|
|
||||||
},
|
|
||||||
signal,
|
signal,
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
@@ -101,6 +157,13 @@ export async function request<T>(
|
|||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
logger.error("Request failed", { url, status: res.status, code, message });
|
logger.error("Request failed", { url, status: res.status, code, message });
|
||||||
|
|
||||||
|
// Auto-logout on 401: token expired / invalidated
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearSessionToken();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
throw new ApiError(code, message, res.status);
|
throw new ApiError(code, message, res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +180,7 @@ export function getAPIURL(): string {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
ActiveSpeaker,
|
ActiveSpeaker,
|
||||||
|
AdminSettings,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
Channel,
|
Channel,
|
||||||
ChatResponse,
|
ChatResponse,
|
||||||
@@ -271,13 +335,35 @@ export function deleteRecording(id: string): Promise<void> {
|
|||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function login(password: string): Promise<{ ok: boolean }> {
|
export function login(password: string): Promise<{ ok: boolean; token?: string }> {
|
||||||
return request<{ ok: boolean }>("/api/auth/login", {
|
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side logout: increments token version, invalidating all sessions.
|
||||||
|
* Call this before clearing local state so the token is properly revoked.
|
||||||
|
*/
|
||||||
|
export function logout(): Promise<{ ok: boolean }> {
|
||||||
|
return request<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
// ─── Admin Settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAdminSettings(): Promise<AdminSettings> {
|
||||||
|
return request<AdminSettings>("/api/admin/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminSettings(
|
||||||
|
patch: Partial<{ dashboardIsPublic: boolean }>,
|
||||||
|
): Promise<AdminSettings> {
|
||||||
|
return request<AdminSettings>("/api/admin/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Dashboard ─────────────────────────────────────────────────────────────────
|
// ─── Dashboard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getDashboardStats(): Promise<DashboardStats> {
|
export function getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
FileText,
|
||||||
|
HelpCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Moon,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
Volume2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
} from "react";
|
||||||
|
import type { MessageRecord } from "../api/client";
|
||||||
|
import { request } from "../api/client";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { Input } from "./index";
|
||||||
|
|
||||||
|
/* ─── Modal backdrop variants ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const backdropVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: { opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.96, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { type: "spring" as const, stiffness: 350, damping: 28 },
|
||||||
|
},
|
||||||
|
exit: { opacity: 0, scale: 0.96, y: 10, transition: { duration: 0.15 } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ─── Types ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type ModalMode = "search" | "shortcuts" | null;
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
mode: ModalMode;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: (tab: string) => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcuts = [
|
||||||
|
{ keys: ["Ctrl", "K"], desc: "Open search" },
|
||||||
|
{ keys: ["?"], desc: "Show keyboard shortcuts" },
|
||||||
|
{ keys: ["Esc"], desc: "Close modal / cancel" },
|
||||||
|
{ keys: ["Ctrl", "1"], desc: "Messages & Moderation" },
|
||||||
|
{ keys: ["Ctrl", "2"], desc: "Voice & Media" },
|
||||||
|
{ keys: ["Ctrl", "3"], desc: "Dashboard" },
|
||||||
|
{ keys: ["Ctrl", "4"], desc: "Settings" },
|
||||||
|
{ keys: ["Space"], desc: "Push-to-talk (when in voice)" },
|
||||||
|
{ keys: ["T"], desc: "Toggle theme" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Help panel ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function ShortcutsPanel() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<HelpCircle className="h-4 w-4 text-primary" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{shortcuts.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.keys.join("+")}
|
||||||
|
className="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">{s.desc}</span>
|
||||||
|
<kbd className="flex items-center gap-1">
|
||||||
|
{s.keys.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-border bg-background px-1.5 text-xs font-mono text-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{k === "Ctrl" ? <Command className="h-3 w-3" /> : k}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Quick actions ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ id: "messages", label: "Go to Messages", icon: MessageSquare },
|
||||||
|
{ id: "live", label: "Go to Voice & Media", icon: Volume2 },
|
||||||
|
{ id: "dashboard", label: "Go to Dashboard", icon: FileText },
|
||||||
|
{ id: "settings", label: "Open Settings", icon: Settings },
|
||||||
|
{ id: "theme", label: "Toggle theme", icon: Sun },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Main component ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function CommandPalette({
|
||||||
|
isOpen,
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
onToggleTheme,
|
||||||
|
isDark,
|
||||||
|
}: CommandPaletteProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
// Focus input when search mode opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && mode === "search") {
|
||||||
|
// Small delay for the animation to settle
|
||||||
|
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
return () => clearTimeout(focusTimer);
|
||||||
|
}
|
||||||
|
}, [isOpen, mode]);
|
||||||
|
|
||||||
|
// Reset state when closing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (q: string) => {
|
||||||
|
setQuery(q);
|
||||||
|
if (!q.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q, limit: "10" });
|
||||||
|
const data = await request<{ results: MessageRecord[] }>(
|
||||||
|
`/api/analysis/search?${params}`,
|
||||||
|
);
|
||||||
|
setSearchResults(data.results || []);
|
||||||
|
} catch {
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeAction = useCallback(
|
||||||
|
(action: string) => {
|
||||||
|
if (action === "theme") {
|
||||||
|
onToggleTheme();
|
||||||
|
} else if (action === "settings") {
|
||||||
|
onNavigate("settings");
|
||||||
|
} else if (action === "messages") {
|
||||||
|
onNavigate("messages");
|
||||||
|
} else if (action === "live") {
|
||||||
|
onNavigate("live");
|
||||||
|
} else if (action === "dashboard") {
|
||||||
|
onNavigate("dashboard");
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onNavigate, onToggleTheme, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i < searchResults.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i > 0 ? i - 1 : searchResults.length - 1));
|
||||||
|
} else if (e.key === "Enter" && searchResults.length > 0) {
|
||||||
|
onClose();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchResults.length, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global keyboard listeners for search and help
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Ctrl+K — open search
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
// Don't toggle if already open — just close
|
||||||
|
if (isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? — show shortcuts (only when no modal is open)
|
||||||
|
if (e.key === "?" && !isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape — close any modal
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+1-4 — tab navigation
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const tabMap: Record<string, string> = {
|
||||||
|
"1": "messages",
|
||||||
|
"2": "live",
|
||||||
|
"3": "dashboard",
|
||||||
|
"4": "settings",
|
||||||
|
};
|
||||||
|
const tab = tabMap[e.key];
|
||||||
|
if (tab) {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// T — toggle theme (when no input focused)
|
||||||
|
if (e.key === "t" && !e.ctrlKey && !e.metaKey && !isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [isOpen, onClose, onNavigate, onToggleTheme]);
|
||||||
|
|
||||||
|
const showSearch = mode === "search";
|
||||||
|
const showShortcuts = mode === "shortcuts";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-start justify-center pt-[12vh]"
|
||||||
|
variants={backdropVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="hidden"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-border/50 bg-card shadow-2xl"
|
||||||
|
variants={modalVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
{/* Search header */}
|
||||||
|
{showSearch && (
|
||||||
|
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-3">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search messages across all channels..."
|
||||||
|
className="flex-1 border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
)}
|
||||||
|
{!isSearching && query && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<kbd className="shrink-0 hidden sm:inline-flex h-5 items-center rounded-md border border-border bg-background px-1.5 text-[10px] font-mono text-muted-foreground">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shortcuts header */}
|
||||||
|
{showShortcuts && (
|
||||||
|
<div className="flex items-center justify-between border-b border-border/50 px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<HelpCircle className="h-4 w-4 text-primary" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{showSearch && (
|
||||||
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
|
{/* Quick actions */}
|
||||||
|
{!query && (
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Quick actions
|
||||||
|
</p>
|
||||||
|
{quickActions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const isThemeAction = action.id === "theme";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => executeAction(action.id)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isThemeAction && isDark
|
||||||
|
? "text-amber-400"
|
||||||
|
: isThemeAction
|
||||||
|
? "text-indigo-400"
|
||||||
|
: "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{action.label}</span>
|
||||||
|
{isThemeAction && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{isDark ? "→ Light" : "→ Dark"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{query && (
|
||||||
|
<div className="p-2">
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Messages ({searchResults.length})
|
||||||
|
</p>
|
||||||
|
{searchResults.map((msg, i) => (
|
||||||
|
<button
|
||||||
|
key={msg.id}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors",
|
||||||
|
i === activeIndex
|
||||||
|
? "bg-accent"
|
||||||
|
: "hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-foreground">
|
||||||
|
{msg.content || "(no content)"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{msg.username || msg.user_id || "unknown"}
|
||||||
|
{msg.ai_status === "flagged" && (
|
||||||
|
<span className="ml-2 text-destructive">
|
||||||
|
⚑ flagged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{isSearching
|
||||||
|
? "Searching..."
|
||||||
|
: "No messages found matching your query."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search footer hint */}
|
||||||
|
{!query && (
|
||||||
|
<div className="border-t border-border/50 px-4 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type to search messages — results are fetched from the
|
||||||
|
server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shortcuts content */}
|
||||||
|
{showShortcuts && <ShortcutsPanel />}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
import { motion } from "framer-motion";
|
||||||
|
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
|
||||||
import type { DashboardTab } from "../../entities/ui/types.js";
|
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
|||||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||||
{ id: "live", label: "Voice & Media", Icon: Radio },
|
{ id: "live", label: "Voice & Media", Icon: Radio },
|
||||||
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
|
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
|
||||||
|
{ id: "settings" as const, label: "Admin", Icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface MobileTabBarProps {
|
interface MobileTabBarProps {
|
||||||
@@ -18,7 +20,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
|||||||
<nav
|
<nav
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden"
|
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg shadow-black/5 md:hidden"
|
||||||
>
|
>
|
||||||
{tabs.map(({ id, label, Icon }) => (
|
{tabs.map(({ id, label, Icon }) => (
|
||||||
<button
|
<button
|
||||||
@@ -29,16 +31,26 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onTabChange(id)}
|
onClick={() => onTabChange(id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
"relative flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
||||||
activeTab === id ? "text-primary" : "text-muted-foreground",
|
activeTab === id
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{activeTab === id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="tab-indicator"
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
|
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")} />
|
||||||
<span className="text-[10px]">{label}</span>
|
<span className="text-[10px]">{label}</span>
|
||||||
{activeTab === id && (
|
{activeTab === id && (
|
||||||
<span
|
<motion.div
|
||||||
aria-hidden="true"
|
layoutId="tab-dot"
|
||||||
className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5"
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
|
className="h-1 w-1 rounded-full bg-primary mt-0.5"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error Boundary component — catches JavaScript errors in its child tree,
|
||||||
|
* logs them, and displays a fallback UI instead of crashing the whole app.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("[ErrorBoundary] Caught error:", error.message, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-center",
|
||||||
|
this.props.className,
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mb-3 h-8 w-8 text-destructive" />
|
||||||
|
<h3 className="mb-1 font-semibold text-foreground">
|
||||||
|
{this.props.message || "Something went wrong"}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{this.state.error?.message || "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { MediaState } from "../../entities/media/types.js";
|
import type { MediaState } from "../../entities/media/types.js";
|
||||||
import { createLogger } from "../lib/logger.js";
|
import { createLogger } from "../lib/logger.js";
|
||||||
|
import { getSessionToken } from "../api/client.js";
|
||||||
import type { ActiveSpeakerData } from "./events.js";
|
import type { ActiveSpeakerData } from "./events.js";
|
||||||
|
|
||||||
const logger = createLogger("socket");
|
const logger = createLogger("socket");
|
||||||
@@ -79,6 +80,7 @@ let _wsInstance: WebSocket | null = null;
|
|||||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let _closed = false;
|
let _closed = false;
|
||||||
let _reconnectAttempts = 0;
|
let _reconnectAttempts = 0;
|
||||||
|
let _heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
const _listeners = new Set<WsHandlers>();
|
const _listeners = new Set<WsHandlers>();
|
||||||
const _statusCallbacks = new Set<(s: WsStatus) => void>();
|
const _statusCallbacks = new Set<(s: WsStatus) => void>();
|
||||||
|
|
||||||
@@ -90,7 +92,13 @@ function doConnect(): WebSocket {
|
|||||||
const BE_WS_URL =
|
const BE_WS_URL =
|
||||||
import.meta.env.VITE_BE_WS_URL ||
|
import.meta.env.VITE_BE_WS_URL ||
|
||||||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
||||||
const url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
|
let url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
|
||||||
|
|
||||||
|
// ⚠️ Token is NOT appended to the URL — sending it in the query string
|
||||||
|
// would leak it into Nginx/Traefik access logs, browser history, and
|
||||||
|
// Referer headers. Instead, the frontend sends an auth message as the
|
||||||
|
// first WebSocket frame after connection.
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
dispatchStatus("connecting");
|
dispatchStatus("connecting");
|
||||||
@@ -100,12 +108,32 @@ function doConnect(): WebSocket {
|
|||||||
_reconnectAttempts = 0;
|
_reconnectAttempts = 0;
|
||||||
dispatchStatus("connected");
|
dispatchStatus("connected");
|
||||||
logger.info("Connected");
|
logger.info("Connected");
|
||||||
|
|
||||||
|
// Send auth message — token is sent as the first WebSocket frame,
|
||||||
|
// NOT in the URL query string, to avoid exposure in access logs.
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
ws.send(JSON.stringify({ type: "auth", token: sessionToken }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat — send a ping every 25s to keep the connection alive
|
||||||
|
if (_heartbeatInterval) clearInterval(_heartbeatInterval);
|
||||||
|
_heartbeatInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
}
|
||||||
|
}, 25_000);
|
||||||
});
|
});
|
||||||
ws.addEventListener("error", () => {
|
ws.addEventListener("error", () => {
|
||||||
dispatchStatus("error");
|
dispatchStatus("error");
|
||||||
logger.error("WebSocket error");
|
logger.error("WebSocket error");
|
||||||
});
|
});
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
|
// Clean up heartbeat on disconnect
|
||||||
|
if (_heartbeatInterval) {
|
||||||
|
clearInterval(_heartbeatInterval);
|
||||||
|
_heartbeatInterval = null;
|
||||||
|
}
|
||||||
dispatchStatus("disconnected");
|
dispatchStatus("disconnected");
|
||||||
logger.info("Disconnected", { code: event.code, reason: event.reason });
|
logger.info("Disconnected", { code: event.code, reason: event.reason });
|
||||||
if (!_closed && _listeners.size > 0) {
|
if (!_closed && _listeners.size > 0) {
|
||||||
@@ -338,15 +366,20 @@ export function useDashboardSocket(handlers: WsHandlers) {
|
|||||||
ensureConnected();
|
ensureConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return (): void => {
|
||||||
_listeners.delete(wrapper);
|
_listeners.delete(wrapper);
|
||||||
_statusCallbacks.delete(setStatus);
|
_statusCallbacks.delete(setStatus);
|
||||||
if (_listeners.size === 0) {
|
// Defer the close so that React Strict Mode double-invoke in dev
|
||||||
_closed = true;
|
// doesn't kill the socket that the remount immediately re-creates.
|
||||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
const delayClose = setTimeout(() => {
|
||||||
_wsInstance?.close();
|
if (_listeners.size === 0) {
|
||||||
_wsInstance = null;
|
_closed = true;
|
||||||
}
|
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||||
|
_wsInstance?.close();
|
||||||
|
_wsInstance = null;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
delayClose.unref?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../tailwind.config.js";
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
/* ── Light theme (default) ───────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--background: 1 0 0;
|
--background: 1 0 0;
|
||||||
--foreground: 0.141 0.005 285.823;
|
--foreground: 0.141 0.005 285.823;
|
||||||
@@ -25,10 +25,49 @@
|
|||||||
--primary-glow: 0.623 0.214 259.815 / 0.15;
|
--primary-glow: 0.623 0.214 259.815 / 0.15;
|
||||||
--accent-glow: 0.552 0.016 285.938 / 0.15;
|
--accent-glow: 0.552 0.016 285.938 / 0.15;
|
||||||
--card-shadow: 0.92 0.004 286.32 / 0.3;
|
--card-shadow: 0.92 0.004 286.32 / 0.3;
|
||||||
|
--particle-primary: 0.623 0.214 259.815 / 0.1;
|
||||||
|
--particle-secondary: 0.552 0.016 285.938 / 0.1;
|
||||||
|
--brand-gradient-from: var(--primary);
|
||||||
|
--brand-gradient-to: 0.623 0.2 200;
|
||||||
|
--scrollbar-track: 0.967 0.001 286.375;
|
||||||
|
--scrollbar-thumb: 0.92 0.004 286.32;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
/* ── Dark theme ──────────────────────────────────────────────────────── */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--background: 0.147 0.004 285.823;
|
||||||
|
--foreground: 0.92 0.004 286.32;
|
||||||
|
--card: 0.162 0.008 286.034;
|
||||||
|
--card-foreground: 0.92 0.004 286.32;
|
||||||
|
--primary: 0.623 0.214 259.815;
|
||||||
|
--primary-soft: 0.3 0.04 259.815;
|
||||||
|
--primary-foreground: 0.97 0.014 254.604;
|
||||||
|
--secondary: 0.2 0.008 286.034;
|
||||||
|
--secondary-foreground: 0.85 0.008 286.034;
|
||||||
|
--muted: 0.2 0.008 286.034;
|
||||||
|
--muted-foreground: 0.6 0.016 285.938;
|
||||||
|
--accent: 0.2 0.008 286.034;
|
||||||
|
--accent-foreground: 0.85 0.008 286.034;
|
||||||
|
--destructive: 0.577 0.245 27.325;
|
||||||
|
--destructive-foreground: 0.97 0.014 254.604;
|
||||||
|
--border: 0.25 0.008 286.034;
|
||||||
|
--input: 0.25 0.008 286.034;
|
||||||
|
--ring: 0.623 0.214 259.815;
|
||||||
|
--primary-glow: 0.623 0.214 259.815 / 0.08;
|
||||||
|
--accent-glow: 0.552 0.016 285.938 / 0.08;
|
||||||
|
--card-shadow: 0 0 0 / 0.5;
|
||||||
|
--particle-primary: 0.623 0.214 259.815 / 0.06;
|
||||||
|
--particle-secondary: 0.552 0.016 285.938 / 0.06;
|
||||||
|
--brand-gradient-from: var(--primary);
|
||||||
|
--brand-gradient-to: 0.7 0.2 220;
|
||||||
|
--scrollbar-track: 0.147 0.004 285.823;
|
||||||
|
--scrollbar-thumb: 0.3 0.008 286.034;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base styles ─────────────────────────────────────────────────────── */
|
||||||
|
[data-theme] {
|
||||||
border-color: oklch(var(--border));
|
border-color: oklch(var(--border));
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -36,7 +75,7 @@
|
|||||||
color: oklch(var(--foreground));
|
color: oklch(var(--foreground));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: Outfit, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -44,22 +83,47 @@
|
|||||||
#root {
|
#root {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar styling ───────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: oklch(var(--scrollbar-track));
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(var(--scrollbar-thumb));
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus ring consistency ──────────────────────────────────────────── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid oklch(var(--ring));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
|
@apply backdrop-blur-sm rounded-xl;
|
||||||
|
background-color: oklch(var(--card) / 0.7);
|
||||||
|
border: 1px solid oklch(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-pattern {
|
.grid-pattern {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
|
linear-gradient(oklch(var(--border) / 0.3) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
|
linear-gradient(90deg, oklch(var(--border) / 0.3) 1px, transparent 1px);
|
||||||
background-size: 40px 40px;
|
background-size: 40px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
|
@apply bg-clip-text text-transparent;
|
||||||
|
background-image: linear-gradient(to right, oklch(var(--brand-gradient-from)), oklch(var(--brand-gradient-to)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in-up {
|
.animate-fade-in-up {
|
||||||
@@ -99,23 +163,32 @@
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||||
|
50% { opacity: 0.8; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
.animate-bar-pulse {
|
.animate-bar-pulse {
|
||||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-shimmer {
|
.animate-shimmer {
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
90deg,
|
||||||
oklch(0.92 0.004 286.32 / 0.5) 0%,
|
oklch(var(--border) / 0.5) 0%,
|
||||||
oklch(0.967 0.001 286.375) 40%,
|
oklch(var(--muted)) 40%,
|
||||||
oklch(0.92 0.004 286.32 / 0.5) 80%,
|
oklch(var(--border) / 0.5) 80%,
|
||||||
oklch(0.92 0.004 286.32 / 0.7) 100%
|
oklch(var(--border) / 0.7) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-glow-pulse {
|
||||||
|
animation: glow-pulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion ──────────────────────────────────────────────────── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { MessageRecord } from "../entities/message/types.js";
|
import type { MessageRecord } from "../entities/message/types.js";
|
||||||
import type { DashboardTab } from "../entities/ui/types.js";
|
import type { DashboardTab } from "../entities/ui/types.js";
|
||||||
import type { VoiceStatus } from "../entities/voice/types.js";
|
import type { VoiceStatus } from "../entities/voice/types.js";
|
||||||
|
import type { ThemeMode } from "../hooks/useTheme";
|
||||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||||
import type { WsStatus } from "../shared/ws/socket";
|
import type { WsStatus } from "../shared/ws/socket";
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
@@ -13,22 +14,30 @@ interface DashboardLayoutProps {
|
|||||||
activeTab: DashboardTab;
|
activeTab: DashboardTab;
|
||||||
wsStatus: WsStatus;
|
wsStatus: WsStatus;
|
||||||
voiceStatus: VoiceStatus;
|
voiceStatus: VoiceStatus;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
isDark: boolean;
|
||||||
onTabChange: (tab: DashboardTab) => void;
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
|
onThemeToggle: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
recentMessages?: MessageRecord[];
|
recentMessages?: MessageRecord[];
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
notificationCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardLayout({
|
export function DashboardLayout({
|
||||||
activeTab,
|
activeTab,
|
||||||
wsStatus,
|
wsStatus,
|
||||||
voiceStatus,
|
voiceStatus,
|
||||||
|
themeMode,
|
||||||
|
isDark,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
onThemeToggle,
|
||||||
children,
|
children,
|
||||||
recentMessages = [],
|
recentMessages = [],
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
|
notificationCount = 0,
|
||||||
}: DashboardLayoutProps) {
|
}: DashboardLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-background text-foreground">
|
<div className="relative min-h-screen bg-background text-foreground">
|
||||||
@@ -46,23 +55,29 @@ export function DashboardLayout({
|
|||||||
recentMessages={recentMessages}
|
recentMessages={recentMessages}
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
|
notificationCount={notificationCount}
|
||||||
/>
|
/>
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<Header
|
<Header
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
wsStatus={wsStatus}
|
wsStatus={wsStatus}
|
||||||
voiceStatus={voiceStatus}
|
voiceStatus={voiceStatus}
|
||||||
|
themeMode={themeMode}
|
||||||
|
isDark={isDark}
|
||||||
|
onThemeToggle={onThemeToggle}
|
||||||
/>
|
/>
|
||||||
<motion.main
|
<AnimatePresence mode="wait">
|
||||||
key={activeTab}
|
<motion.main
|
||||||
variants={fadeSlideUp}
|
key={activeTab}
|
||||||
initial="initial"
|
variants={fadeSlideUp}
|
||||||
animate="animate"
|
initial="initial"
|
||||||
exit="exit"
|
animate="animate"
|
||||||
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
|
exit="exit"
|
||||||
>
|
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8 pb-16 md:pb-0"
|
||||||
{children}
|
>
|
||||||
</motion.main>
|
{children}
|
||||||
|
</motion.main>
|
||||||
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Wifi, WifiOff } from "lucide-react";
|
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
|
||||||
import type { DashboardTab } from "../entities/ui/types.js";
|
import type { DashboardTab } from "../entities/ui/types.js";
|
||||||
import type { VoiceStatus } from "../entities/voice/types.js";
|
import type { VoiceStatus } from "../entities/voice/types.js";
|
||||||
|
import type { ThemeMode } from "../hooks/useTheme";
|
||||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||||
import { cn } from "../shared/lib/utils";
|
import { cn } from "../shared/lib/utils";
|
||||||
import { Badge } from "../shared/ui";
|
import { Badge } from "../shared/ui";
|
||||||
@@ -11,18 +12,23 @@ const titles: Record<DashboardTab, string> = {
|
|||||||
messages: "Messages & Moderation",
|
messages: "Messages & Moderation",
|
||||||
live: "Voice & Media",
|
live: "Voice & Media",
|
||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
|
settings: "Admin Settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtitles: Record<DashboardTab, string> = {
|
const subtitles: Record<DashboardTab, string> = {
|
||||||
messages: "Capture, analyse, and moderate Discord messages.",
|
messages: "Capture, analyse, and moderate Discord messages.",
|
||||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||||
dashboard: "Server statistics, user profiles, and AI moderation overview.",
|
dashboard: "Server statistics, user profiles, and AI moderation overview.",
|
||||||
|
settings: "Manage dashboard visibility, runtime configuration, and authentication.",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
activeTab: DashboardTab;
|
activeTab: DashboardTab;
|
||||||
wsStatus: WsStatus;
|
wsStatus: WsStatus;
|
||||||
voiceStatus: VoiceStatus;
|
voiceStatus: VoiceStatus;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
isDark: boolean;
|
||||||
|
onThemeToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dot indicator colour for WS badge */
|
/** Dot indicator colour for WS badge */
|
||||||
@@ -63,7 +69,7 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, onThemeToggle }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
|
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -92,8 +98,22 @@ export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
|||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Right: status badges */}
|
{/* Right: status badges + theme toggle */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onThemeToggle}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
title={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<Sun className="h-3.5 w-3.5 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-3.5 w-3.5 text-indigo-400" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* WS Badge */}
|
{/* WS Badge */}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
import {
|
||||||
|
Bell,
|
||||||
|
LayoutDashboard,
|
||||||
|
MessageSquare,
|
||||||
|
Radio,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
import type { MessageRecord } from "../entities/message/types.js";
|
import type { MessageRecord } from "../entities/message/types.js";
|
||||||
import type { DashboardTab } from "../entities/ui/types.js";
|
import type { DashboardTab } from "../entities/ui/types.js";
|
||||||
import { useMascotChat } from "../shared/hooks/useMascotChat";
|
import { useMascotChat } from "../shared/hooks/useMascotChat";
|
||||||
@@ -7,12 +13,16 @@ import { cn } from "../shared/lib/utils";
|
|||||||
import { MascotChatbot } from "./mascot/MascotChatbot";
|
import { MascotChatbot } from "./mascot/MascotChatbot";
|
||||||
import { MascotImage } from "./mascot/MascotImage";
|
import { MascotImage } from "./mascot/MascotImage";
|
||||||
|
|
||||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
|
const navItems: Array<{
|
||||||
[
|
id: DashboardTab;
|
||||||
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
|
label: string;
|
||||||
{ id: "live", label: "Voice & Media", icon: Radio },
|
icon: typeof Radio;
|
||||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
}> = [
|
||||||
];
|
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
|
||||||
|
{ id: "live", label: "Voice & Media", icon: Radio },
|
||||||
|
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ id: "settings" as const, label: "Admin", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeTab: DashboardTab;
|
activeTab: DashboardTab;
|
||||||
@@ -21,6 +31,7 @@ interface SidebarProps {
|
|||||||
recentMessages?: MessageRecord[];
|
recentMessages?: MessageRecord[];
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
notificationCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -30,6 +41,7 @@ export function Sidebar({
|
|||||||
recentMessages = [],
|
recentMessages = [],
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
|
notificationCount = 0,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const mascotChat = useMascotChat({
|
const mascotChat = useMascotChat({
|
||||||
messageCount: recentMessages.length,
|
messageCount: recentMessages.length,
|
||||||
@@ -46,7 +58,7 @@ export function Sidebar({
|
|||||||
<motion.nav
|
<motion.nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
|
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
|
||||||
collapsed ? "w-16" : "w-64",
|
collapsed ? "w-16" : "w-56 lg:w-64",
|
||||||
)}
|
)}
|
||||||
layout
|
layout
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
@@ -62,6 +74,10 @@ export function Sidebar({
|
|||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||||
alt="IMPHNEN"
|
alt="IMPHNEN"
|
||||||
className="h-8 w-8 rounded-xl"
|
className="h-8 w-8 rounded-xl"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mascot image — only when expanded */}
|
{/* Mascot image — only when expanded */}
|
||||||
@@ -70,6 +86,10 @@ export function Sidebar({
|
|||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||||
alt="Mascot"
|
alt="Mascot"
|
||||||
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md"
|
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +115,21 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
{!collapsed && item.id === "messages" &&
|
||||||
|
notificationCount !== undefined &&
|
||||||
|
notificationCount > 0 && (
|
||||||
|
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||||
|
{notificationCount > 99 ? "99+" : notificationCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Collapsed badge — top-right dot */}
|
||||||
|
{collapsed && item.id === "messages" &&
|
||||||
|
notificationCount !== undefined &&
|
||||||
|
notificationCount > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[7px] font-bold text-destructive-foreground">
|
||||||
|
{notificationCount > 9 ? "N" : notificationCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -118,7 +153,7 @@ export function Sidebar({
|
|||||||
onClose={() => mascotChat.setIsOpen(false)}
|
onClose={() => mascotChat.setIsOpen(false)}
|
||||||
onSendMessage={mascotChat.handleSendMessage}
|
onSendMessage={mascotChat.handleSendMessage}
|
||||||
mascotName="IMPHNEN Mascot"
|
mascotName="IMPHNEN Mascot"
|
||||||
className="fixed bottom-[170px] left-[80px] z-[9999]"
|
className="fixed bottom-[170px] left-[80px] z-[9999] md:bottom-4 md:left-4 md:right-auto"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -117,14 +117,14 @@ export function MascotChatbot({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-primary to-primary/80 text-white p-4 flex items-center justify-between">
|
<div className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground p-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-primary-foreground/20 flex items-center justify-center">
|
||||||
<MessageCircle className="h-5 w-5" />
|
<MessageCircle className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm">{mascotName}</h3>
|
<h3 className="font-semibold text-sm">{mascotName}</h3>
|
||||||
<p className="text-xs text-white/80">
|
<p className="text-xs text-primary-foreground/80">
|
||||||
{loading ? "Mengetik..." : "Online"}
|
{loading ? "Mengetik..." : "Online"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +134,7 @@ export function MascotChatbot({
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||||
title={isMinimized ? "Maximize" : "Minimize"}
|
title={isMinimized ? "Maximize" : "Minimize"}
|
||||||
>
|
>
|
||||||
{isMinimized ? (
|
{isMinimized ? (
|
||||||
@@ -149,7 +149,7 @@ export function MascotChatbot({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}}
|
}}
|
||||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||||
title="Close"
|
title="Close"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -176,6 +176,10 @@ export function MascotChatbot({
|
|||||||
src={mascotAvatar}
|
src={mascotAvatar}
|
||||||
alt={mascotName}
|
alt={mascotName}
|
||||||
className="w-6 h-6 rounded-full object-cover"
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -200,6 +204,10 @@ export function MascotChatbot({
|
|||||||
src={mascotAvatar}
|
src={mascotAvatar}
|
||||||
alt={mascotName}
|
alt={mascotName}
|
||||||
className="w-6 h-6 rounded-full object-cover"
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
|
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function MascotImage({
|
|||||||
const sizeClass = sizeMap[size];
|
const sizeClass = sizeMap[size];
|
||||||
const chatSizeClass = chatSizeMap[size];
|
const chatSizeClass = chatSizeMap[size];
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showChat && chatMessage) {
|
if (showChat && chatMessage) {
|
||||||
@@ -51,13 +52,20 @@ export function MascotImage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<motion.img
|
{imgError ? (
|
||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
<div className={`flex items-center justify-center ${sizeClass} bg-muted/30 rounded-xl`}>
|
||||||
alt="Mascot"
|
<MessageCircle className="h-6 w-6 text-muted-foreground/50" />
|
||||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
</div>
|
||||||
whileHover={{ scale: 1.05 }}
|
) : (
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
<motion.img
|
||||||
/>
|
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||||
|
alt="Mascot"
|
||||||
|
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating Chat Bubble */}
|
{/* Floating Chat Bubble */}
|
||||||
{isVisible && chatMessage && (
|
{isVisible && chatMessage && (
|
||||||
@@ -72,7 +80,7 @@ export function MascotImage({
|
|||||||
{/* Chat bubble */}
|
{/* Chat bubble */}
|
||||||
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
|
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
|
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-primary-foreground/80" />
|
||||||
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||||
{chatMessage}
|
{chatMessage}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ export function ParticleBackground() {
|
|||||||
style={{ zIndex: -1 }}
|
style={{ zIndex: -1 }}
|
||||||
>
|
>
|
||||||
{/* Top-right glow orb */}
|
{/* Top-right glow orb */}
|
||||||
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
|
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
|
||||||
|
style={{ backgroundColor: "oklch(var(--particle-primary, 0.623 0.214 259.815 / 0.1))" }}
|
||||||
|
/>
|
||||||
{/* Bottom-left glow orb */}
|
{/* Bottom-left glow orb */}
|
||||||
<div
|
<div
|
||||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse"
|
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl animate-glow-pulse"
|
||||||
style={{ animationDelay: "1.5s" }}
|
style={{
|
||||||
|
backgroundColor: "oklch(var(--particle-secondary, 0.552 0.016 285.938 / 0.1))",
|
||||||
|
animationDelay: "1.5s",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"types": ["vite/client"],
|
"types": ["astro/client"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "vite.config.ts"]
|
"include": ["src/**/*", "astro.config.mjs"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
build: {
|
|
||||||
rolldownOptions: {
|
|
||||||
checks: {
|
|
||||||
pluginTimings: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
middlewareMode: false,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
port: 3000,
|
|
||||||
host: true,
|
|
||||||
allowedHosts: [
|
|
||||||
"imphnen.asepharyana.my.id",
|
|
||||||
"imphnen.asepharyana.tech",
|
|
||||||
"imphnen.asepharyana.web.id",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user