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:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
+4 -1
View File
@@ -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)
+161
View File
@@ -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
+383
View File
@@ -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 0100% 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 50950 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."* ❄️🩵
+371
View File
@@ -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); /* 1112px */
--fs-sm: clamp(0.81rem, 0.81rem + 0.02vw, 0.88rem); /* 1314px */
--fs-base: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem); /* 1516px ★ */
--fs-md: clamp(1.06rem, 1.06rem + 0.04vw, 1.13rem); /* 1718px */
--fs-lg: clamp(1.19rem, 1.19rem + 0.06vw, 1.25rem); /* 1920px */
--fs-xl: clamp(1.31rem, 1.31rem + 0.08vw, 1.50rem); /* 2124px */
--fs-2xl: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem); /* 2430px */
--fs-3xl: clamp(1.69rem, 1.69rem + 0.18vw, 2.25rem); /* 2736px */
--fs-4xl: clamp(1.88rem, 1.88rem + 0.26vw, 2.81rem); /* 3045px */
--fs-5xl: clamp(2.25rem, 2.25rem + 0.38vw, 3.50rem); /* 3656px */
--fs-6xl: clamp(2.50rem, 2.50rem + 0.50vw, 4.00rem); /* 4064px */
```
> **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."* ❄️🩵
+349
View File
@@ -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 |
| `640768px` | Bottom tab | `--sp-4` (24px) | 1 |
| `7681024px` | Icon-only (64px) | `--sp-4` (24px) | 12 |
| `10241280px` | Full (256px) | `--sp-5` (32px) | 23 |
| `1280px+` | Full (256px) | `--sp-5` (32px) | 24 |
---
## 📦 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."* ❄️🩵
+552
View File
@@ -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; }
/* ✅ 250350ms 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."* ❄️🩵
+313
View File
@@ -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."* ❄️🩵
+151
View File
@@ -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."* ❄️🩵
+171
View File
@@ -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."* ❄️🩵
+110
View File
@@ -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."* ❄️🩵
+307
View File
@@ -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."* ❄️🩵
+288
View File
@@ -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."* ❄️🩵
+327
View File
@@ -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."* ❄️🩵
+310
View File
@@ -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; // 01
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."* ❄️🩵
+334
View File
@@ -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."* ❄️🩵
+282
View File
@@ -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."* ❄️🩵
+314
View File
@@ -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."* ❄️🩵
-2
View File
@@ -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 .",
+8 -2
View File
@@ -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
+3
View File
@@ -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),
}), }),
); );
+2043 -68
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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"
} }
} }
+9 -1
View File
@@ -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}`, {
+115 -9
View File
@@ -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);
} }
+83 -19
View File
@@ -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");
+194 -81
View File
@@ -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> = { "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" };
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
View File
@@ -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;
}
+18
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1782920146192
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="astro/client" />
+45
View File
@@ -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
},
});
-17
View File
@@ -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>
+6 -6
View File
@@ -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"
} }
} }
+18
View File
@@ -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
View File
@@ -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}
/>
</>
); );
} }
+9 -2
View File
@@ -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>
);
}
+96 -15
View File
@@ -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) };
}
+77
View File
@@ -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>
-19
View File
@@ -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>,
);
+12
View File
@@ -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>
+100 -14
View File
@@ -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;
}
}
+41 -8
View File
@@ -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?.();
}; };
}, []); }, []);
+85 -12
View File
@@ -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>
+23 -3
View File
@@ -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"
+44 -9
View File
@@ -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
View File
@@ -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: {
+7 -3
View File
@@ -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"]
} }
-25
View File
@@ -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",
],
},
});