From 6ecff523c7cd30571b4245926cd8aa693545267a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 2 Jul 2026 03:54:44 +0700 Subject: [PATCH] Revert "docs: add Astro migration implementation plan" This reverts commit 5e40b1ad8c9fbb1cc972b0c77b20f7f27fddf54c. --- .../plans/2026-07-02-astro-migration-plan.md | 2310 ----------------- 1 file changed, 2310 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-02-astro-migration-plan.md diff --git a/docs/superpowers/plans/2026-07-02-astro-migration-plan.md b/docs/superpowers/plans/2026-07-02-astro-migration-plan.md deleted file mode 100644 index 7073a77..0000000 --- a/docs/superpowers/plans/2026-07-02-astro-migration-plan.md +++ /dev/null @@ -1,2310 +0,0 @@ -# BETE Astro Migration — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Refactor existing Astro + React SPA frontend into Astro SSG with real file-based routing, per-feature React islands, and full design system implementation from `design/`. - -**Architecture:** Replace the current pattern (single `client:only="react"` island rendering a monolithic `App.tsx`) with file-based Astro pages. Non-interactive UI rendered as `.astro` components (zero JS). Interactive parts (WebSocket, canvas, real-time state) remain as React islands in `src/islands/`, each hydrated via appropriate `client:*` directive. CSS architecture uses Tailwind 4 `@theme` block + OKLCH CSS custom properties from `design/`. - -**Tech Stack:** Astro 7, React 19, Tailwind CSS 4, Zustand 5, TanStack Query 5, TypeScript 5 - -## Global Constraints - -- All colors must use OKLCH color space via CSS custom properties (no hex/RGB hardcoding) -- All spacing must use 4px baseline grid (`--sp-*` tokens via Tailwind's 4px `--spacing` unit) -- All typography uses fluid `clamp()` scale via `--fs-*` tokens -- Glassmorphism utility classes must use `backdrop-filter: blur()` + OKLCH rgba -- React islands must use `client:load` only for WebSocket-reliant components; `client:idle` for decorative/non-critical ones -- All API calls go through `src/shared/api/client.ts` with `X-Admin-Password` header -- WebSocket lifecycle managed by `SocketManager` singleton -- Every data-driven component must handle loading/error/empty/success states per `design/patterns/09-state-machines.md` - ---- - -## File Structure Map - -Before tasks, here's every file that will be created or modified: - -``` -services/frontend/ -├── src/ -│ ├── styles/ -│ │ └── base.css ← CREATE (replace styles.css) -│ │ -│ ├── layouts/ -│ │ ├── BaseLayout.astro ← MODIFY (update meta, view-transitions) -│ │ └── AuthLayout.astro ← CREATE -│ │ -│ ├── pages/ -│ │ ├── index.astro ← MODIFY (redirect to /live) -│ │ ├── live.astro ← CREATE -│ │ ├── messages.astro ← CREATE -│ │ ├── settings.astro ← CREATE -│ │ ├── recordings.astro ← CREATE -│ │ ├── login.astro ← CREATE -│ │ └── 404.astro ← CREATE -│ │ -│ ├── components/ ← Astro components (zero JS) -│ │ ├── ui/ -│ │ │ ├── Button.astro ← CREATE -│ │ │ ├── Badge.astro ← CREATE -│ │ │ ├── SeverityBadge.astro ← CREATE -│ │ │ ├── Card.astro ← CREATE -│ │ │ ├── Skeleton.astro ← CREATE -│ │ │ └── Spinner.astro ← CREATE -│ │ ├── sidebar/ -│ │ │ ├── Sidebar.astro ← CREATE -│ │ │ └── NavItem.astro ← CREATE -│ │ ├── header/ -│ │ │ └── Header.astro ← CREATE -│ │ └── states/ -│ │ ├── EmptyState.astro ← CREATE -│ │ ├── ErrorState.astro ← CREATE -│ │ └── LoadingSkeleton.astro ← CREATE -│ │ -│ ├── islands/ ← React components (interactive) -│ │ ├── AuthGuard.tsx ← CREATE (from features/auth) -│ │ ├── VoiceControls.tsx ← CREATE (from features/live) -│ │ ├── AudioVisualizer.tsx ← PORT (from features/live/components/) -│ │ ├── MessageFeed.tsx ← CREATE (composite of features/messages) -│ │ ├── MascotChat.tsx ← CREATE (from MascotChat feature) -│ │ ├── ThemeToggle.tsx ← CREATE (from hooks/useTheme) -│ │ ├── Particles.tsx ← PORT (from widgets/particles/) -│ │ ├── ActiveSpeakers.tsx ← CREATE (from features/live/components/) -│ │ ├── NowPlaying.tsx ← CREATE (from features/live/components/) -│ │ ├── RecordingsList.tsx ← CREATE (from RecordingsSubPanel) -│ │ └── SettingsForm.tsx ← CREATE (from features/settings) -│ │ -│ ├── stores/ ← Zustand -│ │ ├── ui-store.ts ← CREATE -│ │ ├── voice-store.ts ← CREATE -│ │ └── message-store.ts ← CREATE -│ │ -│ ├── shared/ -│ │ ├── api/client.ts ← PORT (existing) -│ │ ├── ws/socket.ts ← PORT (existing) -│ │ ├── hooks/ -│ │ │ ├── useMessages.ts ← PORT (from features/messages/hooks/) -│ │ │ ├── useMediaControl.ts ← PORT (from features/live/hooks/) -│ │ │ └── useVoiceControl.ts ← PORT (from features/live/hooks/) -│ │ └── lib/utils.ts ← PORT (existing) -│ │ -│ └── layouts/ -│ └── DashboardLayout.tsx ← DELETE (replaced by Astro layout) -│ -├── src/App.tsx ← DELETE (logic distributed to islands) -├── src/App.client.tsx ← DELETE (no longer needed) -├── src/entities/ ← DELETE (types move to @bete/shared or inline) -├── src/features/ ← DELETE (features become pages + islands) -├── src/hooks/ ← DELETE (distributed to stores + islands) -├── src/widgets/ ← DELETE (replaced by Astro layout + components) -├── src/styles.css ← DELETE (replaced by base.css) -└── src/shared/ui/ ← DELETE (replaced by Astro components) -``` - ---- - -## Phase 1: Foundation 🔧 - -### Task 1.1: Rewrite CSS Architecture - -**Files:** -- Create: `services/frontend/src/styles/base.css` -- Delete: `services/frontend/src/styles.css` - -**Interfaces:** -- Consumes: Design tokens from `design/core/01-color-system.md`, `design/core/02-typography.md`, `design/core/03-spatial-system.md`, `design/core/04-motion-system.md`, `design/system/15-theme-architecture.md` -- Produces: `base.css` with all design tokens, Tailwind `@theme`, glass utilities, animations - -**CSS Variables Context:** - -``` -Color tokens: --clr-surface-base, --clr-primary, --clr-text, etc. (all OKLCH) -Spacing tokens: --sp-3 = 16px (via 4px grid unit) -Radius tokens: --rd-md = 8px, --rd-lg = 12px, --rd-xl = 16px -Shadow tokens: --sh-card, --sh-hover, --sh-modal -Z-index tokens: --z-header = 30, --z-sidebar = 40, --z-modal = 60, --z-mascot = 100 -Timing tokens: --dur-fast = 150ms, --dur-normal = 250ms, --dur-slow = 350ms -Easing tokens: --ease-out = cubic-bezier(0.16, 1, 0.3, 1) -Font tokens: --fs-base = clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem) -``` - -- [ ] **Step 1: Create the complete `base.css` with design tokens** - -```css -@import "tailwindcss"; - -/* ── Theme: Dark (default) ─────────────────────────────────── */ -:root { - /* 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-on-primary: oklch(0.97 0.005 286); - --clr-text-inverse: oklch(0.11 0.010 286); - - /* Brand (Hue 255 — Aetherial Blue) */ - --clr-primary: oklch(0.62 0.150 255); - --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); - --clr-primary-bg: oklch(0.25 0.060 255 / 0.20); - - /* 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); - - /* Severity */ - --clr-severity-safe: oklch(0.60 0.130 145); - --clr-severity-low: oklch(0.70 0.120 75); - --clr-severity-medium: oklch(0.65 0.150 50); - --clr-severity-high: oklch(0.60 0.150 30); - --clr-severity-critical: oklch(0.55 0.165 25); - - /* Glass */ - --glass-bg: oklch(0.15 0.015 286 / 0.60); - --glass-border: oklch(0.25 0.030 286 / 0.20); - - /* Spacing (4px grid) */ - --sp-0: 0px; - --sp-0-5: 4px; - --sp-1: 8px; - --sp-2: 12px; - --sp-3: 16px; - --sp-4: 24px; - --sp-5: 32px; - --sp-6: 48px; - --sp-7: 64px; - - /* Radius */ - --rd-xs: 4px; - --rd-sm: 6px; - --rd-md: 8px; - --rd-lg: 12px; - --rd-xl: 16px; - - /* Shadow */ - --sh-card: 0 2px 8px rgba(0, 0, 0, 0.3); - --sh-hover: 0 4px 16px rgba(0, 0, 0, 0.4); - --sh-modal: 0 16px 48px rgba(0, 0, 0, 0.6); - - /* Z-index */ - --z-header: 30; - --z-sidebar: 40; - --z-overlay: 50; - --z-modal: 60; - --z-toast: 80; - --z-mascot: 100; - - /* Duration */ - --dur-fast: 150ms; - --dur-normal: 250ms; - --dur-slow: 350ms; - - /* Easing */ - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); - --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); -} - -/* ── Theme: Light ──────────────────────────────────────── */ -[data-theme="light"] { - --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); - --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-primary: oklch(0.55 0.175 255); - --clr-interactive-hover: oklch(0.90 0.005 286); - --clr-interactive-active: oklch(0.85 0.008 286); - --glass-bg: oklch(0.97 0.002 286 / 0.50); - --glass-border: oklch(0.87 0.005 286 / 0.30); - --sh-card: 0 2px 8px rgba(0, 0, 0, 0.08); - --sh-hover: 0 4px 16px rgba(0, 0, 0, 0.12); - --sh-modal: 0 16px 48px rgba(0, 0, 0, 0.12); -} - -/* ── Tailwind 4 @theme ──────────────────────────────────── */ -@theme { - --font-sans: 'Outfit', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; - - --color-background: var(--clr-surface-base); - --color-foreground: var(--clr-text); - --color-card: var(--clr-surface-elevated); - --color-card-foreground: var(--clr-text); - --color-border: var(--clr-border); - --color-primary: var(--clr-primary); - --color-primary-foreground: var(--clr-text-on-primary); - --color-muted: var(--clr-surface-elevated); - --color-muted-foreground: var(--clr-text-secondary); - --color-destructive: var(--clr-severity-critical); - --color-destructive-foreground: white; - --color-severity-safe: var(--clr-severity-safe); - --color-severity-low: var(--clr-severity-low); - --color-severity-medium: var(--clr-severity-medium); - --color-severity-high: var(--clr-severity-high); - --color-severity-critical: var(--clr-severity-critical); - - --radius-xs: var(--rd-xs); - --radius-sm: var(--rd-sm); - --radius-md: var(--rd-md); - --radius-lg: var(--rd-lg); - --radius-xl: var(--rd-xl); - - --spacing: 4px; - - --animate-fade-in: fadeIn var(--dur-normal) var(--ease-out); - --animate-fade-in-up: fadeInUp var(--dur-slow) var(--ease-out); - --animate-shimmer: shimmer 1.5s ease-in-out infinite; - --animate-scale-in: scaleIn var(--dur-slow) var(--ease-out-quint); - --animate-glow-pulse: glowPulse 2s ease-in-out infinite; -} - -/* ── Keyframes ───────────────────────────────────────────── */ -@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } -@keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } -@keyframes shimmer { 0% { background-position: 200% 0; } to { background-position: -200% 0; } } -@keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } -@keyframes glowPulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } -@keyframes slideUp { from { transform: translateY(10px); opacity: 0; } to { opacity: 1; } } -@keyframes slideDown { from { transform: translateY(-10px); opacity: 0; } to { opacity: 1; } } - -/* ── Base layer ──────────────────────────────────────────── */ -@layer base { - body { - background-color: var(--clr-surface-base); - color: var(--clr-text); - font-family: var(--font-sans); - -webkit-font-smoothing: antialiased; - } - - * { - border-color: var(--clr-border); - transition: background-color var(--dur-normal) var(--ease-out), - color var(--dur-normal) var(--ease-out), - border-color var(--dur-normal) var(--ease-out); - } - - :focus-visible { - outline: 2px solid var(--clr-primary); - outline-offset: 2px; - } - - ::-webkit-scrollbar { width: 6px; height: 6px; } - ::-webkit-scrollbar-track { background: var(--clr-surface-sunken); } - ::-webkit-scrollbar-thumb { background: var(--clr-border); border-radius: 999px; } -} - -/* ── Components layer ─────────────────────────────────────── */ -@layer components { - .glass { - background: var(--glass-bg); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border: 1px solid var(--glass-border); - } - - .glass-strong { - background: oklch(from var(--clr-surface-overlay) l c h / 0.85); - backdrop-filter: blur(24px); - } - - .gradient-text { - background: linear-gradient(135deg, oklch(from var(--clr-primary) l c h), oklch(from var(--clr-primary-400) l c h)); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} -``` - -- [ ] **Step 2: Delete old `styles.css` and verify build** - -Run: -```bash -rm services/frontend/src/styles.css -cd services/frontend -pnpm build 2>&1 | tail -20 -``` -Expected: Build succeeds, new CSS is compiled. - -- [ ] **Step 3: Commit** - -```bash -git add services/frontend/src/styles/base.css services/frontend/src/styles.css -git rm services/frontend/src/styles.css -git commit -m "feat(frontend): implement design system CSS architecture with OKLCH tokens" -``` - ---- - -### Task 1.2: Create Astro UI Components - -**Files:** -- Create: `services/frontend/src/components/ui/Button.astro` -- Create: `services/frontend/src/components/ui/Badge.astro` -- Create: `services/frontend/src/components/ui/SeverityBadge.astro` -- Create: `services/frontend/src/components/ui/Card.astro` -- Create: `services/frontend/src/components/ui/Skeleton.astro` -- Create: `services/frontend/src/components/ui/Spinner.astro` - -**Interfaces:** -- Consumes: CSS variables from Task 1.1 -- Produces: Reusable Astro components usable in all page layouts - -- [ ] **Step 1: Create `Button.astro`** - -```astro ---- -export interface Props { - variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost'; - size?: 'sm' | 'default' | 'lg' | 'icon'; - disabled?: boolean; - href?: string; - class?: string; -} - -const { variant = 'primary', size = 'default', disabled = false, href, class: className = '' } = Astro.props; - -const baseClass = `btn btn--${variant} btn--${size}${className ? ' ' + className : ''}`; ---- - -{ - href ? ( - - - - ) : ( - - ) -} - - -``` - -- [ ] **Step 2: Create `Badge.astro`** - -```astro ---- -export interface Props { - variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; - size?: 'sm' | 'default'; - dot?: boolean; - class?: string; -} - -const { variant = 'default', size = 'default', dot = false, class: className = '' } = Astro.props; ---- - - - {dot && - - -``` - -- [ ] **Step 3: Create `SeverityBadge.astro`** - -```astro ---- -export interface Props { - severity: 'safe' | 'low' | 'medium' | 'high' | 'critical'; - class?: string; -} - -const { severity, class: className = '' } = Astro.props; - -const ICONS: Record = { - safe: '✅', - low: '⚠️', - medium: '🔶', - high: '🚫', - critical: '🔴', -}; ---- - - - - {severity.toUpperCase()} - - - -``` - -- [ ] **Step 4: Create `Card.astro`** - -```astro ---- -export interface Props { - variant?: 'default' | 'elevated' | 'glass' | 'interactive'; - padding?: 'sm' | 'default' | 'lg' | 'none'; - class?: string; -} - -const { variant = 'default', padding = 'default', class: className = '' } = Astro.props; ---- - -
- -
- - -``` - -- [ ] **Step 5: Create `Skeleton.astro`** and `Spinner.astro` - -```astro ---- -// Skeleton.astro -export interface Props { - variant?: 'text' | 'card' | 'circle' | 'rect'; - width?: string; - height?: string; - class?: string; -} -const { variant = 'text', width, height, class: className = '' } = Astro.props; ---- -
- -``` - -```astro ---- -// Spinner.astro -export interface Props { size?: 'sm' | 'default' | 'lg'; class?: string; } -const { size = 'default', class: className = '' } = Astro.props; ---- -
- Loading... -
- -``` - -- [ ] **Step 6: Verify components compile** - -Run: -```bash -cd services/frontend && pnpm build 2>&1 | tail -10 -``` -Expected: Build succeeds, zero errors. - -- [ ] **Step 7: Commit** - -```bash -git add services/frontend/src/components/ui/ -git commit -m "feat(frontend): add Astro UI components (Button, Badge, Card, Skeleton, Spinner)" -``` - ---- - -### Task 1.3: Create Layout Components (Sidebar, Header, States) - -**Files:** -- Create: `services/frontend/src/components/sidebar/NavItem.astro` -- Create: `services/frontend/src/components/sidebar/Sidebar.astro` -- Create: `services/frontend/src/components/header/Header.astro` -- Create: `services/frontend/src/components/states/EmptyState.astro` -- Create: `services/frontend/src/components/states/ErrorState.astro` -- Create: `services/frontend/src/components/states/LoadingSkeleton.astro` - -**Interfaces:** -- Consumes: UI components from Task 1.2 -- Produces: Layout shell components used by DashboardLayout - -- [ ] **Step 1: Create `NavItem.astro`** - -```astro ---- -export interface Props { - href: string; - icon: string; // Lucide icon name - label: string; - active?: boolean; - collapsed?: boolean; - notificationCount?: number; -} -const { href, icon, label, active = false, collapsed = false, notificationCount = 0 } = Astro.props; ---- - - - - {!collapsed && ( - <> - {label} - {notificationCount > 0 && ( - {notificationCount > 99 ? '99+' : notificationCount} - )} - - )} - - - -``` - -- [ ] **Step 2: Create `Sidebar.astro`** - -```astro ---- -import NavItem from './NavItem.astro'; - -export interface Props { - collapsed?: boolean; - activeTab?: string; - notificationCount?: number; - class?: string; -} - -const { collapsed = false, activeTab = 'live', notificationCount = 0, class: className = '' } = Astro.props; - -const NAV_ITEMS = [ - { id: 'live', icon: 'radio', href: '/live', label: 'Live' }, - { id: 'messages', icon: 'message-square', href: '/messages', label: 'Messages' }, - { id: 'recordings', icon: 'mic', href: '/recordings', label: 'Recordings' }, - { id: 'settings', icon: 'settings', href: '/settings', label: 'Settings' }, -]; ---- - - - - -``` - -- [ ] **Step 3: Create `Header.astro`** - -```astro ---- -export interface Props { - title?: string; - class?: string; -} -const { title = 'Dashboard', class: className = '' } = Astro.props; ---- - -
-
-

{title}

-
-
- -
-
- - -``` - -- [ ] **Step 4: Create state components** - -`EmptyState.astro`: -```astro ---- -export interface Props { - icon?: string; - title: string; - description?: string; - class?: string; -} -const { icon, title, description, class: className = '' } = Astro.props; ---- -
- {icon && - -``` - -`ErrorState.astro`: -```astro ---- -export interface Props { - message?: string; - class?: string; -} -const { message = 'Something went wrong', class: className = '' } = Astro.props; ---- -
-
!
-

{message}

- -
- -``` - -`LoadingSkeleton.astro`: -```astro ---- -import Skeleton from '../ui/Skeleton.astro'; - -export interface Props { - variant?: 'card' | 'list' | 'detail'; - count?: number; - class?: string; -} -const { variant = 'card', count = 3, class: className = '' } = Astro.props; ---- -
- {Array.from({ length: count }).map(() => ( - variant === 'list' ? ( -
- -
- - -
-
- ) : ( -
- - - -
- ) - ))} -
-``` - -- [ ] **Step 5: Commit** - -```bash -git add services/frontend/src/components/ -git commit -m "feat(frontend): add Astro layout components (Sidebar, Header, states)" -``` - ---- - -### Task 1.4: Update BaseLayout and Create AuthLayout + Pages - -**Files:** -- Modify: `services/frontend/src/layouts/BaseLayout.astro` -- Create: `services/frontend/src/layouts/AuthLayout.astro` -- Create: `services/frontend/src/pages/login.astro` -- Create: `services/frontend/src/pages/404.astro` -- Modify: `services/frontend/src/pages/index.astro` - -**Interfaces:** -- Consumes: No prior tasks (independent) -- Produces: Working routes and layout shell - -- [ ] **Step 1: Update `BaseLayout.astro`** - -```astro ---- -const FONT_HREF = - "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"; -const FONT_MONO_HREF = - "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"; - -export interface Props { - title?: string; - description?: string; -} -const { title = 'IMPHNEN — Discord Moderation', description = 'Real-time Discord AI Moderation & Voice Recording Dashboard' } = Astro.props; ---- - - - - - - - {title} - - - - - - - - - - - - - -``` - -- [ ] **Step 2: Create `AuthLayout.astro`** - -```astro ---- -import BaseLayout from './BaseLayout.astro'; - -interface Props { title?: string; } -const { title } = Astro.props; ---- - - -
-
- -
-
-
- - -``` - -- [ ] **Step 3: Create `login.astro`** - -```astro ---- -import AuthLayout from '../layouts/AuthLayout.astro'; ---- - - -
-

BETE

-

Discord Moderation Dashboard

-
- -
-``` - -- [ ] **Step 4: Create `404.astro`** - -```astro ---- -import BaseLayout from '../layouts/BaseLayout.astro'; ---- - - -
-
-

404

-

The page you're looking for doesn't exist.

- Go Home -
-
-
-``` - -- [ ] **Step 5: Update `index.astro`** — 301 redirect to /live - -```astro ---- -// Redirect to live view (default tab) -return Astro.redirect('/live', 301); ---- -``` - -- [ ] **Step 6: Commit** - -```bash -git add services/frontend/src/layouts/ services/frontend/src/pages/ -git commit -m "feat(frontend): update BaseLayout, add AuthLayout, login, 404 pages" -``` - ---- - -## Phase 2: Zustand Stores + Shared Utilities - -### Task 2.1: Create Zustand Stores - -**Files:** -- Create: `services/frontend/src/stores/ui-store.ts` -- Create: `services/frontend/src/stores/voice-store.ts` -- Create: `services/frontend/src/stores/message-store.ts` - -**Interfaces:** -- Consumes: Types from `@bete/shared` -- Produces: `useUIStore`, `useVoiceStore`, `useMessageStore` for islands - -- [ ] **Step 1: Create `ui-store.ts`** - -```typescript -import { create } from 'zustand'; - -export type DashboardTab = 'live' | 'messages' | 'recordings' | 'settings' | 'dashboard'; - -interface UIState { - sidebarCollapsed: boolean; - activeTab: DashboardTab; - theme: 'dark' | 'light' | 'system'; - selectedVoiceGuild: string; - selectedVoiceChannel: string; - toggleSidebar: () => void; - setActiveTab: (tab: DashboardTab) => void; - setTheme: (theme: 'dark' | 'light' | 'system') => void; -} - -export const useUIStore = create((set) => ({ - sidebarCollapsed: false, - activeTab: 'live', - theme: 'dark', - selectedVoiceGuild: '', - selectedVoiceChannel: '', - toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), - setActiveTab: (tab) => set({ activeTab: tab }), - setTheme: (theme) => set({ theme }), -})); -``` - -- [ ] **Step 2: Create `voice-store.ts`** - -```typescript -import { create } from 'zustand'; -import type { ActiveSpeaker, VoiceStatus } from '@bete/shared/types'; - -interface VoiceState { - connected: boolean; - status: VoiceStatus | null; - activeSpeakers: ActiveSpeaker[]; - guildId: string; - channelId: string; - setConnected: (connected: boolean) => void; - setStatus: (status: VoiceStatus) => void; - setActiveSpeakers: (speakers: ActiveSpeaker[]) => void; - updateSpeaker: (speaker: Partial & { userId: string }) => void; - setGuildChannel: (guildId: string, channelId: string) => void; -} - -export const useVoiceStore = create((set) => ({ - connected: false, - status: null, - activeSpeakers: [], - guildId: '', - channelId: '', - setConnected: (connected) => set({ connected }), - setStatus: (status) => set({ status }), - setActiveSpeakers: (activeSpeakers) => set({ activeSpeakers }), - updateSpeaker: (speaker) => - set((s) => { - const idx = s.activeSpeakers.findIndex((sp) => sp.userId === speaker.userId); - if (idx >= 0) { - const next = [...s.activeSpeakers]; - next[idx] = { ...next[idx], ...speaker, heardAt: Date.now() }; - return { activeSpeakers: next }; - } - return { activeSpeakers: [...s.activeSpeakers, { ...speaker, heardAt: Date.now() } as ActiveSpeaker] }; - }), - setGuildChannel: (guildId, channelId) => set({ guildId, channelId }), -})); -``` - -- [ ] **Step 3: Create `message-store.ts`** - -```typescript -import { create } from 'zustand'; -import type { MessageRecord } from '@bete/shared/types'; - -interface MessageState { - messages: MessageRecord[]; - setMessages: (msgs: MessageRecord[] | ((prev: MessageRecord[]) => MessageRecord[])) => void; - prependMessage: (msg: MessageRecord) => void; - updateMessage: (id: string, updates: Partial) => void; - removeMessage: (id: string) => void; -} - -export const useMessageStore = create((set) => ({ - messages: [], - setMessages: (msgs) => - set((s) => ({ - messages: typeof msgs === 'function' ? msgs(s.messages) : msgs, - })), - prependMessage: (msg) => - set((s) => ({ - messages: s.messages.some((m) => m.id === msg.id) ? s.messages : [msg, ...s.messages], - })), - updateMessage: (id, updates) => - set((s) => ({ - messages: s.messages.map((m) => (m.id === id ? { ...m, ...updates } : m)), - })), - removeMessage: (id) => - set((s) => ({ - messages: s.messages.map((m) => (m.id === id ? { ...m, type: 'deleted' as const } : m)), - })), -})); -``` - -- [ ] **Step 4: Commit** - -```bash -git add services/frontend/src/stores/ -git commit -m "feat(frontend): add Zustand stores (ui, voice, message)" -``` - ---- - -### Task 2.2: Create WebSocket Bridge + AuthGuard Island - -**Files:** -- Port: `services/frontend/src/shared/ws/socket.ts` (exists, port as-is with minor cleanup) -- Create: `services/frontend/src/islands/AuthGuard.tsx` -- Create: `services/frontend/src/islands/ThemeToggle.tsx` - -**Interfaces:** -- Consumes: `useUIStore` from Task 2.1, existing `socket.ts` -- Produces: Islands mounted on `/login` and layout - -- [ ] **Step 1: Verify `socket.ts` is clean and working** -Read existing file and confirm SocketManager class is exported properly. - -Run: -```bash -head -5 services/frontend/src/shared/ws/socket.ts -``` -Expected: Contains `export class SocketManager` or similar. - -- [ ] **Step 2: Create `AuthGuard.tsx` island** - -```tsx -import { useEffect, useState } from 'react'; -import { getSessionToken, getAdminPassword, clearSessionToken } from '../shared/api/client'; - -interface AuthGuardProps { - children?: React.ReactNode; -} - -export default function AuthGuard({ children }: AuthGuardProps) { - const [authenticated, setAuthenticated] = useState(null); - - useEffect(() => { - const token = getSessionToken(); - const password = getAdminPassword(); - if (token || password) { - setAuthenticated(true); - } else { - setAuthenticated(false); - } - }, []); - - if (authenticated === null) { - return ( -
-
-
- ); - } - - if (!authenticated) { - return ( -
-

Please enter admin password to continue.

- {/* Login form with password input */} -
{ - e.preventDefault(); - const form = e.target as HTMLFormElement; - const input = form.elements.namedItem('password') as HTMLInputElement; - localStorage.setItem('admin-password', input.value); - setAuthenticated(true); - }} className="space-y-3"> - - -
-
- ); - } - - return <>{children}; -} -``` - -- [ ] **Step 3: Create `ThemeToggle.tsx` island** - -```tsx -import { useEffect, useState } from 'react'; - -export default function ThemeToggle() { - const [theme, setTheme] = useState<'dark' | 'light'>('dark'); - - useEffect(() => { - const stored = localStorage.getItem('bete-dashboard-theme'); - if (stored === 'light' || stored === 'dark') setTheme(stored); - }, []); - - const toggle = () => { - const next = theme === 'dark' ? 'light' : 'dark'; - setTheme(next); - localStorage.setItem('bete-dashboard-theme', next); - document.documentElement.setAttribute('data-theme', next); - document.documentElement.classList.toggle('dark', next === 'dark'); - }; - - return ( - - ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add services/frontend/src/islands/AuthGuard.tsx services/frontend/src/islands/ThemeToggle.tsx -git commit -m "feat(frontend): add AuthGuard and ThemeToggle islands" -``` - ---- - -## Phase 3: Messages Feature - -### Task 3.1: MessageFeed Island - -**Files:** -- Create: `services/frontend/src/islands/MessageFeed.tsx` -- Port: `services/frontend/src/shared/hooks/useMessages.ts` from `features/messages/hooks/useMessages.ts` -- Port: `services/frontend/src/shared/api/client.ts` (exists) - -**Interfaces:** -- Consumes: `useMessageStore` from Task 2.1, `socket.ts` from Task 2.2 -- Produces: `MessageFeed` React island with loading/error/empty states - -- [ ] **Step 1: Port `useMessages` hook to `src/shared/hooks/useMessages.ts`** - -```typescript -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { MessageRecord } from '@bete/shared/types'; -import { getMessages, reanalyzeMessage } from '../api/client'; -import { useMessageStore } from '../../stores/message-store'; - -export function mergeMessages( - existing: MessageRecord[], - incoming: MessageRecord[], -): MessageRecord[] { - const map = new Map(existing.map((m) => [m.id, m])); - for (const msg of incoming) { - map.set(msg.id, msg); - } - return Array.from(map.values()).sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); -} - -export function useMessages() { - const { messages, setMessages, prependMessage } = useMessageStore(); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); - const cursorRef = useRef(undefined); - const guildRef = useRef(''); - - const fetchMessages = useCallback(async (guildId?: string) => { - if (!guildId) return; - guildRef.current = guildId; - setLoading(true); - try { - const res = await getMessages({ guildId, limit: 50 }); - setMessages(res.data ?? []); - cursorRef.current = res.nextCursor; - setHasMore(!!res.nextCursor); - } catch (err) { - console.error('Failed to fetch messages', err); - } finally { - setLoading(false); - } - }, [setMessages]); - - const loadMore = useCallback(async () => { - if (!hasMore || loadingMore || !guildRef.current) return; - setLoadingMore(true); - try { - const res = await getMessages({ guildId: guildRef.current, cursor: cursorRef.current, limit: 50 }); - setMessages((prev) => mergeMessages(prev, res.data ?? [])); - cursorRef.current = res.nextCursor; - setHasMore(!!res.nextCursor); - } catch (err) { - console.error('Failed to load more', err); - } finally { - setLoadingMore(false); - } - }, [hasMore, loadingMore, setMessages]); - - const reanalyze = useCallback(async (messageId: string) => { - try { - await reanalyzeMessage(messageId); - } catch (err) { - console.error('Reanalyze failed', err); - } - }, []); - - return { messages, loading, loadingMore, hasMore, fetchMessages, loadMore, reanalyze }; -} -``` - -- [ ] **Step 2: Create `MessageFeed.tsx` island** - -```tsx -import { useEffect, useRef, useCallback } from 'react'; -import { useMessages } from '../shared/hooks/useMessages'; -import { useDashboardSocket } from '../shared/ws/socket'; -import { useMessageStore } from '../stores/message-store'; - -interface MessageFeedProps { - guildId?: string; -} - -export default function MessageFeed({ guildId }: MessageFeedProps) { - const { messages, loading, loadingMore, hasMore, fetchMessages, loadMore, reanalyze } = useMessages(); - const { setMessages, updateMessage } = useMessageStore(); - const containerRef = useRef(null); - const scrollRef = useRef(null); - - // WebSocket bridge - useDashboardSocket({ - onMessageCreated: (msg) => { - useMessageStore.getState().prependMessage(msg); - }, - onMessageUpdated: (msg) => { - useMessageStore.getState().updateMessage(msg.id, msg); - }, - onMessageDeleted: (msg) => { - useMessageStore.getState().removeMessage(msg.id); - }, - onMessageAnalyzed: (msg) => { - useMessageStore.getState().updateMessage(msg.id, msg); - }, - }); - - useEffect(() => { - if (guildId) fetchMessages(guildId); - }, [guildId, fetchMessages]); - - // Infinite scroll - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 200 && hasMore && !loadingMore) { - loadMore(); - } - }, [hasMore, loadingMore, loadMore]); - - // Loading state - if (loading) { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ); - } - - // Empty state - if (!loading && messages.length === 0) { - return ( -
-
💬
-

No messages yet

-

Messages will appear here once captured.

-
- ); - } - - // Error state - if (messages.length === 0) { - return ( -
-
!
-

Failed to load messages

- -
- ); - } - - // Success state - return ( -
- {messages.map((msg) => ( -
-
- -
-
- {msg.username} - {msg.ai_severity && ( - - {msg.ai_severity.toUpperCase()} - - )} -
-

{msg.content || msg.edited_content}

-
- -
-
-
-
- ))} - {loadingMore && ( -
-
-
- )} -
- ); -} -``` - -- [ ] **Step 3: Create `messages.astro` page** - -```astro ---- -import BaseLayout from '../layouts/BaseLayout.astro'; -import Sidebar from '../components/sidebar/Sidebar.astro'; -import Header from '../components/header/Header.astro'; -import ThemeToggle from '../islands/ThemeToggle.tsx'; -import MessageFeed from '../islands/MessageFeed.tsx'; ---- - - -
- -
-
- -
-
- -
-
-
-
- - -``` - -- [ ] **Step 4: Commit** - -```bash -git add services/frontend/src/islands/MessageFeed.tsx services/frontend/src/pages/messages.astro services/frontend/src/shared/hooks/useMessages.ts -git commit -m "feat(frontend): add MessageFeed island and /messages page" -``` - ---- - -## Phase 4: Live / Voice Feature - -### Task 4.1: Voice Islands (Controls, Speakers, Visualizer) - -**Files:** -- Create: `services/frontend/src/islands/VoiceControls.tsx` -- Create: `services/frontend/src/islands/ActiveSpeakers.tsx` -- Create: `services/frontend/src/islands/AudioVisualizer.tsx` -- Create: `services/frontend/src/islands/NowPlaying.tsx` -- Port: `services/frontend/src/shared/hooks/useVoiceControl.ts` from `features/live/hooks/useVoiceControl.ts` -- Port: `services/frontend/src/shared/hooks/useMediaControl.ts` from `features/live/hooks/useMediaControl.ts` - -**Interfaces:** -- Consumes: `useVoiceStore` from Task 2.1, `useDashboardSocket` from `socket.ts` -- Produces: Voice islands mounted on `/live` page - -- [ ] **Step 1: Create `VoiceControls.tsx`** - -```tsx -import { useEffect, useState } from 'react'; -import { useVoiceStore } from '../stores/voice-store'; -import { voiceConnect, voiceDisconnect, getStatus } from '../shared/api/client'; - -interface VoiceControlsProps { - guilds: Array<{ id: string; name: string }>; - voiceChannels: Array<{ id: string; name: string }>; -} - -export default function VoiceControls({ guilds, voiceChannels }: VoiceControlsProps) { - const { guildId, channelId, connected, setConnected, setGuildChannel } = useVoiceStore(); - const [loading, setLoading] = useState(false); - - const handleConnect = async () => { - if (!guildId || !channelId) return; - setLoading(true); - try { - await voiceConnect(guildId, channelId); - setConnected(true); - } catch (err) { - console.error('Failed to connect', err); - } finally { - setLoading(false); - } - }; - - const handleDisconnect = async () => { - setLoading(true); - try { - await voiceDisconnect(); - setConnected(false); - } catch (err) { - console.error('Failed to disconnect', err); - } finally { - setLoading(false); - } - }; - - return ( -
- {/* Guild select */} - - - {/* Channel select */} - - - {/* Connect/Disconnect */} - {!connected ? ( - - ) : ( - - )} -
- ); -} -``` - -- [ ] **Step 2: Create `ActiveSpeakers.tsx`** - -```tsx -import { useVoiceStore } from '../stores/voice-store'; - -export default function ActiveSpeakers() { - const activeSpeakers = useVoiceStore((s) => s.activeSpeakers); - - if (activeSpeakers.length === 0) { - return ( -
-

No active speakers

-
- ); - } - - return ( -
    - {activeSpeakers.map((speaker) => ( -
  • - - {speaker.username} - {speaker.speaking && ( -
    - {Array.from({ length: 4 }).map((_, i) => ( -
    - ))} -
    - )} -
  • - ))} -
- ); -} -``` - -- [ ] **Step 3: Create `AudioVisualizer.tsx`** (canvas-based) - -```tsx -import { useEffect, useRef } from 'react'; - -interface AudioVisualizerProps { - barCount?: number; - height?: number; -} - -export default function AudioVisualizer({ barCount = 48, height = 32 }: AudioVisualizerProps) { - const canvasRef = useRef(null); - const animRef = useRef(0); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Dummy frequency data — in real app, this comes from WebSocket - const draw = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - const barWidth = canvas.width / barCount; - for (let i = 0; i < barCount; i++) { - const h = Math.random() * height * 0.8; - ctx.fillStyle = `oklch(0.62 0.15 255 / ${0.3 + Math.random() * 0.7})`; - ctx.fillRect(i * barWidth, height - h, barWidth - 1, h); - } - animRef.current = requestAnimationFrame(draw); - }; - draw(); - - return () => cancelAnimationFrame(animRef.current); - }, [barCount, height]); - - return ( - - ); -} -``` - -- [ ] **Step 4: Create `live.astro` page** - -```astro ---- -import BaseLayout from '../layouts/BaseLayout.astro'; -import Sidebar from '../components/sidebar/Sidebar.astro'; -import Header from '../components/header/Header.astro'; -import Card from '../components/ui/Card.astro'; -import ThemeToggle from '../islands/ThemeToggle.tsx'; -import VoiceControls from '../islands/VoiceControls.tsx'; -import ActiveSpeakers from '../islands/ActiveSpeakers.tsx'; -import AudioVisualizer from '../islands/AudioVisualizer.tsx'; -import NowPlaying from '../islands/NowPlaying.tsx'; -import Particles from '../islands/Particles.tsx'; -import MascotChat from '../islands/MascotChat.tsx'; ---- - - -
- -
-
- -
-
-
-
- -

Voice Connection

- -
- - -

Now Playing

- -
-
- -
-
-
- - -
-
- - -``` - -- [ ] **Step 5: Commit** - -```bash -git add services/frontend/src/islands/VoiceControls.tsx services/frontend/src/islands/ActiveSpeakers.tsx services/frontend/src/islands/AudioVisualizer.tsx services/frontend/src/islands/NowPlaying.tsx services/frontend/src/pages/live.astro -git commit -m "feat(frontend): add voice islands and /live page" -``` - ---- - -### Task 4.2: Recordings Page and Remaining Pages - -**Files:** -- Create: `services/frontend/src/pages/recordings.astro` -- Create: `services/frontend/src/islands/RecordingsList.tsx` -- Create: `services/frontend/src/pages/settings.astro` -- Create: `services/frontend/src/islands/SettingsForm.tsx` - -- [ ] **Step 1: Create `RecordingsList.tsx` island** - -```tsx -import { useEffect, useState } from 'react'; -import { getRecordings } from '../shared/api/client'; - -interface Recording { - id: string; - username: string; - channel_name: string; - created_at: string; - download_url?: string; -} - -export default function RecordingsList() { - const [recordings, setRecordings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - getRecordings() - .then((res) => { - setRecordings(res.data ?? []); - setLoading(false); - }) - .catch((err) => { - setError(err.message ?? 'Failed to load recordings'); - setLoading(false); - }); - }, []); - - if (loading) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- ); - } - - if (error) { - return ( -
-
!
-

{error}

-
- ); - } - - if (recordings.length === 0) { - return ( -
-

No recordings

-

Join a voice channel to start recording.

-
- ); - } - - return ( -
- {recordings.map((rec) => ( -
-
-
-

{rec.username}

-

{rec.channel_name} · {new Date(rec.created_at).toLocaleString()}

-
- {rec.download_url && ( - - Download - - )} -
-
- ))} -
- ); -} -``` - -- [ ] **Step 2: Create `recordings.astro` page** - -```astro ---- -import BaseLayout from '../layouts/BaseLayout.astro'; -import Sidebar from '../components/sidebar/Sidebar.astro'; -import Header from '../components/header/Header.astro'; -import Card from '../components/ui/Card.astro'; -import RecordingsList from '../islands/RecordingsList.tsx'; -import ThemeToggle from '../islands/ThemeToggle.tsx'; ---- - - -
- -
-
- -
-
- - - -
-
-
-
-``` - -- [ ] **Step 3: Create `SettingsForm.tsx` island and `settings.astro` page** - -```tsx -// settings.astro ---- -import BaseLayout from '../layouts/BaseLayout.astro'; -import Sidebar from '../components/sidebar/Sidebar.astro'; -import Header from '../components/header/Header.astro'; -import Card from '../components/ui/Card.astro'; -import ThemeToggle from '../islands/ThemeToggle.tsx'; -import SettingsForm from '../islands/SettingsForm.tsx'; ---- - - -
- -
-
- -
-
- - - -
-
-
-
-``` - -```tsx -// SettingsForm.tsx -import { useState, useEffect } from 'react'; - -export default function SettingsForm() { - const [theme, setThemeState] = useState<'dark' | 'light' | 'system'>('dark'); - - useEffect(() => { - const stored = localStorage.getItem('bete-dashboard-theme') as 'dark' | 'light' | 'system' | null; - if (stored) setThemeState(stored); - }, []); - - const handleThemeChange = (mode: 'dark' | 'light' | 'system') => { - setThemeState(mode); - localStorage.setItem('bete-dashboard-theme', mode); - let resolved: string; - if (mode === 'system') { - resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } else { - resolved = mode; - } - document.documentElement.setAttribute('data-theme', resolved); - document.documentElement.classList.toggle('dark', resolved === 'dark'); - }; - - return ( -
-

Appearance

-
- -
- {(['dark', 'light', 'system'] as const).map((mode) => ( - - ))} -
-
-
- ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add services/frontend/src/pages/recordings.astro services/frontend/src/islands/RecordingsList.tsx services/frontend/src/pages/settings.astro services/frontend/src/islands/SettingsForm.tsx -git commit -m "feat(frontend): add Recordings and Settings pages with islands" -``` - ---- - -## Phase 5: Polish (Deferred Islands + Cleanup) - -### Task 5.1: Deferred Islands (Particles, Mascot, Recordings) - -**Files:** -- Port: `services/frontend/src/islands/Particles.tsx` from `widgets/particles/` -- Create: `services/frontend/src/islands/MascotChat.tsx` - -- [ ] **Step 1: Create `Particles.tsx`** (Three.js background, deferred) - -```tsx -import { useEffect, useRef } from 'react'; - -export default function Particles() { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - - const particles: Array<{ x: number; y: number; vx: number; vy: number; r: number }> = []; - for (let i = 0; i < 30; i++) { - particles.push({ - x: Math.random() * canvas.width, - y: Math.random() * canvas.height, - vx: (Math.random() - 0.5) * 0.5, - vy: (Math.random() - 0.5) * 0.5, - r: Math.random() * 3 + 1, - }); - } - - let animId: number; - const animate = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - for (const p of particles) { - p.x += p.vx; - p.y += p.vy; - if (p.x < 0 || p.x > canvas.width) p.vx *= -1; - if (p.y < 0 || p.y > canvas.height) p.vy *= -1; - ctx.beginPath(); - ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); - ctx.fillStyle = 'oklch(0.62 0.15 255 / 0.15)'; - ctx.fill(); - } - animId = requestAnimationFrame(animate); - }; - animate(); - - return () => cancelAnimationFrame(animId); - }, []); - - return ( -