From 8ad888da288c90a556f87e73377f02592fd9b03a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 2 Jul 2026 01:18:45 +0700 Subject: [PATCH] feat: migrate frontend to Astro SSG with design system - Replace monolithic React SPA (App.tsx) with 7 Astro pages (+ routing) - Implement full design system from design/ (OKLCH tokens, Tailwind 4 @theme) - Add 12 Astro components (Button, Badge, Card, Sidebar, Header, states) - Add 11 React islands (AuthGuard, MessageFeed, VoiceControls, AudioVisualizer, etc.) - Add 3 Zustand stores (UI, voice, message state) - Add CSS architecture: dark/light themes, glassmorphism, fluid typography, animations - Add 404, login, settings, recordings pages - Remove 40+ legacy React SPA files - Add Particles background and MascotChat deferred islands - Wire WebSocket bridge for real-time messages and voice events Build: 7 pages in ~900ms Typecheck: clean (0 errors) Lint: clean (0 errors) --- pnpm-lock.yaml | 26 + services/frontend/.astro/types.d.ts | 1 + services/frontend/astro.config.mjs | 2 - services/frontend/package.json | 3 +- services/frontend/src/App.client.tsx | 18 - services/frontend/src/App.tsx | 512 ---------------- .../src/components/header/Header.astro | 34 ++ .../src/components/sidebar/NavItem.astro | 60 ++ .../src/components/sidebar/Sidebar.astro | 93 +++ .../src/components/states/EmptyState.astro | 35 ++ .../src/components/states/ErrorState.astro | 51 ++ .../components/states/LoadingSkeleton.astro | 60 ++ .../frontend/src/components/ui/Badge.astro | 106 ++++ .../frontend/src/components/ui/Button.astro | 146 +++++ .../frontend/src/components/ui/Card.astro | 101 +++ .../src/components/ui/SeverityBadge.astro | 97 +++ .../frontend/src/components/ui/Skeleton.astro | 74 +++ .../frontend/src/components/ui/Spinner.astro | 60 ++ .../src/features/admin/AdminPanel.tsx | 295 --------- .../components/ChannelProfileDetail.tsx | 56 -- .../components/ChannelSummaryList.tsx | 56 -- .../dashboard/components/DashboardStats.tsx | 246 -------- .../components/UserProfileDetail.tsx | 66 -- .../dashboard/components/UserSummaryList.tsx | 67 -- .../features/dashboard/hooks/useDashboard.ts | 126 ---- .../frontend/src/features/dashboard/index.tsx | 135 ---- .../live/components/ActiveSpeakers.tsx | 49 -- .../live/components/AudioVisualizer.tsx | 79 --- .../live/components/MicLevelMeter.tsx | 29 - .../live/components/MusicSubPanel.tsx | 116 ---- .../features/live/components/NowPlaying.tsx | 61 -- .../live/components/RecordingsSubPanel.tsx | 207 ------- .../live/components/ScreenSubPanel.tsx | 47 -- .../live/components/VoiceConnectionCard.tsx | 119 ---- .../live/components/WaveformPlayer.tsx | 256 -------- .../features/live/hooks/useMediaControl.ts | 104 ---- .../features/live/hooks/useVoiceControl.ts | 113 ---- services/frontend/src/features/live/index.tsx | 171 ------ .../messages/components/ImageGrid.tsx | 132 ---- .../messages/components/MessageCard.tsx | 577 ------------------ .../messages/components/MessageFeed.tsx | 120 ---- .../components/ModerationAlertListener.tsx | 57 -- .../frontend/src/features/messages/index.tsx | 315 ---------- .../frontend/src/features/settings/index.tsx | 426 ------------- .../src/hooks/useNotificationBadge.ts | 31 - .../frontend/src/islands/ActiveSpeakers.tsx | 61 ++ .../frontend/src/islands/AudioVisualizer.tsx | 95 +++ services/frontend/src/islands/AuthGuard.tsx | 122 ++++ services/frontend/src/islands/LiveShell.tsx | 142 +++++ services/frontend/src/islands/MascotChat.tsx | 122 ++++ services/frontend/src/islands/MessageFeed.tsx | 356 +++++++++++ services/frontend/src/islands/NowPlaying.tsx | 69 +++ services/frontend/src/islands/PageHeader.tsx | 40 ++ services/frontend/src/islands/PageSidebar.tsx | 30 + services/frontend/src/islands/Particles.tsx | 104 ++++ .../frontend/src/islands/RecordingsList.tsx | 192 ++++++ .../frontend/src/islands/SettingsForm.tsx | 89 +++ services/frontend/src/islands/ThemeToggle.tsx | 68 +++ .../frontend/src/islands/VoiceControls.tsx | 110 ++++ .../frontend/src/layouts/AuthLayout.astro | 15 + .../frontend/src/layouts/BaseLayout.astro | 37 +- services/frontend/src/pages/404.astro | 20 + services/frontend/src/pages/index.astro | 14 +- services/frontend/src/pages/live.astro | 14 + services/frontend/src/pages/login.astro | 13 + services/frontend/src/pages/messages.astro | 28 + services/frontend/src/pages/recordings.astro | 27 + services/frontend/src/pages/settings.astro | 27 + services/frontend/src/shared/api/client.ts | 30 +- .../{ui => components}/CommandPalette.tsx | 2 +- .../{widgets => shared/components}/Header.tsx | 28 +- .../{ui => components}/MobileTabBar.tsx | 6 +- .../components}/Sidebar.tsx | 14 +- .../components/auth/AuthGuard.client.tsx | 19 + .../components}/auth/index.tsx | 11 +- .../src/shared/{ui => components}/badge.tsx | 0 .../src/shared/{ui => components}/button.tsx | 0 .../src/shared/{ui => components}/card.tsx | 0 .../{ui => components}/error-boundary.tsx | 4 +- .../src/shared/{ui => components}/index.ts | 0 .../src/shared/{ui => components}/input.tsx | 0 .../components}/mascot/MascotChatbot.tsx | 4 +- .../components}/mascot/MascotImage.tsx | 4 +- .../particles/ParticleBackground.tsx | 11 +- .../{ui => components}/profile-detail.tsx | 0 .../shared/{ui => components}/scroll-area.tsx | 0 .../src/shared/{ui => components}/select.tsx | 0 .../shared/{ui => components}/skeleton.tsx | 0 .../{ui => components}/status-badge.tsx | 0 .../{ui => components}/summary-list.tsx | 0 .../src/shared/{ui => components}/tabs.tsx | 0 .../src/shared/{ui => components}/toast.tsx | 0 .../src/shared/hooks/useMascotChat.ts | 2 +- .../messages => shared}/hooks/useMessages.ts | 60 +- .../src/{ => shared}/hooks/useTheme.ts | 0 .../frontend/src/shared/hooks/useUIState.ts | 2 +- .../types.ts => shared/types/dashboard.ts} | 0 .../guild/types.ts => shared/types/guild.ts} | 0 .../media/types.ts => shared/types/media.ts} | 0 .../types.ts => shared/types/message.ts} | 0 .../types.ts => shared/types/recording.ts} | 0 .../ui/types.ts => shared/types/ui-types.ts} | 0 .../voice/types.ts => shared/types/voice.ts} | 2 +- services/frontend/src/shared/ws/socket.ts | 44 +- services/frontend/src/stores/message-store.ts | 49 ++ services/frontend/src/stores/ui-store.ts | 46 ++ services/frontend/src/stores/voice-store.ts | 72 +++ services/frontend/src/styles.css | 199 ------ services/frontend/src/styles/base.css | 352 +++++++++++ .../frontend/src/widgets/DashboardLayout.tsx | 85 --- 110 files changed, 3396 insertions(+), 4980 deletions(-) delete mode 100644 services/frontend/src/App.client.tsx delete mode 100644 services/frontend/src/App.tsx create mode 100644 services/frontend/src/components/header/Header.astro create mode 100644 services/frontend/src/components/sidebar/NavItem.astro create mode 100644 services/frontend/src/components/sidebar/Sidebar.astro create mode 100644 services/frontend/src/components/states/EmptyState.astro create mode 100644 services/frontend/src/components/states/ErrorState.astro create mode 100644 services/frontend/src/components/states/LoadingSkeleton.astro create mode 100644 services/frontend/src/components/ui/Badge.astro create mode 100644 services/frontend/src/components/ui/Button.astro create mode 100644 services/frontend/src/components/ui/Card.astro create mode 100644 services/frontend/src/components/ui/SeverityBadge.astro create mode 100644 services/frontend/src/components/ui/Skeleton.astro create mode 100644 services/frontend/src/components/ui/Spinner.astro delete mode 100644 services/frontend/src/features/admin/AdminPanel.tsx delete mode 100644 services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx delete mode 100644 services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx delete mode 100644 services/frontend/src/features/dashboard/components/DashboardStats.tsx delete mode 100644 services/frontend/src/features/dashboard/components/UserProfileDetail.tsx delete mode 100644 services/frontend/src/features/dashboard/components/UserSummaryList.tsx delete mode 100644 services/frontend/src/features/dashboard/hooks/useDashboard.ts delete mode 100644 services/frontend/src/features/dashboard/index.tsx delete mode 100644 services/frontend/src/features/live/components/ActiveSpeakers.tsx delete mode 100644 services/frontend/src/features/live/components/AudioVisualizer.tsx delete mode 100644 services/frontend/src/features/live/components/MicLevelMeter.tsx delete mode 100644 services/frontend/src/features/live/components/MusicSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/NowPlaying.tsx delete mode 100644 services/frontend/src/features/live/components/RecordingsSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/ScreenSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/VoiceConnectionCard.tsx delete mode 100644 services/frontend/src/features/live/components/WaveformPlayer.tsx delete mode 100644 services/frontend/src/features/live/hooks/useMediaControl.ts delete mode 100644 services/frontend/src/features/live/hooks/useVoiceControl.ts delete mode 100644 services/frontend/src/features/live/index.tsx delete mode 100644 services/frontend/src/features/messages/components/ImageGrid.tsx delete mode 100644 services/frontend/src/features/messages/components/MessageCard.tsx delete mode 100644 services/frontend/src/features/messages/components/MessageFeed.tsx delete mode 100644 services/frontend/src/features/messages/components/ModerationAlertListener.tsx delete mode 100644 services/frontend/src/features/messages/index.tsx delete mode 100644 services/frontend/src/features/settings/index.tsx delete mode 100644 services/frontend/src/hooks/useNotificationBadge.ts create mode 100644 services/frontend/src/islands/ActiveSpeakers.tsx create mode 100644 services/frontend/src/islands/AudioVisualizer.tsx create mode 100644 services/frontend/src/islands/AuthGuard.tsx create mode 100644 services/frontend/src/islands/LiveShell.tsx create mode 100644 services/frontend/src/islands/MascotChat.tsx create mode 100644 services/frontend/src/islands/MessageFeed.tsx create mode 100644 services/frontend/src/islands/NowPlaying.tsx create mode 100644 services/frontend/src/islands/PageHeader.tsx create mode 100644 services/frontend/src/islands/PageSidebar.tsx create mode 100644 services/frontend/src/islands/Particles.tsx create mode 100644 services/frontend/src/islands/RecordingsList.tsx create mode 100644 services/frontend/src/islands/SettingsForm.tsx create mode 100644 services/frontend/src/islands/ThemeToggle.tsx create mode 100644 services/frontend/src/islands/VoiceControls.tsx create mode 100644 services/frontend/src/layouts/AuthLayout.astro create mode 100644 services/frontend/src/pages/404.astro create mode 100644 services/frontend/src/pages/live.astro create mode 100644 services/frontend/src/pages/login.astro create mode 100644 services/frontend/src/pages/messages.astro create mode 100644 services/frontend/src/pages/recordings.astro create mode 100644 services/frontend/src/pages/settings.astro rename services/frontend/src/shared/{ui => components}/CommandPalette.tsx (100%) rename services/frontend/src/{widgets => shared/components}/Header.tsx (88%) rename services/frontend/src/shared/{ui => components}/MobileTabBar.tsx (92%) rename services/frontend/src/{widgets => shared/components}/Sidebar.tsx (93%) create mode 100644 services/frontend/src/shared/components/auth/AuthGuard.client.tsx rename services/frontend/src/{features => shared/components}/auth/index.tsx (95%) rename services/frontend/src/shared/{ui => components}/badge.tsx (100%) rename services/frontend/src/shared/{ui => components}/button.tsx (100%) rename services/frontend/src/shared/{ui => components}/card.tsx (100%) rename services/frontend/src/shared/{ui => components}/error-boundary.tsx (98%) rename services/frontend/src/shared/{ui => components}/index.ts (100%) rename services/frontend/src/shared/{ui => components}/input.tsx (100%) rename services/frontend/src/{widgets => shared/components}/mascot/MascotChatbot.tsx (99%) rename services/frontend/src/{widgets => shared/components}/mascot/MascotImage.tsx (96%) rename services/frontend/src/{widgets => shared/components}/particles/ParticleBackground.tsx (72%) rename services/frontend/src/shared/{ui => components}/profile-detail.tsx (100%) rename services/frontend/src/shared/{ui => components}/scroll-area.tsx (100%) rename services/frontend/src/shared/{ui => components}/select.tsx (100%) rename services/frontend/src/shared/{ui => components}/skeleton.tsx (100%) rename services/frontend/src/shared/{ui => components}/status-badge.tsx (100%) rename services/frontend/src/shared/{ui => components}/summary-list.tsx (100%) rename services/frontend/src/shared/{ui => components}/tabs.tsx (100%) rename services/frontend/src/shared/{ui => components}/toast.tsx (100%) rename services/frontend/src/{features/messages => shared}/hooks/useMessages.ts (69%) rename services/frontend/src/{ => shared}/hooks/useTheme.ts (100%) rename services/frontend/src/{entities/dashboard/types.ts => shared/types/dashboard.ts} (100%) rename services/frontend/src/{entities/guild/types.ts => shared/types/guild.ts} (100%) rename services/frontend/src/{entities/media/types.ts => shared/types/media.ts} (100%) rename services/frontend/src/{entities/message/types.ts => shared/types/message.ts} (100%) rename services/frontend/src/{entities/recording/types.ts => shared/types/recording.ts} (100%) rename services/frontend/src/{entities/ui/types.ts => shared/types/ui-types.ts} (100%) rename services/frontend/src/{entities/voice/types.ts => shared/types/voice.ts} (85%) create mode 100644 services/frontend/src/stores/message-store.ts create mode 100644 services/frontend/src/stores/ui-store.ts create mode 100644 services/frontend/src/stores/voice-store.ts delete mode 100644 services/frontend/src/styles.css create mode 100644 services/frontend/src/styles/base.css delete mode 100644 services/frontend/src/widgets/DashboardLayout.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34efb2c..5d573ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.14)(react@19.2.6) devDependencies: '@biomejs/biome': specifier: latest @@ -6047,6 +6050,24 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11514,4 +11535,9 @@ snapshots: zod@4.4.3: {} + zustand@5.0.14(@types/react@19.2.14)(react@19.2.6): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 + zwitch@2.0.4: {} diff --git a/services/frontend/.astro/types.d.ts b/services/frontend/.astro/types.d.ts index f964fe0..03d7cc4 100644 --- a/services/frontend/.astro/types.d.ts +++ b/services/frontend/.astro/types.d.ts @@ -1 +1,2 @@ /// +/// \ No newline at end of file diff --git a/services/frontend/astro.config.mjs b/services/frontend/astro.config.mjs index 3bae84b..332f594 100644 --- a/services/frontend/astro.config.mjs +++ b/services/frontend/astro.config.mjs @@ -36,10 +36,8 @@ export default defineConfig({ "imphnen.asepharyana.web.id", ], watch: { - // Penting: Astro punya public/ dir sendiri, jangan bentrok ignored: ["!**/node_modules/**"], }, }, - // PostCSS otomatis terdeteksi dari root project }, }); diff --git a/services/frontend/package.json b/services/frontend/package.json index 934f9da..0703268 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -23,7 +23,8 @@ "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.14" }, "devDependencies": { "@biomejs/biome": "latest", diff --git a/services/frontend/src/App.client.tsx b/services/frontend/src/App.client.tsx deleted file mode 100644 index 2ec3bd5..0000000 --- a/services/frontend/src/App.client.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// ─── App.client.tsx — Astro React island entry point ──────────────────────── -// DILOAD OLEH Astro client:only="react" -// Menyediakan
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 ( - - - - - - ); -} diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx deleted file mode 100644 index ad3b3b9..0000000 --- a/services/frontend/src/App.tsx +++ /dev/null @@ -1,512 +0,0 @@ -// ─── 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 { AuthOverlay } from "./features/auth"; -import { DashboardPanel } from "./features/dashboard"; -import { LivePanel } from "./features/live"; -import { useMediaControl } from "./features/live/hooks/useMediaControl"; -import { useVoiceControl } from "./features/live/hooks/useVoiceControl"; -import { MessagesPanel } from "./features/messages"; -import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener"; -import { - mergeMessages, - useMessages, -} from "./features/messages/hooks/useMessages"; -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 { useAudioTransmit } from "./shared/hooks/useAudioTransmit"; -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 type { DashboardTab } from "./entities/ui/types.js"; -import { useDashboardSocket } from "./shared/ws/socket"; -import { DashboardLayout } from "./widgets/DashboardLayout"; - -type AuthState = "loading" | "authenticated" | "unauthenticated"; - -export default function App() { - const { uiState, patchUIState } = useUIState(); - const { theme, mode, isDark, toggle: toggleTheme, setMode } = useTheme(); - const voice = useVoiceControl(); - const media = useMediaControl(); - const messages = useMessages(); - const [activeSpeakers, setActiveSpeakers] = useState< - (ActiveSpeaker & { heardAt?: number })[]>([]); - 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("loading"); - const [dashboardIsPublic, setDashboardIsPublic] = useState(false); - const [configError, setConfigError] = useState(null); - const configRetryRef = useRef(0); - const configTimeoutRef = useRef | 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 selectedVoiceGuild = - uiState.selectedVoiceGuild || uiState.selectedGuild || ""; - - // Resolve monitor guild name from the full guild list (has real names now) - const monitorGuildName = useMemo( - () => - monitorGuildId - ? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null) - : null, - [monitorGuildId, voice.guilds], - ); - - // Update speaker list from incremental voice_active_user events - const updateSpeakerList = ( - prev: (ActiveSpeaker & { heardAt?: number })[], - data: Partial & { - userId?: string; - id?: string; - speaking: boolean; - }, - ): (ActiveSpeaker & { heardAt?: number })[] => { - const key = data.userId ?? data.id; - if (!key) return prev; - const now = Date.now(); - const idx = prev.findIndex((s) => (s.userId ?? s.id) === key); - if (idx >= 0) { - const next = [...prev]; - next[idx] = { ...next[idx], ...data, heardAt: now }; - return next; - } - return [ - ...prev, - { ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number }, - ]; - }; - - const socket = useDashboardSocket({ - onBinary: (d) => audio.handleIncomingBinary(d), - onUserState: (users) => - setActiveSpeakers( - users.map((u) => ({ - ...u, - heardAt: Date.now(), - })), - ), - onVoiceActiveUser: (data) => { - if (data.userId) audio.registerUserId(data.userId); - setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => - updateSpeakerList(prev, { - userId: data.userId, - username: data.username, - avatar: data.avatar, - speaking: data.speaking, - }), - ); - }, - onVoiceRecordingStarted: (data) => - window.dispatchEvent( - new CustomEvent("voice_recording_started", { detail: data }), - ), - onVoiceRecordingStopped: (data) => - window.dispatchEvent( - new CustomEvent("voice_recording_stopped", { detail: data }), - ), - onVoiceAnalyzed: (data) => - window.dispatchEvent( - new CustomEvent("voice_analyzed", { detail: data }), - ), - onMessageCreated: (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) => - messages.setMessages((prev) => - prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)), - ), - onMessageDeleted: (m) => - messages.setMessages((prev) => - prev.map((i) => - i.id === m.id ? { ...i, type: "deleted" as const } : i, - ), - ), - onMessageAnalyzed: (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; - if (status === "flagged") { - const username = msg.username || msg.user_id || "unknown"; - const severity = msg.ai_severity || ""; - const categories = msg.ai_categories || ""; - const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI"; - window.dispatchEvent( - new CustomEvent("moderation_alert", { - detail: { type: status, username, severity, categories, brief }, - }), - ); - } - }, - onAttachmentUploaded: () => - messages - .fetchMessages(monitorGuildId || undefined) - .catch(() => undefined), - onAttachmentCreated: () => - messages - .fetchMessages(monitorGuildId || undefined) - .catch(() => undefined), - onMediaState: (state) => media.setMediaState(state), - onVoiceRecordingUploaded: (d) => - window.dispatchEvent( - new CustomEvent("voice_recording_uploaded", { detail: d }), - ), - }); - - const transmit = useAudioTransmit(socket.socketRef); - - // Load voice channels when guild changes (Live tab) - useEffect(() => { - if (selectedVoiceGuild) - voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); - }, [selectedVoiceGuild, voice.loadVoiceChannels]); - - // Auto-fetch messages for the monitor guild - useEffect(() => { - if (monitorGuildId) - messages.fetchMessages(monitorGuildId).catch(() => undefined); - }, [monitorGuildId, messages.fetchMessages]); - - // Periodic refetch — keeps dashboard in sync even if WS events missed - const monitorGuildRef = useRef(monitorGuildId); - monitorGuildRef.current = monitorGuildId; - - useEffect(() => { - const currentGuild = monitorGuildRef.current; - if (!currentGuild) return; - const interval = setInterval(() => { - messages.fetchMessages(monitorGuildRef.current).catch(() => undefined); - }, 15_000); - return () => { - clearInterval(interval); - }; - }, [monitorGuildId]); - - // Stale speaker pruning — remove speakers not heard from in 30s - useEffect(() => { - const interval = setInterval(() => { - setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => { - const now = Date.now(); - const pruned = prev.filter( - (s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000), - ); - return pruned.length < prev.length ? pruned : prev; - }); - }, 30_000); - return () => clearInterval(interval); - }, []); - - // Push-to-Talk — hold Space to transmit - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement - ) - return; - if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) { - e.preventDefault(); - transmit.startTransmit().catch(() => undefined); - } - }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.code === "Space" && transmit.isStreaming) { - transmit.stopTransmit(); - } - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, [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 ( - - - 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} - /> - - ); - case "dashboard": - return ( - - - - ); - case "settings": - return ( - - - - ); - default: - return ( - - - - ); - } - }; - - // ── Render: Auth loading ───────────────────────────────────────────────── - if (authState === "loading") { - return ( -
-
-
-

- {configError - ? "Connection lost — retrying..." - : `Connecting to server${".".repeat(configRetryRef.current)}`} -

-
-
- ); - } - - // ── Render: Auth overlay ───────────────────────────────────────────────── - if (authState === "unauthenticated") { - return ( - 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 ( - <> - - - {renderContent()} - - - - - - - ); -} diff --git a/services/frontend/src/components/header/Header.astro b/services/frontend/src/components/header/Header.astro new file mode 100644 index 0000000..eaa1884 --- /dev/null +++ b/services/frontend/src/components/header/Header.astro @@ -0,0 +1,34 @@ +--- +// ─── Header.astro — App header bar ────────────────────────────────────────── +// Features: sticky top, backdrop blur, border-bottom, actions slot +// ────────────────────────────────────────────────────────────────────────────── + +export interface Props { + title: string; +} + +const { title } = Astro.props; +--- + +
+ +
+

{title}

+
+ + +
+ +
+
+ + diff --git a/services/frontend/src/components/sidebar/NavItem.astro b/services/frontend/src/components/sidebar/NavItem.astro new file mode 100644 index 0000000..29484f0 --- /dev/null +++ b/services/frontend/src/components/sidebar/NavItem.astro @@ -0,0 +1,60 @@ +--- +// ─── NavItem.astro — Sidebar navigation item ──────────────────────────────── +// Handles: active state highlighting, collapsed icon-only mode, +// notification badge / dot indicators. +// ──────────────────────────────────────────────────────────────────────────────── + +export interface Props { + href: string; + icon: string; + label: string; + active?: boolean; + collapsed?: boolean; + notificationCount?: number; +} + +const { + href, + icon, + label, + active = false, + collapsed = false, + notificationCount = 0, +} = Astro.props; + +const navClass = active + ? "bg-[var(--clr-interactive-selected,oklch(var(--primary-soft)))] text-primary-foreground" + : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"; +--- + + + diff --git a/services/frontend/src/components/sidebar/Sidebar.astro b/services/frontend/src/components/sidebar/Sidebar.astro new file mode 100644 index 0000000..34fa960 --- /dev/null +++ b/services/frontend/src/components/sidebar/Sidebar.astro @@ -0,0 +1,93 @@ +--- +// ─── Sidebar.astro — Main app sidebar navigation ────────────────────────── +// Features: collapsed / expanded (64px / 256px), logo, nav items, version +// ────────────────────────────────────────────────────────────────────────────── + +import NavItem from "./NavItem.astro"; + +export interface Props { + collapsed?: boolean; + version?: string; + currentPath?: string; +} + +const { collapsed = false, version = "1.0.0", currentPath = "/live" } = + Astro.props; + +const ICONS = { + live: ``, + messages: ``, + recordings: ``, + settings: ``, +} as const; +--- + + + + diff --git a/services/frontend/src/components/states/EmptyState.astro b/services/frontend/src/components/states/EmptyState.astro new file mode 100644 index 0000000..bdc19b1 --- /dev/null +++ b/services/frontend/src/components/states/EmptyState.astro @@ -0,0 +1,35 @@ +--- +// ─── EmptyState.astro — Empty / no-data state ─────────────────────────────── +// Features: centered layout, icon, title, description, actions slot +// ────────────────────────────────────────────────────────────────────────────── + +export interface Props { + icon: string; + title: string; + description?: string; +} + +const { icon, title, description } = Astro.props; +--- + +
+ + diff --git a/services/frontend/src/components/states/ErrorState.astro b/services/frontend/src/components/states/ErrorState.astro new file mode 100644 index 0000000..f3a7cae --- /dev/null +++ b/services/frontend/src/components/states/ErrorState.astro @@ -0,0 +1,51 @@ +--- +// ─── ErrorState.astro — Error / failure state ─────────────────────────────── +// Features: role="alert", severity-critical bg tint, error icon, retry slot +// ────────────────────────────────────────────────────────────────────────────── + +export interface Props { + message: string; +} + +const { message } = Astro.props; +--- + + diff --git a/services/frontend/src/components/states/LoadingSkeleton.astro b/services/frontend/src/components/states/LoadingSkeleton.astro new file mode 100644 index 0000000..486f801 --- /dev/null +++ b/services/frontend/src/components/states/LoadingSkeleton.astro @@ -0,0 +1,60 @@ +--- +// ─── LoadingSkeleton.astro — Placeholder loading states ───────────────────── +// Uses the shared Skeleton React component in 3 layout variants. +// ────────────────────────────────────────────────────────────────────────────── + +import { Skeleton } from "../../shared/components/skeleton"; + +export type Variant = "card" | "list" | "detail"; + +export interface Props { + /** Visual layout variant */ + variant?: Variant; + /** Number of skeleton items to render (default 1) */ + count?: number; +} + +const { variant = "card", count = 1 } = Astro.props; + +const items = Array.from({ length: count }); +--- + + diff --git a/services/frontend/src/components/ui/Badge.astro b/services/frontend/src/components/ui/Badge.astro new file mode 100644 index 0000000..0b6ab54 --- /dev/null +++ b/services/frontend/src/components/ui/Badge.astro @@ -0,0 +1,106 @@ +--- +export interface Props { + variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline"; + size?: "sm" | "default"; + dot?: boolean; + class?: string; +} + +const { + variant = "default", + size = "default", + dot = false, + class: className = "", +} = Astro.props; +--- + + + {dot && + + diff --git a/services/frontend/src/components/ui/Button.astro b/services/frontend/src/components/ui/Button.astro new file mode 100644 index 0000000..886a0db --- /dev/null +++ b/services/frontend/src/components/ui/Button.astro @@ -0,0 +1,146 @@ +--- +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 Tag = href ? "a" : "button"; + +const attrs: Record = { + class: `btn btn--${variant} btn--${size} ${className}`, +}; +if (Tag === "button") { + attrs.disabled = disabled; +} else if (disabled) { + attrs["aria-disabled"] = true; + attrs.role = "link"; +} +--- + + + + + + diff --git a/services/frontend/src/components/ui/Card.astro b/services/frontend/src/components/ui/Card.astro new file mode 100644 index 0000000..2ab6f4f --- /dev/null +++ b/services/frontend/src/components/ui/Card.astro @@ -0,0 +1,101 @@ +--- +export interface Props { + variant?: "default" | "elevated" | "glass" | "interactive"; + padding?: "none" | "sm" | "md" | "lg"; + class?: string; +} + +const { + variant = "default", + padding = "md", + class: className = "", +} = Astro.props; +--- + +
+ +
+ + diff --git a/services/frontend/src/components/ui/SeverityBadge.astro b/services/frontend/src/components/ui/SeverityBadge.astro new file mode 100644 index 0000000..1aa0cb3 --- /dev/null +++ b/services/frontend/src/components/ui/SeverityBadge.astro @@ -0,0 +1,97 @@ +--- +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: "✖", +}; + +const icon = ICONS[severity] ?? ""; +--- + + + + + diff --git a/services/frontend/src/components/ui/Skeleton.astro b/services/frontend/src/components/ui/Skeleton.astro new file mode 100644 index 0000000..b1a8e20 --- /dev/null +++ b/services/frontend/src/components/ui/Skeleton.astro @@ -0,0 +1,74 @@ +--- +export interface Props { + variant?: "text" | "card" | "circle" | "rect"; + width?: string; + height?: string; + class?: string; +} + +const { + variant = "text", + width, + height, + class: className = "", +} = Astro.props; + +const style: Record = {}; +if (width) style.width = width; +if (height) style.height = height; +--- + + + + diff --git a/services/frontend/src/components/ui/Spinner.astro b/services/frontend/src/components/ui/Spinner.astro new file mode 100644 index 0000000..13c0aba --- /dev/null +++ b/services/frontend/src/components/ui/Spinner.astro @@ -0,0 +1,60 @@ +--- +export interface Props { + size?: "sm" | "default" | "lg"; + class?: string; +} + +const { size = "default", class: className = "" } = Astro.props; +--- + + + + + diff --git a/services/frontend/src/features/admin/AdminPanel.tsx b/services/frontend/src/features/admin/AdminPanel.tsx deleted file mode 100644 index fb3aa04..0000000 --- a/services/frontend/src/features/admin/AdminPanel.tsx +++ /dev/null @@ -1,295 +0,0 @@ -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(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(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 ( - - - Admin Settings - Loading settings... - - -
-
-
- - - ); - } - - if (error && !settings) { - return ( - - - Admin Settings - {error} - - - - - - ); - } - - const isPublic = settings?.dashboardIsPublic ?? false; - - return ( - - - - -
-
- - - Admin Settings - - - Manage dashboard visibility and runtime configuration. - -
- -
-
- - {/* ── Success / Error messages ── */} - {success && ( -
- {success} -
- )} - {error && ( -
- {error} -
- )} - - {/* ── Dashboard Visibility ── */} -
-
-
-
- {isPublic ? ( - - ) : ( - - )} -

- Dashboard Visibility:{" "} - - {isPublic ? "Public" : "Private"} - -

-
-

- {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."} -

-
- -
- - {/* ── Status indicators ── */} -
-
-

Runtime

-
- - - {isPublic ? "Public" : "Private"} - -
-
-
-

- Env Default -

-
- - - {settings?.envDashboardIsPublic ? "Public" : "Private"} - -
-
-
-
- - {/* ── Logout ── */} -
- -
- - {/* ── Info card ── */} -
-
- -
-

- Admin password is configured via the - - ADMIN_PASSWORD - - environment variable. For security, it cannot be changed - through this panel — update it in your deployment - configuration and restart the service. -

-

- Runtime settings are persisted across restarts in the - - data/settings.json - - file. Changes take effect immediately, no restart needed. -

-
-
-
-
-
-
-
- ); -} diff --git a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx deleted file mode 100644 index fb44b92..0000000 --- a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Hash } from "lucide-react"; -import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js"; -import { ProfileDetail } from "../../../shared/ui"; - -interface ChannelProfileDetailProps { - detail: DashboardChannelDetail | null; - loading: boolean; - error: string | null; - onBack: () => void; - onRefetch: () => void; -} - -export function ChannelProfileDetail({ - detail, - loading, - error, - onBack, - onRefetch, -}: ChannelProfileDetailProps) { - if (!detail && !loading && !error) return null; - - return ( - } - title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""} - subtitle={detail?.channel_id} - summaryLabel="AI Channel Summary" - summaryText={detail?.culture_summary ?? undefined} - lastAnalyzedLabel={ - detail?.last_analyzed_at - ? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}` - : undefined - } - stats={{ - totalLabel: "Total Messages", - totalValue: detail?.total_messages ?? 0, - cleanLabel: "Clean", - cleanValue: detail?.clean_count ?? 0, - flaggedLabel: "Flagged", - flaggedValue: detail?.flagged_count ?? 0, - }} - messages={ - detail?.recent_messages.map((msg) => ({ - id: msg.id, - content: msg.content, - created_at: new Date(msg.created_at).toISOString(), - ai_status: msg.ai_status, - })) ?? [] - } - /> - ); -} diff --git a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx deleted file mode 100644 index 2f82463..0000000 --- a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Hash } from "lucide-react"; -import type { DashboardChannel } from "../../../entities/dashboard/types.js"; -import type { SummaryItem } from "../../../shared/ui"; -import { SummaryList } from "../../../shared/ui"; - -interface ChannelSummaryListProps { - channels: DashboardChannel[]; - loading: boolean; - error: string | null; - search: string; - onSearchChange: (value: string) => void; - onLoadMore: () => void; - hasMore: boolean; - onRefetch: () => void; - onSelectChannel: (channelId: string) => void; -} - -export function ChannelSummaryList({ - channels, - loading, - error, - search, - onSearchChange, - onLoadMore, - hasMore, - onRefetch, - onSelectChannel, -}: ChannelSummaryListProps) { - const items: SummaryItem[] = channels.map((ch) => ({ - id: ch.channel_id, - label: `#${ch.channel_name ?? ch.channel_id}`, - subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined, - summaryText: ch.culture_summary ?? `${ch.total_messages} messages`, - onClick: () => onSelectChannel(ch.channel_id), - })); - - return ( - ( -
- -
- )} - emptyMessage="No channels found." - /> - ); -} diff --git a/services/frontend/src/features/dashboard/components/DashboardStats.tsx b/services/frontend/src/features/dashboard/components/DashboardStats.tsx deleted file mode 100644 index 11a5537..0000000 --- a/services/frontend/src/features/dashboard/components/DashboardStats.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { motion } from "framer-motion"; -import { - AlertCircle, - BarChart3, - MessageSquare, - Mic, - RefreshCw, - ShieldAlert, - UserCheck, - Users, -} from "lucide-react"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { useUIState } from "../../../shared/hooks/useUIState.js"; -import { cn } from "../../../shared/lib/utils"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, - StatusBadge, -} from "../../../shared/ui"; -import { useDashboardStats } from "../hooks/useDashboard"; - -export function DashboardStatsContent() { - const { stats, loading, error, refetch } = useDashboardStats(); - const { patchUIState } = useUIState(); - - if (loading) { - return ; - } - - if (error) { - return ( -
- -

{error}

- -
- ); - } - - if (!stats) { - return ( -
- -

No data available yet.

-
- ); - } - - const cards = [ - { - title: "Total Messages", - value: stats.total_messages.toLocaleString(), - icon: MessageSquare, - color: "text-primary", - bg: "bg-primary/10", - }, - { - title: "Today's Messages", - value: stats.today_messages.toLocaleString(), - icon: MessageSquare, - color: "text-emerald-500", - bg: "bg-emerald-100", - }, - { - title: "Total Users", - value: stats.total_users.toLocaleString(), - icon: Users, - color: "text-blue-500", - bg: "bg-blue-100", - }, - { - title: "Active Users (24h)", - value: stats.active_users_24h.toLocaleString(), - icon: UserCheck, - color: "text-violet-500", - bg: "bg-violet-100", - }, - { - title: "Flagged", - value: stats.total_flagged.toLocaleString(), - icon: ShieldAlert, - color: "text-destructive", - bg: "bg-destructive/10", - }, - { - title: "Clean", - value: stats.total_clean.toLocaleString(), - icon: ShieldAlert, - color: "text-emerald-600", - bg: "bg-emerald-100", - }, - { - title: "Voice Recordings", - value: stats.total_voice_recordings.toLocaleString(), - icon: Mic, - color: "text-cyan-500", - bg: "bg-cyan-100", - onClick: () => patchUIState({ activeTab: "live" }), - }, - { - title: "AI Profiles", - value: stats.total_profiles.toLocaleString(), - icon: Users, - color: "text-amber-500", - bg: "bg-amber-100", - }, - ]; - - return ( - - {/* Summary cards grid */} - - {cards.map((card) => ( - - -
-
-

- {card.title} -

-

- {card.value} -

-
-
- -
-
-
-
- ))} -
- - {/* Top channels */} - - - - Top Channels - - - {stats.top_channels.length === 0 ? ( -

- No channel data yet. -

- ) : ( -
- {stats.top_channels.map((ch) => ( -
- - #{ch.channel_name ?? ch.channel_id} - - - {ch.message_count.toLocaleString()} - -
- ))} -
- )} -
-
-
- - {/* Moderation overview */} - - - - Moderation Queue - - -
- - - -
-
-
-

- {stats.moderation_overview.pending} -

-

Pending

-
-
-

- {stats.moderation_overview.processing} -

-

Processing

-
-
-

- {stats.moderation_overview.error} -

-

Errors

-
-
-
-
-
-
- ); -} - -function StatsSkeleton() { - return ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( - - -
- - -
-
-
- ))} -
-
- ); -} diff --git a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx deleted file mode 100644 index 632b252..0000000 --- a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { User } from "lucide-react"; -import type { DashboardUserDetail } from "../../../entities/dashboard/types.js"; -import { ProfileDetail } from "../../../shared/ui"; - -interface UserProfileDetailProps { - detail: DashboardUserDetail | null; - loading: boolean; - error: string | null; - onBack: () => void; - onRefetch: () => void; -} - -export function UserProfileDetail({ - detail, - loading, - error, - onBack, - onRefetch, -}: UserProfileDetailProps) { - if (!detail && !loading && !error) return null; - - const icon = detail?.avatar_url ? ( - - ) : ( - - ); - - return ( - ({ - id: msg.id, - content: msg.content, - created_at: new Date(msg.created_at).toISOString(), - ai_status: msg.ai_status, - })) ?? [] - } - /> - ); -} diff --git a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx deleted file mode 100644 index 66b51d2..0000000 --- a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { User } from "lucide-react"; -import type { DashboardUser } from "../../../entities/dashboard/types.js"; -import type { SummaryItem } from "../../../shared/ui"; -import { SummaryList } from "../../../shared/ui"; - -interface UserSummaryListProps { - users: DashboardUser[]; - loading: boolean; - error: string | null; - search: string; - onSearchChange: (value: string) => void; - onLoadMore: () => void; - hasMore: boolean; - onRefetch: () => void; - onSelectUser: (userId: string) => void; -} - -export function UserSummaryList({ - users, - loading, - error, - search, - onSearchChange, - onLoadMore, - hasMore, - onRefetch, - onSelectUser, -}: UserSummaryListProps) { - const items: SummaryItem[] = users.map((u) => ({ - id: u.user_id, - label: u.username ?? u.user_id, - subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined, - summaryText: u.profile_summary ?? `${u.total_messages} messages`, - onClick: () => onSelectUser(u.user_id), - })); - - const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url])); - - return ( - { - const avatarUrl = avatarMap.get(item.id); - return avatarUrl ? ( - - ) : ( -
- -
- ); - }} - emptyMessage="No users found." - /> - ); -} diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts deleted file mode 100644 index ee983ef..0000000 --- a/services/frontend/src/features/dashboard/hooks/useDashboard.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { DashboardStats } from "../../../entities/dashboard/types.js"; -import { - getDashboardChannelDetail, - getDashboardStats, - getDashboardUserDetail, - listDashboardChannels, - listDashboardUsers, -} from "../../../shared/api/client.js"; -import { useItemDetail } from "../../../shared/hooks/useItemDetail"; -import { usePaginatedList } from "../../../shared/hooks/usePaginatedList"; - -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-dashboard"); - -/** - * Fetch dashboard aggregate stats. - */ -export function useDashboardStats() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await getDashboardStats(); - setStats(data); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to load stats"; - setError(msg); - logger.error("[useDashboardStats]", { error: msg }); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { stats, loading, error, refetch: fetch }; -} - -/** - * Fetch paginated user list with optional search. - */ -export function useDashboardUsers() { - const paginated = usePaginatedList( - (params) => - listDashboardUsers({ - limit: params.limit, - search: params.search, - cursor: params.cursor, - }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), - "", - ); - - return { - users: paginated.data, - loading: paginated.loading, - error: paginated.error, - search: paginated.search, - setSearch: paginated.setSearch, - loadMore: paginated.loadMore, - hasMore: paginated.hasMore, - refetch: paginated.refetch, - }; -} - -/** - * Fetch a single user detail by userId. - */ -export function useDashboardUserDetail(userId: string | null) { - const { data, loading, error, refetch } = useItemDetail( - (_guildId, entityId) => getDashboardUserDetail(entityId), - "", - userId, - "user", - ); - - return { detail: data, loading, error, refetch }; -} - -/** - * Fetch paginated channel list with optional search. - */ -export function useDashboardChannels() { - const paginated = usePaginatedList( - (params) => - listDashboardChannels({ - limit: params.limit, - search: params.search, - guild_id: params.guildId, - cursor: params.cursor, - }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), - "", - ); - - return { - channels: paginated.data, - loading: paginated.loading, - error: paginated.error, - search: paginated.search, - setSearch: paginated.setSearch, - loadMore: paginated.loadMore, - hasMore: paginated.hasMore, - refetch: paginated.refetch, - }; -} - -/** - * Fetch a single channel detail by channelId. - */ -export function useDashboardChannelDetail(channelId: string | null) { - const { data, loading, error, refetch } = useItemDetail( - (_guildId, entityId) => getDashboardChannelDetail(entityId), - "", - channelId, - "channel", - ); - - return { detail: data, loading, error, refetch }; -} diff --git a/services/frontend/src/features/dashboard/index.tsx b/services/frontend/src/features/dashboard/index.tsx deleted file mode 100644 index 9d4ecb8..0000000 --- a/services/frontend/src/features/dashboard/index.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Settings } from "lucide-react"; -import { useState } from "react"; -import { AdminPanel } from "../../features/admin/AdminPanel"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; -import { ChannelProfileDetail } from "./components/ChannelProfileDetail"; -import { ChannelSummaryList } from "./components/ChannelSummaryList"; -import { DashboardStatsContent } from "./components/DashboardStats"; -import { UserProfileDetail } from "./components/UserProfileDetail"; -import { UserSummaryList } from "./components/UserSummaryList"; -import { - useDashboardChannelDetail, - useDashboardChannels, - useDashboardUserDetail, - useDashboardUsers, -} from "./hooks/useDashboard"; - -export function DashboardPanel() { - const [activeTab, setActiveTab] = useState("stats"); - const [selectedUserId, setSelectedUserId] = useState(null); - const [selectedChannelId, setSelectedChannelId] = useState( - null, - ); - const { - users, - loading: usersLoading, - error: usersError, - search: userSearch, - setSearch: setUserSearch, - loadMore: loadMoreUsers, - hasMore: hasMoreUsers, - refetch: refetchUsers, - } = useDashboardUsers(); - const { - detail: userDetail, - loading: userDetailLoading, - error: userDetailError, - refetch: refetchUserDetail, - } = useDashboardUserDetail(selectedUserId); - const { - channels, - loading: channelsLoading, - error: channelsError, - search: channelSearch, - setSearch: setChannelSearch, - loadMore: loadMoreChannels, - hasMore: hasMoreChannels, - refetch: refetchChannels, - } = useDashboardChannels(); - const { - detail: channelDetail, - loading: channelDetailLoading, - error: channelDetailError, - refetch: refetchChannelDetail, - } = useDashboardChannelDetail(selectedChannelId); - - // Show user detail view - if (selectedUserId) { - return ( - { - setSelectedUserId(null); - }} - onRefetch={refetchUserDetail} - /> - ); - } - - // Show channel detail view - if (selectedChannelId) { - return ( - { - setSelectedChannelId(null); - }} - onRefetch={refetchChannelDetail} - /> - ); - } - - return ( - - - Stats - Users - Channels - - - Admin - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/services/frontend/src/features/live/components/ActiveSpeakers.tsx b/services/frontend/src/features/live/components/ActiveSpeakers.tsx deleted file mode 100644 index 96e80ce..0000000 --- a/services/frontend/src/features/live/components/ActiveSpeakers.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { ActiveSpeaker } from "../../../entities/voice/types.js"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; - -interface ActiveSpeakersProps { - speakers: ActiveSpeaker[]; -} - -export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) { - if (speakers.length === 0) { - return ; - } - - return ( -
- {speakers.map((s) => { - const key = s.userId ?? s.id ?? `speaker-${s.username}`; - return ( -
- -
-
{s.username}
-
- - - {s.speaking ? "Speaking" : "Silent"} - -
-
-
- ); - })} -
- ); -} diff --git a/services/frontend/src/features/live/components/AudioVisualizer.tsx b/services/frontend/src/features/live/components/AudioVisualizer.tsx deleted file mode 100644 index ab6c113..0000000 --- a/services/frontend/src/features/live/components/AudioVisualizer.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect, useRef } from "react"; - -interface AudioVisualizerProps { - levels: number[]; -} - -export function AudioVisualizer({ levels }: AudioVisualizerProps) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - - const ro = new ResizeObserver(() => { - const rect = container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - canvas.width = rect.width * dpr; - canvas.height = 128 * dpr; - canvas.style.height = "128px"; - }); - ro.observe(container); - return () => ro.disconnect(); - }, []); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const width = canvas.width / dpr; - const height = canvas.height / dpr; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - const barWidth = width / levels.length; - const maxBarHeight = height * 0.85; - - const gradient = ctx.createLinearGradient(0, 0, 0, height); - gradient.addColorStop(0, "#23a1eb"); - gradient.addColorStop(1, "#3eb0f2"); - - for (let i = 0; i < levels.length; i++) { - const level = levels[i]; - const barHeight = Math.min(maxBarHeight, level * maxBarHeight); - const x = i * barWidth; - const y = height - barHeight; - - ctx.fillStyle = gradient; - - const radius = barWidth * 0.4; - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + barWidth - radius, y); - ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius); - ctx.lineTo(x + barWidth, height); - ctx.lineTo(x, height); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.fill(); - } - }, [levels]); - - return ( -
- -
- ); -} diff --git a/services/frontend/src/features/live/components/MicLevelMeter.tsx b/services/frontend/src/features/live/components/MicLevelMeter.tsx deleted file mode 100644 index e83b52d..0000000 --- a/services/frontend/src/features/live/components/MicLevelMeter.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// ─── Mic level meter — vertical bar showing outgoing audio RMS level ───────── - -interface MicLevelMeterProps { - level: number; // 0-1 -} - -export function MicLevelMeter({ level }: MicLevelMeterProps) { - const pct = Math.round(level * 100); - - // Color gradient: green <-> yellow <-> red - const hue = 120 - level * 120; // 120 (green) -> 0 (red) - const bg = `hsl(${hue}, 80%, 45%)`; - - return ( -
-
-
- ); -} diff --git a/services/frontend/src/features/live/components/MusicSubPanel.tsx b/services/frontend/src/features/live/components/MusicSubPanel.tsx deleted file mode 100644 index 1b19855..0000000 --- a/services/frontend/src/features/live/components/MusicSubPanel.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Input } from "../../../shared/ui"; - -interface MusicSubPanelProps { - volume: number; - onVolumeChange: (v: number) => void; - onQueue: (source: string) => void; - onSkip: () => void; - onStop: () => void; - loading: boolean; -} - -export function MusicSubPanel({ - volume, - onVolumeChange, - onQueue, - onSkip, - onStop, - loading, -}: MusicSubPanelProps) { - const [source, setSource] = useState(""); - const safeVolume = Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : 1; - const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100)); - const [muted, setMuted] = useState(false); - const prevVolumeRef = useRef(safeVolume); - const debounceRef = useRef | null>(null); - - // Proper debounce: setTimeout instead of setInterval polling - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - const normalized = draftVolume / 100; - if (Math.abs(normalized - safeVolume) >= 0.001) - onVolumeChange(normalized); - }, 200); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [draftVolume, safeVolume, onVolumeChange]); - - const handleMute = useCallback(() => { - if (muted) { - // Unmute: restore previous volume - const restore = prevVolumeRef.current; - setDraftVolume(Math.round(restore * 100)); - onVolumeChange(restore); - setMuted(false); - } else { - // Mute: save current, set to 0 - prevVolumeRef.current = safeVolume; - setDraftVolume(0); - onVolumeChange(0); - setMuted(true); - } - }, [muted, safeVolume, onVolumeChange]); - - const submit = () => { - const t = source.trim(); - if (!t) return; - onQueue(t); - setSource(""); - }; - - return ( -
- setSource(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - placeholder="YouTube URL, Spotify track, or search terms" - /> -
- - { - setDraftVolume(Number(e.target.value)); - if (muted) setMuted(false); - }} - className="h-2 w-full cursor-pointer accent-primary" - /> - - {draftVolume}% - -
-
- - - -
-
- ); -} diff --git a/services/frontend/src/features/live/components/NowPlaying.tsx b/services/frontend/src/features/live/components/NowPlaying.tsx deleted file mode 100644 index 2ea9d74..0000000 --- a/services/frontend/src/features/live/components/NowPlaying.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { MonitorUp, Music2 } from "lucide-react"; -import type { MediaItem } from "../../../entities/media/types.js"; -import { Badge } from "../../../shared/ui"; - -interface NowPlayingProps { - current: MediaItem | null; - queue: MediaItem[]; -} - -export function NowPlaying({ current, queue }: NowPlayingProps) { - if (!current) return null; - - return ( -
-
-
-
- {current.mode === "screen" ? ( - - ) : ( - - )} -
-
-
{current.title}
-
- {current.source} -
-
- - {current.mode ?? "music"} - -
-
- - {queue.length > 0 && ( -
-
Queue ({queue.length})
-
- {queue.map((item, i) => ( -
- - {i + 1} - -
-
{item.title}
-
- {item.source} -
-
-
- ))} -
-
- )} -
- ); -} diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx deleted file mode 100644 index 43ecfad..0000000 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// ─── Recordings Sub-Panel ── - -import { Download, Mic, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import type { VoiceRecording } from "../../../entities/recording/types.js"; -import { deleteRecording, listRecordings } from "../../../shared/api/client"; -import { formatBytes, formatDate } from "../../../shared/lib/utils"; -import { Badge, Button, Skeleton } from "../../../shared/ui"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; -import { WaveformPlayer } from "./WaveformPlayer"; - -export function RecordingsSubPanel() { - const [recordings, setRecordings] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [deletingIds, setDeletingIds] = useState>(new Set()); - - const loadRecordings = useCallback( - async (opts?: { signal?: AbortSignal }) => { - try { - setLoading(true); - setError(null); - const data = await listRecordings({ limit: 50 }); - if (!opts?.signal?.aborted) { - setRecordings(data.items); - setNextCursor(data.nextCursor); - setHasMore(data.hasMore); - } - } catch (err) { - if (!opts?.signal?.aborted) - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!opts?.signal?.aborted) setLoading(false); - } - }, - [], - ); - - const loadMore = useCallback(async () => { - if (!nextCursor || loadingMore) return; - try { - setLoadingMore(true); - const data = await listRecordings({ limit: 50, cursor: nextCursor }); - setRecordings((prev) => [...prev, ...data.items]); - setNextCursor(data.nextCursor); - setHasMore(data.hasMore); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoadingMore(false); - } - }, [nextCursor, loadingMore]); - - useEffect(() => { - const ab = new AbortController(); - loadRecordings({ signal: ab.signal }); - const handler = () => loadRecordings(); - window.addEventListener("voice_recording_uploaded", handler); - return () => { - ab.abort(); - window.removeEventListener("voice_recording_uploaded", handler); - }; - }, [loadRecordings]); - - const handleDelete = useCallback(async (id: string) => { - if (!confirm("Delete this recording?")) return; - setDeletingIds((prev) => new Set(prev).add(id)); - try { - await deleteRecording(id); - setRecordings((prev) => prev.filter((r) => r.id !== id)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setDeletingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - } - }, []); - - if (loading) { - return ( -
- {[1, 2, 3].map((i) => ( -
- -
- - -
-
- ))} -
- ); - } - - if (error) { - return ( -
- {error} -
- -
-
- ); - } - - if (recordings.length === 0) { - return ; - } - - return ( -
- {recordings.map((rec) => ( -
-
-
- -
-
-
{rec.filename}
-
- {rec.username} - · - {rec.channel_name ?? rec.channel_id ?? "unknown"} - · - {formatDate(rec.created_at)} - · - {formatBytes(rec.size_bytes)} -
- {rec.upload_error && ( -
- {rec.upload_error} -
- )} - {rec.transcription && ( -
- {rec.transcription} -
- )} -
-
- - - {rec.upload_status} - - {rec.download_url && ( - - - - )} -
-
- {rec.download_url && ( -
- -
- )} -
- ))} - {hasMore && ( -
- -
- )} -
- ); -} diff --git a/services/frontend/src/features/live/components/ScreenSubPanel.tsx b/services/frontend/src/features/live/components/ScreenSubPanel.tsx deleted file mode 100644 index 05c8dbd..0000000 --- a/services/frontend/src/features/live/components/ScreenSubPanel.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { MonitorUp, SkipForward, Square } from "lucide-react"; -import { useState } from "react"; -import { Button, Input } from "../../../shared/ui"; - -interface ScreenSubPanelProps { - onStart: (source: string) => void; - onSkip: () => void; - onStop: () => void; - loading: boolean; -} - -export function ScreenSubPanel({ - onStart, - onSkip, - onStop, - loading, -}: ScreenSubPanelProps) { - const [source, setSource] = useState(""); - const submit = () => { - const t = source.trim(); - if (!t) return; - onStart(t); - setSource(""); - }; - - return ( -
- setSource(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - placeholder="Screen share URL or local file path" - /> -
- - - -
-
- ); -} diff --git a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx deleted file mode 100644 index 47381de..0000000 --- a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Headphones, Radio } from "lucide-react"; -import type { Channel, Guild } from "../../../entities/guild/types.js"; -import type { VoiceStatus } from "../../../entities/voice/types.js"; -import { Button, Select } from "../../../shared/ui"; -import { MicLevelMeter } from "./MicLevelMeter"; - -interface VoiceConnectionCardProps { - guilds: Guild[]; - voiceChannels: Channel[]; - selectedGuild: string; - selectedChannel: string; - status: VoiceStatus; - voiceLoading: boolean; - isListening: boolean; - isStreaming: boolean; - micLevel: number; - onGuildChange: (id: string) => void; - onChannelChange: (id: string) => void; - onJoin: () => void; - onDisconnect: () => void; - onListenToggle: () => void; - onStreamingToggle: () => void; -} - -export function VoiceConnectionCard({ - guilds, - voiceChannels, - selectedGuild, - selectedChannel, - status, - voiceLoading, - isListening, - isStreaming, - micLevel, - onGuildChange, - onChannelChange, - onJoin, - onDisconnect, - onListenToggle, - onStreamingToggle, -}: VoiceConnectionCardProps) { - return ( -
-
-

- Voice Bridge -

-

- Join a Discord voice channel, listen, and transmit audio. -

- -
-
- - onChannelChange(e.target.value)} - placeholder="Select voice channel" - options={voiceChannels.map((c) => ({ - value: c.id, - label: c.name, - }))} - /> -
-
- -
- - - - - {isStreaming && ( -
- - Hold Space - - -
- )} -
-
-
- ); -} diff --git a/services/frontend/src/features/live/components/WaveformPlayer.tsx b/services/frontend/src/features/live/components/WaveformPlayer.tsx deleted file mode 100644 index 5a7418b..0000000 --- a/services/frontend/src/features/live/components/WaveformPlayer.tsx +++ /dev/null @@ -1,256 +0,0 @@ -// ─── Waveform Player — audio visualizer with seekable waveform bars ────────── - -import { Pause, Play } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { createLogger } from "../../../shared/lib/logger"; - -const logger = createLogger("waveform-player"); - -const BAR_COUNT = 64; -const SAMPLE_RATE = 24000; - -interface WaveformPlayerProps { - downloadUrl: string; - filename: string; -} - -export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) { - const [playing, setPlaying] = useState(false); - const [peaks, setPeaks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const canvasRef = useRef(null); - const containerRef = useRef(null); - const audioContextRef = useRef(null); - const sourceRef = useRef(null); - const startTimeRef = useRef(0); - const startOffsetRef = useRef(0); - const rafRef = useRef(0); - const decodedRef = useRef(null); - const durationRef = useRef(0); - - // Decode audio on mount - useEffect(() => { - let cancelled = false; - const ctx = new AudioContext(); - audioContextRef.current = ctx; - - fetch(downloadUrl) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.arrayBuffer(); - }) - .then((buf) => ctx.decodeAudioData(buf)) - .then((audioBuffer) => { - if (cancelled) return; - decodedRef.current = audioBuffer; - durationRef.current = audioBuffer.duration; - - // Compute waveform peaks - const channel = audioBuffer.getChannelData(0); - const samplesPerBar = Math.floor(channel.length / BAR_COUNT); - const peakValues: number[] = []; - for (let i = 0; i < BAR_COUNT; i++) { - let max = 0; - const start = i * samplesPerBar; - const end = Math.min(start + samplesPerBar, channel.length); - for (let j = start; j < end; j++) { - const abs = Math.abs(channel[j]); - if (abs > max) max = abs; - } - // Clamp so silent sections still show a tiny bar - peakValues.push(Math.max(0.01, max)); - } - setPeaks(peakValues); - setLoading(false); - }) - .catch((err) => { - if (cancelled) return; - const msg = err instanceof Error ? err.message : String(err); - logger.error("Failed to decode audio", { error: msg }); - setError(msg); - setLoading(false); - }); - - return () => { - cancelled = true; - ctx.close(); - }; - }, [downloadUrl]); - - // Draw waveform on canvas whenever peaks change or while playing - const drawWaveform = useCallback( - (progress = 0) => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const dpr = window.devicePixelRatio || 1; - const rect = container.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = 64 * dpr; - canvas.style.height = "64px"; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, rect.width, 64); - - if (peaks.length === 0) return; - - const barWidth = rect.width / peaks.length; - const barGap = Math.max(1, barWidth * 0.15); - const barActualWidth = barWidth - barGap; - const progressPixel = rect.width * progress; - - for (let i = 0; i < peaks.length; i++) { - const x = i * barWidth; - const height = Math.max(2, peaks[i] * 50); - const y = 32 - height / 2; - - // Color: played vs unplayed - const isPlayed = x + barWidth <= progressPixel; - ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155"; - ctx.fillRect(x, y, barActualWidth, height); - } - }, - [peaks], - ); - - // Initial draw when peaks change - useEffect(() => { - drawWaveform(); - }, [drawWaveform]); - - // Animation loop while playing - useEffect(() => { - if (!playing || !decodedRef.current) return; - - const tick = () => { - if (!audioContextRef.current) return; - const elapsed = - audioContextRef.current.currentTime - startTimeRef.current; - const progress = (elapsed + startOffsetRef.current) / durationRef.current; - drawWaveform(Math.min(1, Math.max(0, progress))); - - if (progress >= 1) { - setPlaying(false); - return; - } - rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - - return () => cancelAnimationFrame(rafRef.current); - }, [playing, drawWaveform]); - - const handleTogglePlay = useCallback(() => { - const ctx = audioContextRef.current; - const buffer = decodedRef.current; - if (!ctx || !buffer) return; - - if (playing) { - // Pause - if (sourceRef.current) { - startOffsetRef.current += ctx.currentTime - startTimeRef.current; - sourceRef.current.stop(); - sourceRef.current.disconnect(); - sourceRef.current = null; - } - setPlaying(false); - return; - } - - // Resume / start - const source = ctx.createBufferSource(); - source.buffer = buffer; - source.connect(ctx.destination); - source.start(0, startOffsetRef.current); - startTimeRef.current = ctx.currentTime; - sourceRef.current = source; - setPlaying(true); - - source.onended = () => { - if (sourceRef.current === source) { - setPlaying(false); - sourceRef.current = null; - } - }; - }, [playing]); - - const handleSeek = useCallback( - (e: React.MouseEvent) => { - if (!decodedRef.current) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const progress = Math.max(0, Math.min(1, x / rect.width)); - const offset = progress * durationRef.current; - - const ctx = audioContextRef.current; - if (ctx && sourceRef.current) { - sourceRef.current.stop(); - sourceRef.current.disconnect(); - } - - startOffsetRef.current = offset; - startTimeRef.current = ctx?.currentTime ?? 0; - drawWaveform(progress); - - if (playing && ctx) { - const buffer = decodedRef.current; - const source = ctx.createBufferSource(); - source.buffer = buffer; - source.connect(ctx.destination); - source.start(0, offset); - startTimeRef.current = ctx.currentTime; - sourceRef.current = source; - source.onended = () => { - if (sourceRef.current === source) { - setPlaying(false); - sourceRef.current = null; - } - }; - } - }, - [playing, drawWaveform], - ); - - if (loading) { - return
; - } - - if (error) { - return ( -
- {error} -
- ); - } - - if (peaks.length === 0) return null; - - return ( -
- -
- -
-
- ); -} diff --git a/services/frontend/src/features/live/hooks/useMediaControl.ts b/services/frontend/src/features/live/hooks/useMediaControl.ts deleted file mode 100644 index 915f369..0000000 --- a/services/frontend/src/features/live/hooks/useMediaControl.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { MediaState } from "../../../entities/media/types.js"; -import { - getMediaStatus, - queueMedia, - setMediaVolume, - skipMedia, - stopMedia, -} from "../../../shared/api/client"; -import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js"; -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-media-control"); - -const emptyMediaState: MediaState = { - playing: false, - musicVolume: 1, - current: null, - queue: [], -}; - -export function useMediaControl() { - const [mediaState, setMediaState] = useState(emptyMediaState); - const { loading, error, execute, clearError } = useAsyncAction(); - - const refreshMedia = useCallback(async () => { - const state = await getMediaStatus(); - setMediaState(state); - return state; - }, []); - - const enqueue = useCallback( - async (source: string, mode: "music" | "screen") => { - const result = await execute(() => queueMedia(source, mode)); - if (result) { - setMediaState(result); - logger.info("Media queued", { source, mode }); - } else { - logger.error("Failed to queue media", { source, mode }); - } - return result; - }, - [execute], - ); - - const skip = useCallback(async () => { - const result = await execute(() => skipMedia()); - if (result) { - setMediaState(result); - logger.info("Media skipped"); - } else { - logger.error("Failed to skip media"); - } - return result; - }, [execute]); - - const stop = useCallback(async () => { - const result = await execute(() => stopMedia()); - if (result) { - setMediaState(result); - logger.info("Media stopped"); - } else { - logger.error("Failed to stop media"); - } - return result; - }, [execute]); - - const setVolume = useCallback( - async (volume: number) => { - clearError(); - try { - const state = await setMediaVolume(volume); - setMediaState(state); - logger.info("Volume set", { volume }); - return state; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Failed to set volume", { volume, error: message }); - throw err; - } - }, - [clearError], - ); - - useEffect(() => { - refreshMedia().catch((err) => - logger.error("Failed to refresh media state on mount", { - error: String(err), - }), - ); - }, [refreshMedia]); - - return { - mediaState, - setMediaState, - loading, - error, - refreshMedia, - enqueue, - skip, - stop, - setVolume, - }; -} diff --git a/services/frontend/src/features/live/hooks/useVoiceControl.ts b/services/frontend/src/features/live/hooks/useVoiceControl.ts deleted file mode 100644 index 318f5f0..0000000 --- a/services/frontend/src/features/live/hooks/useVoiceControl.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { Channel, Guild } from "../../../entities/guild/types.js"; -import type { VoiceStatus } from "../../../entities/voice/types.js"; -import { - connectVoice, - disconnectVoice, - getGuilds, - getTextChannels, - getVoiceChannels, - getVoiceStatus, -} from "../../../shared/api/client"; -import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js"; -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-voice-control"); - -export function useVoiceControl() { - const [guilds, setGuilds] = useState([]); - const [voiceChannels, setVoiceChannels] = useState([]); - const [textChannels, setTextChannels] = useState([]); - const [voiceStatus, setVoiceStatus] = useState({ - connected: false, - activeGuildId: null, - activeChannelId: null, - activeChannelName: null, - connections: [], - }); - const { loading, error, execute, clearError } = useAsyncAction(); - - const refreshGuilds = useCallback(async () => { - clearError(); - const nextGuilds = await getGuilds(); - setGuilds(nextGuilds); - return nextGuilds; - }, [clearError]); - - const refreshVoiceStatus = useCallback(async () => { - const status = await getVoiceStatus(); - setVoiceStatus(status); - return status; - }, []); - - const loadVoiceChannels = useCallback(async (guildId: string) => { - if (!guildId) { - setVoiceChannels([]); - return []; - } - const channels = await getVoiceChannels(guildId); - setVoiceChannels(channels); - return channels; - }, []); - - const loadTextTargets = useCallback(async (guildId: string) => { - if (!guildId) { - setTextChannels([]); - return []; - } - const channels = await getTextChannels(guildId); - setTextChannels(channels); - return channels; - }, []); - - const joinVoice = useCallback( - async (guildId: string, channelId: string) => { - const result = await execute(() => connectVoice(guildId, channelId)); - if (result) { - setVoiceStatus(result); - logger.info("Connected to voice", { guildId, channelId }); - } else { - logger.error("Failed to connect to voice", { guildId, channelId }); - } - return result; - }, - [execute], - ); - - const leaveVoice = useCallback(async () => { - const result = await execute(() => disconnectVoice()); - if (result) { - setVoiceStatus(result); - logger.info("Disconnected from voice"); - } else { - logger.error("Failed to disconnect from voice"); - } - return result; - }, [execute]); - - useEffect(() => { - refreshGuilds().catch((err) => - logger.error("Failed to refresh guilds on mount", { error: String(err) }), - ); - refreshVoiceStatus().catch((err) => - logger.error("Failed to refresh voice status on mount", { - error: String(err), - }), - ); - }, [refreshGuilds, refreshVoiceStatus]); - - return { - guilds, - voiceChannels, - textChannels, - voiceStatus, - loading, - error, - refreshGuilds, - refreshVoiceStatus, - loadVoiceChannels, - loadTextTargets, - joinVoice, - leaveVoice, - }; -} diff --git a/services/frontend/src/features/live/index.tsx b/services/frontend/src/features/live/index.tsx deleted file mode 100644 index 60f5783..0000000 --- a/services/frontend/src/features/live/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// ─── Live Panel — thin composition layer ──────────────────────────────────── - -import { motion } from "framer-motion"; -import { Mic, MonitorUp, Music2 } from "lucide-react"; -import type { Channel, Guild } from "../../entities/guild/types.js"; -import type { MediaState } from "../../entities/media/types.js"; -import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js"; -import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; -import { ActiveSpeakers } from "./components/ActiveSpeakers"; -import { AudioVisualizer } from "./components/AudioVisualizer"; -import { MusicSubPanel } from "./components/MusicSubPanel"; -import { NowPlaying } from "./components/NowPlaying"; -import { RecordingsSubPanel } from "./components/RecordingsSubPanel"; -import { ScreenSubPanel } from "./components/ScreenSubPanel"; -import { VoiceConnectionCard } from "./components/VoiceConnectionCard"; - -interface LivePanelProps { - guilds: Guild[]; - voiceChannels: Channel[]; - selectedGuild: string; - selectedChannel: string; - status: VoiceStatus; - voiceLoading: boolean; - activeSpeakers: ActiveSpeaker[]; - levels: number[]; - isListening: boolean; - isStreaming: boolean; - micLevel: number; - mediaState: MediaState; - mediaLoading: boolean; - onGuildChange: (id: string) => void; - onChannelChange: (id: string) => void; - onJoin: () => void; - onDisconnect: () => void; - onListenToggle: () => void; - onStreamingToggle: () => void; - onQueueMusic: (source: string) => void; - onStartScreen: (source: string) => void; - onSkip: () => void; - onStop: () => void; - onVolumeChange: (v: number) => void; -} - -export function LivePanel({ - guilds, - voiceChannels, - selectedGuild, - selectedChannel, - status, - voiceLoading, - activeSpeakers, - levels, - isListening, - isStreaming, - micLevel, - mediaState, - mediaLoading, - onGuildChange, - onChannelChange, - onJoin, - onDisconnect, - onListenToggle, - onStreamingToggle, - onQueueMusic, - onStartScreen, - onSkip, - onStop, - onVolumeChange, -}: LivePanelProps) { - return ( - - - - - - - - - Live Audio - - - - - - - - Active Speakers - - - - - - - - - - - - - - - - Music - - - Screen Share - - - Recordings - - - - - - - - - - - - - - - ); -} diff --git a/services/frontend/src/features/messages/components/ImageGrid.tsx b/services/frontend/src/features/messages/components/ImageGrid.tsx deleted file mode 100644 index c01b629..0000000 --- a/services/frontend/src/features/messages/components/ImageGrid.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { MessageRecord } from "../../../entities/message/types.js"; -import { parseMetadata } from "../../../shared/lib/utils.js"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; - -interface ImageItem { - url: string; - title: string; - kind: "attachment" | "embed" | "sticker"; - message: MessageRecord; -} - -function kindBadge(kind: ImageItem["kind"]): string { - switch (kind) { - case "sticker": - return "bg-primary/10 text-primary border-primary/20"; - case "attachment": - return "bg-primary-soft text-primary border-primary/30"; - case "embed": - return "bg-purple-100 text-purple-700 border-purple-200"; - } -} - -export function ImageGrid({ messages }: { messages: MessageRecord[] }) { - const images: ImageItem[] = []; - - for (const message of messages) { - const metadata = parseMetadata(message.metadata); - - // Stickers - for (const sticker of metadata.stickers ?? []) { - if (sticker.url) { - images.push({ - url: sticker.url, - title: sticker.name || "sticker", - kind: "sticker", - message, - }); - } - } - - // Attachments - for (const attachment of metadata.attachments ?? []) { - if ( - attachment.url && - (attachment.contentType?.startsWith("image/") || - /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)) - ) { - images.push({ - url: attachment.url, - title: attachment.name, - kind: "attachment", - message, - }); - } - } - - // Embed images - for (const embed of metadata.embeds ?? []) { - for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) { - images.push({ - url: imgUrl as string, - title: embed.title || "embed image", - kind: "embed", - message, - }); - } - } - } - - if (images.length === 0) { - return ; - } - - return ( -
- {images.map((image) => { - // Stable key using message.id + url - const stableKey = `${image.message.id}-${image.kind}-${image.url}`; - return ( - -
- {image.kind === "sticker" ? ( - {image.title} - ) : ( - {image.title} - )} - - {image.kind} - -
-
-
{image.title}
-
-
- -
- - {image.message.username} - -
-
-
- ); - })} -
- ); -} diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx deleted file mode 100644 index 8fcf787..0000000 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import { - AlertCircle, - CheckCircle2, - Forward, - Hash, - Image as ImageIcon, - MessageCircle, - Pencil, - Reply, - RotateCw, - Smile, - Trash2, - Video, -} from "lucide-react"; -import { Fragment, useEffect, useMemo, useState } from "react"; -import type { MessageRecord } from "../../../entities/message/types.js"; -import { parseMetadata } from "../../../shared/lib/utils.js"; -import { getMessageById } from "../../../shared/api/client.js"; -import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui"; - -const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; - -function renderContentWithCustomEmojis(content: string): React.ReactNode { - const parts: React.ReactNode[] = []; - const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g"); - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = regex.exec(content)) !== null) { - if (match.index > lastIndex) { - parts.push(content.slice(lastIndex, match.index)); - } - const [, animated, name, id] = match; - const ext = animated ? "gif" : "png"; - const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`; - parts.push( - {name} { - const target = e.currentTarget; - target.style.display = "none"; - }} - />, - ); - lastIndex = regex.lastIndex; - } - if (lastIndex < content.length) { - parts.push(content.slice(lastIndex)); - } - if (parts.length === 0) return content; - return {parts}; -} - -// ─── Props ─────────────────────────────────────────────────────────────────── - -interface MessageCardProps { - messages: MessageRecord[]; - onReanalyze: (id: string) => Promise; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function parseStringList(value?: string | null): string[] { - if (!value) return []; - try { - const parsed = JSON.parse(value) as unknown; - return Array.isArray(parsed) - ? parsed.filter((item): item is string => typeof item === "string") - : []; - } catch { - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - } -} - -function severityColor(severity: string) { - switch (severity) { - case "critical": - 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": - 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": - 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": - return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800"; - default: - return "bg-muted text-muted-foreground border-border"; - } -} - -function formatTimeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return new Date(ts).toLocaleDateString(); -} - -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); -} - -// ─── Single message row inside a group ─────────────────────────────────────── - -function MessageRow({ - message, - onReanalyze, -}: { - message: MessageRecord; - onReanalyze: (id: string) => Promise; -}) { - const metadata = useMemo( - () => parseMetadata(message.metadata), - [message.metadata], - ); - const displayContent = message.edited_content ?? message.content; - const aiStatus = message.ai_status ?? "pending"; - const categories = useMemo(() => { - const list = parseStringList( - message.ai_categories ?? message.ai_moderation_flags, - ); - return list.filter((c) => c !== "analysis_incomplete"); - }, [message.ai_categories, message.ai_moderation_flags]); - const confidence = - message.ai_confidence ?? message.ai_moderation_score ?? null; - const [isReanalyzing, setIsReanalyzing] = useState(false); - - // ── Fetch referenced message content for replies if not in metadata ── - const referenceMeta = metadata.reference; - const [fetchedRefContent, setFetchedRefContent] = useState<{ - username: string; - content: string; - } | null>(null); - - useEffect(() => { - if ( - message.is_reply && - referenceMeta?.messageId && - !referenceMeta?.content && - !message.deleted_at - ) { - getMessageById(referenceMeta.messageId) - .then((refMsg) => { - if (refMsg) { - setFetchedRefContent({ - username: refMsg.username, - content: refMsg.content, - }); - } - }) - .catch(() => { - // Referenced message might not exist in our DB - }); - } - }, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]); - - const analysisSummary = useMemo(() => { - const parts: string[] = []; - if (categories.length > 0) { - parts.push(categories.slice(0, 3).join(", ")); - if (categories.length > 3) parts.push(`+${categories.length - 3} more`); - } - if (message.ai_severity && message.ai_severity !== "none") { - parts.push(message.ai_severity); - } - if (confidence != null) { - parts.push(`${Math.round(confidence * 100)}% confidence`); - } - if (parts.length === 0) return "View AI analysis"; - return parts.join(" · "); - }, [categories, message.ai_severity, confidence]); - - const stickers = metadata.stickers ?? []; - const attachments = metadata.attachments ?? []; - const imageAttachments = attachments.filter( - (a) => - a.contentType?.startsWith("image/") || - /\.(png|jpe?g|gif|webp)$/i.test(a.name), - ); - const videoAttachments = attachments.filter( - (a) => - a.contentType?.startsWith("video/") || - /\.(mp4|webm|mov|mkv|avi)$/i.test(a.name), - ); - const hasImages = imageAttachments.length > 0; - const hasVideos = videoAttachments.length > 0; - - /** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */ - const isFallbackText = - /^\[(Attachment|Sticker):/i.test(displayContent) || - /^\[Embed\]/i.test(displayContent); - const shouldShowContent = displayContent && !isFallbackText; - - const handleReanalyze = async () => { - setIsReanalyzing(true); - try { - await onReanalyze(message.id); - } finally { - setIsReanalyzing(false); - } - }; - - // ── Reference context (reply / forward / crosspost) ───────────────── - const renderReferenceIndicator = () => { - // Use fetched content if metadata doesn't have it - const effectiveRepliedUsername = - referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null; - const effectiveRepliedContent = - referenceMeta?.content ?? fetchedRefContent?.content ?? null; - - if (message.is_reply) { - return ( -
- - - - Replying to{" "} - {effectiveRepliedUsername - ? `@${effectiveRepliedUsername}` - : "a message"} - - {effectiveRepliedContent && ( - - {effectiveRepliedContent} - - )} - -
- ); - } - - if (message.is_forward) { - return ( -
- - Forwarded -
- ); - } - - if (message.is_crosspost) { - return ( -
- - Crossposted -
- ); - } - - return null; - }; - - const referenceIndicator = renderReferenceIndicator(); - - return ( -
- {/* Row header: time + edit/delete indicators + AI badges */} -
- - {formatTime(message.created_at)} - - {message.edited_at && ( - - edited - - )} - {message.deleted_at && ( - - deleted - - )} -
- - {aiStatus === "clean" && } - {aiStatus === "flagged" && } - {aiStatus === "error" && } - - {message.ai_severity && message.ai_severity !== "none" && ( - - {message.ai_severity} - - )} - {confidence != null && ( - - {Math.round(confidence * 100)}% - - )} -
-
- - {/* Reference context: reply / forward / crosspost */} - {referenceIndicator} - - {/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */} - {shouldShowContent ? ( -

- {renderContentWithCustomEmojis(displayContent)} -

- ) : null} - - {/* Stickers */} - {stickers.length > 0 && ( -
- {stickers.map((sticker) => ( -
- {sticker.url ? ( - {sticker.name { - const target = e.currentTarget; - target.style.display = "none"; - }} - /> - ) : ( -
- -
- )} -
- ))} -
- )} - - {/* Attached images */} - {hasImages && ( -
- {imageAttachments.slice(0, 4).map((img) => ( - - {img.name} { - const target = e.currentTarget; - target.style.display = "none"; - }} - /> - - ))} - {imageAttachments.length > 4 && ( -
- +{imageAttachments.length - 4} - -
- )} -
- )} - - {/* Attached videos */} - {hasVideos && ( -
- {videoAttachments.slice(0, 4).map((vid) => ( -
- )} - - {/* Categories */} - {categories.length > 0 && ( -
- {categories.map((category) => ( - - {category} - - ))} -
- )} - - {/* AI Analysis — always expanded */} - {message.ai_analysis ? ( -
-
- - {aiStatus === "flagged" ? "🚨" : "ℹ️"} - -
- - {analysisSummary} - -
- {message.ai_analysis} -
-
-
-
- ) : null} - - {/* AI Error */} - {message.ai_error ? ( -
- AI error: {message.ai_error} -
- ) : null} - - {/* Re-analyze button */} -
- - {aiStatus === "error" && ( - - Click to retry analysis - - )} -
-
- ); -} - -// ─── Group card: one card per user group ───────────────────────────────────── - -export function MessageCard({ messages, onReanalyze }: MessageCardProps) { - const firstMsg = messages[0]; - const hasMultiple = messages.length > 1; - const meta = useMemo( - () => parseMetadata(firstMsg.metadata), - [firstMsg.metadata], - ); - const channelMeta = meta.channel; - const locationLabel = useMemo(() => { - if (channelMeta?.threadName) { - return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`; - } - if (channelMeta?.channelName) { - return `# ${channelMeta.channelName}`; - } - return null; - }, [channelMeta]); - - return ( -
-
- {/* Avatar — only for first message */} - { - const target = e.currentTarget; - target.src = "https://cdn.discordapp.com/embed/avatars/0.png"; - }} - /> - -
- {/* Group header: username + location + timestamp */} -
- - {firstMsg.username || firstMsg.user_id} - - {locationLabel && ( - - - {locationLabel} - - )} - - {formatTimeAgo(firstMsg.created_at)} - {hasMultiple && ` · ${messages.length} messages`} - -
- - {/* Message rows — divided by separator when multiple */} -
- {messages.map((msg, idx) => ( -
0 ? "pt-2.5" : ""} - > - -
- ))} -
-
-
-
- ); -} - -// ─── Skeleton ──────────────────────────────────────────────────────────────── - -export function MessageCardSkeleton() { - return ( -
-
- -
- - - -
- - -
-
-
-
- ); -} diff --git a/services/frontend/src/features/messages/components/MessageFeed.tsx b/services/frontend/src/features/messages/components/MessageFeed.tsx deleted file mode 100644 index 2064fd3..0000000 --- a/services/frontend/src/features/messages/components/MessageFeed.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { motion } from "framer-motion"; -import { useEffect, useMemo, useRef } from "react"; -import type { MessageRecord } from "../../../entities/message/types.js"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { ScrollArea } from "../../../shared/ui"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; -import { MessageCard, MessageCardSkeleton } from "./MessageCard"; - -export interface MessageFeedProps { - messages: MessageRecord[]; - onReanalyze: (id: string) => Promise; - emptyText?: string; - loading?: boolean; - onLoadMore?: () => void; - hasMore?: boolean; - loadingMore?: boolean; -} - -/** Messages from the same user within 5 minutes are visually grouped. */ -const GROUP_WINDOW_MS = 5 * 60 * 1000; - -interface MessageGroup { - messages: MessageRecord[]; -} - -function groupMessages(messages: MessageRecord[]): MessageGroup[] { - const groups: MessageGroup[] = []; - for (const msg of messages) { - const lastGroup = groups[groups.length - 1]; - if ( - lastGroup && - lastGroup.messages[0].user_id === msg.user_id && - lastGroup.messages[lastGroup.messages.length - 1].created_at - - msg.created_at < - GROUP_WINDOW_MS - ) { - lastGroup.messages.push(msg); - } else { - groups.push({ messages: [msg] }); - } - } - return groups; -} - -export function MessageFeed({ - messages, - onReanalyze, - emptyText: _emptyText, - loading, - onLoadMore, - hasMore, - loadingMore, -}: MessageFeedProps) { - // IntersectionObserver for infinite scroll — fires when sentinel becomes visible - const sentinelRef = useRef(null); - - useEffect(() => { - if (!onLoadMore || !hasMore) return; - const el = sentinelRef.current; - if (!el) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) onLoadMore(); - }, - { rootMargin: "400px" }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [onLoadMore, hasMore]); - - const groupedMessages = useMemo(() => groupMessages(messages), [messages]); - - if (loading) { - return ( - -
- {[1, 2, 3, 4, 5].map((i) => ( - - ))} -
-
- ); - } - - if (messages.length === 0) { - return ; - } - - return ( - - - {groupedMessages.map((group) => ( - - - - ))} - - {/* Infinite-scroll sentinel */} - {hasMore && ( -
- {loadingMore ? ( - - ) : ( -
- )} -
- )} - - - ); -} diff --git a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx deleted file mode 100644 index a11afe6..0000000 --- a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// ─── Moderation alert toast listener ─────────────────────────────────────── -// Listens for "moderation_alert" custom events dispatched from WebSocket -// message_analyzed handler, and shows toast notifications for flagged -// messages so moderators don't miss important alerts. -import { useEffect } from "react"; -import { useToast } from "../../../shared/ui"; - -interface AlertDetail { - type: "flagged"; - username: string; - severity: string; - categories: string; - brief: string; -} - -function severityToToastType( - severity: string, -): "error" | "warning" | "info" | "success" { - switch (severity) { - case "critical": - case "high": - return "error"; - case "medium": - return "warning"; - case "low": - return "info"; - default: - return "warning"; - } -} - -export function ModerationAlertListener() { - const { addToast } = useToast(); - - useEffect(() => { - const handler = (e: Event) => { - const { username, severity, categories, brief } = ( - e as CustomEvent - ).detail; - - const sevLabel = severity ? `[${severity}]` : ""; - const catLabel = categories - ? ` — ${categories.split(",").slice(0, 2).join(", ")}` - : ""; - - addToast( - `🚨 ${username} ${sevLabel}${catLabel}: ${brief}`, - severityToToastType(severity), - ); - }; - - window.addEventListener("moderation_alert", handler); - return () => window.removeEventListener("moderation_alert", handler); - }, [addToast]); - - return null; -} diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx deleted file mode 100644 index b52b1fd..0000000 --- a/services/frontend/src/features/messages/index.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { motion } from "framer-motion"; -import { Filter, RotateCw, Search, X } from "lucide-react"; -import { useMemo, useState } from "react"; -import type { MessageRecord } from "../../shared/api/client"; -import { request } from "../../shared/api/client"; -import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; -import { ImageGrid } from "./components/ImageGrid"; -import { MessageFeed } from "./components/MessageFeed"; - -interface MessagesPanelProps { - guildName: string | null; - messages: MessageRecord[]; - onReanalyze: (id: string) => Promise; - onReanalyzeAllErrors?: () => Promise; - onLoadMore?: () => void; - hasMore?: boolean; - loadingMore?: boolean; -} - -type AiFilter = "all" | "analyzed" | "clean" | "flagged" | "error" | "pending"; - -export function MessagesPanel({ - guildName, - messages, - onReanalyze, - onReanalyzeAllErrors, - onLoadMore, - hasMore, - loadingMore, -}: MessagesPanelProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [showSearch, setShowSearch] = useState(false); - const [aiFilter, setAiFilter] = useState("analyzed"); - const [viewTab, setViewTab] = useState<"all" | "images">("all"); - const [retryingAll, setRetryingAll] = useState(false); - const [retriedCount, setRetriedCount] = useState(null); - - const handleSearch = async () => { - if (!searchQuery.trim()) { - setSearchResults([]); - setShowSearch(false); - return; - } - setIsSearching(true); - try { - const params = new URLSearchParams({ q: searchQuery, limit: "50" }); - const data = await request<{ results: MessageRecord[] }>( - `/api/analysis/search?${params}`, - ); - setSearchResults(data.results || []); - setShowSearch(true); - } catch { - setSearchResults([]); - } finally { - setIsSearching(false); - } - }; - - const stats = useMemo(() => { - const base = showSearch ? searchResults : messages; - return { - total: base.length, - clean: base.filter((m) => m.ai_status === "clean").length, - flagged: base.filter((m) => m.ai_status === "flagged").length, - error: base.filter((m) => m.ai_status === "error").length, - pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status) - .length, - deleted: base.filter((m) => m.deleted_at).length, - edited: base.filter((m) => m.edited_at).length, - }; - }, [messages, searchResults, showSearch]); - - const filteredMessages = useMemo(() => { - const base = showSearch ? searchResults : messages; - if (aiFilter === "all") return base; - return base.filter((m) => { - const status = m.ai_status ?? "pending"; - if (aiFilter === "analyzed") - return status !== "pending" && status !== null && status !== undefined; - if (aiFilter === "pending") - return status === "pending" || status === null || status === undefined; - return status === aiFilter; - }); - }, [messages, searchResults, showSearch, aiFilter]); - - return ( - - - - - Messages - {guildName && ( -

- Monitoring all text channels in{" "} - {guildName} -

- )} -
- -

- Messages are automatically captured from all text channels in the - monitored guild. Real-time updates arrive via WebSocket. -

-
-
-
- - {stats.total > 0 && ( - - - {stats.total} total{hasMore && !showSearch ? "+" : ""} - - - {stats.clean} clean - - - {stats.flagged} flagged - - - {stats.error} error - - - {stats.pending} pending - - {stats.deleted > 0 && ( - - {stats.deleted} deleted - - )} - {stats.edited > 0 && ( - - {stats.edited} edited - - )} - - )} - - -
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - disabled={isSearching} - /> -
- - {showSearch && ( - - )} - {stats.error > 0 && onReanalyzeAllErrors && ( - - )} - {retriedCount !== null && ( - - {retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for - re-analysis - - )} -
- - {( - [ - "all", - "analyzed", - "clean", - "flagged", - "error", - "pending", - ] as AiFilter[] - ).map((f) => ( - - ))} -
-
- - {showSearch && searchResults.length > 0 && ( - - Found {searchResults.length} result - {searchResults.length !== 1 ? "s" : ""} - - )} - - - setViewTab(v as "all" | "images")} - > - - - {showSearch - ? `Search (${filteredMessages.length})` - : `All (${filteredMessages.length})`} - - Images - - - - - - - - - -
- ); -} diff --git a/services/frontend/src/features/settings/index.tsx b/services/frontend/src/features/settings/index.tsx deleted file mode 100644 index c5f5b66..0000000 --- a/services/frontend/src/features/settings/index.tsx +++ /dev/null @@ -1,426 +0,0 @@ -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(loadNotifPrefs); - const [adminSettings, setAdminSettings] = useState(null); - const [adminSaving, setAdminSaving] = useState(false); - const [adminError, setAdminError] = useState(null); - const [adminSuccess, setAdminSuccess] = useState(null); - const adminSuccessTimerRef = useRef | 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) => { - 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 ( - - {/* ── Theme section ────────────────────────────────────────────── */} - - - - - - Theme - - - -
- {themeOptions.map((opt) => { - const Icon = opt.icon; - const isActive = themeMode === opt.value; - return ( - - ); - })} -
-

- Current: {isDark ? "Dark" : "Light"} - {themeMode === "system" && " (follows system)"} -

-
-
-
- - {/* ── Notifications section ────────────────────────────────────── */} - - - - - - Notifications - - - - {/* Toggle — enable/disable all notifs */} - - - {/* Toggle — sound */} - - - - - - {/* ── Admin section ────────────────────────────────────────────── */} - - - - - - Admin Settings - - - - {/* Dashboard visibility toggle */} -
-
-
- {adminSettings?.dashboardIsPublic ? ( - - ) : ( - - )} -
-

- Dashboard Visibility:{" "} - - {adminSettings?.dashboardIsPublic ? "Public" : "Private"} - -

-

- {adminSettings?.dashboardIsPublic - ? "Anyone can view the dashboard. Admin password still required for management." - : "Admin password required to access the dashboard."} -

-
-
- -
- {adminError && ( -

{adminError}

- )} - {adminSuccess && ( -

{adminSuccess}

- )} -
- - {/* Status indicators */} -
-
-

Runtime

-
- - {adminSettings?.dashboardIsPublic ? "Public" : "Private"} -
-
-
-

Env Default

-
- - {adminSettings?.envDashboardIsPublic ? "Public" : "Private"} -
-
-
- - {/* Logout */} -
- -
- - {/* Info */} -
-
- -

- Admin password is set via the ADMIN_PASSWORD env var. - Runtime settings are persisted in data/settings.json. -

-
-
-
-
-
- - {/* ── About section ────────────────────────────────────────────── */} - - - - About - - -

- Bete Dashboard v1.0 — Discord AI Moderation & Voice Recording - System. -

-

- Theme settings are saved locally. Notification preferences are - persisted across sessions. -

-
-
-
-
- ); -} diff --git a/services/frontend/src/hooks/useNotificationBadge.ts b/services/frontend/src/hooks/useNotificationBadge.ts deleted file mode 100644 index 4c75f04..0000000 --- a/services/frontend/src/hooks/useNotificationBadge.ts +++ /dev/null @@ -1,31 +0,0 @@ -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) }; -} diff --git a/services/frontend/src/islands/ActiveSpeakers.tsx b/services/frontend/src/islands/ActiveSpeakers.tsx new file mode 100644 index 0000000..42f6c9d --- /dev/null +++ b/services/frontend/src/islands/ActiveSpeakers.tsx @@ -0,0 +1,61 @@ +// ─── ActiveSpeakers.tsx — Live voice speaker list island ──────────────────── +// Self-contained: reads from useVoiceStore, renders speaker entries with +// animated speaking-indicator bars. +// ───────────────────────────────────────────────────────────────────────────── + +import { useVoiceStore } from "../stores/voice-store.js"; + +export default function ActiveSpeakers() { + const activeSpeakers = useVoiceStore((state) => state.activeSpeakers); + + if (activeSpeakers.length === 0) { + return ( +
+
🎙
+

No active speakers

+
+ ); + } + + return ( +
+ {activeSpeakers.map((speaker) => { + const key = + speaker.userId ?? speaker.id ?? `speaker-${speaker.username}`; + return ( +
+ +
+
+ {speaker.username} +
+
+ {speaker.speaking && ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/services/frontend/src/islands/AudioVisualizer.tsx b/services/frontend/src/islands/AudioVisualizer.tsx new file mode 100644 index 0000000..c6d8421 --- /dev/null +++ b/services/frontend/src/islands/AudioVisualizer.tsx @@ -0,0 +1,95 @@ +// ─── AudioVisualizer.tsx — Canvas-based frequency bar visualizer ──────────── +// Uses requestAnimationFrame to draw placeholder random frequency bars. +// Bar color uses the OKLCH primary token at varying opacity. +// ───────────────────────────────────────────────────────────────────────────── + +import { useEffect, useRef } from "react"; + +interface AudioVisualizerProps { + barCount?: number; + height?: number; +} + +export default function AudioVisualizer({ + barCount = 48, + height = 32, +}: AudioVisualizerProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + let animationId: number; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = height * dpr; + }; + + resize(); + + const draw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const w = canvas.getBoundingClientRect().width; + const h = height; + const barWidth = w / barCount; + const maxBarHeight = h * 0.85; + + for (let i = 0; i < barCount; i++) { + // Random bar height as a placeholder (real data from WebSocket) + const barHeight = Math.random() * maxBarHeight; + const x = i * barWidth; + const y = h - barHeight; + + // Vary opacity across bars for a more dynamic look + const opacity = 0.3 + Math.random() * 0.7; + ctx.fillStyle = `oklch(0.62 0.15 255 / ${opacity})`; + + // Rounded-top bars + const radius = Math.min(barWidth * 0.3, 4); + const gap = 1; + + ctx.beginPath(); + ctx.moveTo(x + gap + radius, y); + ctx.lineTo(x + barWidth - gap - radius, y); + ctx.quadraticCurveTo( + x + barWidth - gap, + y, + x + barWidth - gap, + y + radius, + ); + ctx.lineTo(x + barWidth - gap, h); + ctx.lineTo(x + gap, h); + ctx.lineTo(x + gap, y + radius); + ctx.quadraticCurveTo(x + gap, y, x + gap + radius, y); + ctx.fill(); + } + + animationId = requestAnimationFrame(draw); + }; + + draw(); + + return () => { + cancelAnimationFrame(animationId); + }; + }, [barCount, height]); + + return ( +
+ +
+ ); +} diff --git a/services/frontend/src/islands/AuthGuard.tsx b/services/frontend/src/islands/AuthGuard.tsx new file mode 100644 index 0000000..de72a4e --- /dev/null +++ b/services/frontend/src/islands/AuthGuard.tsx @@ -0,0 +1,122 @@ +// ─── AuthGuard.tsx — Standalone authentication island ──────────────────────── +// Three-state auth wrapper: null=loading, false=unauthenticated (login form), +// true=authenticated (renders children). +// ───────────────────────────────────────────────────────────────────────────── + +import { + type FormEvent, + type ReactNode, + useCallback, + useEffect, + useState, +} from "react"; +import { + getAdminPassword, + getSessionToken, + setAdminPassword, +} from "../shared/api/client.js"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "../shared/components/index.js"; + +interface AuthGuardProps { + children: ReactNode; +} + +export default function AuthGuard({ children }: AuthGuardProps) { + const [authState, setAuthState] = useState(null); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + // On mount: check for existing session token or legacy admin password + useEffect(() => { + const token = getSessionToken(); + const storedPassword = getAdminPassword(); + setAuthState(token || storedPassword ? true : false); + }, []); + + const handleLogin = useCallback( + (e: FormEvent) => { + e.preventDefault(); + if (!password) return; + setError(null); + // Store the password to localStorage; actual validation happens + // when child components call the API (gets 401 on invalid creds). + setAdminPassword(password); + setAuthState(true); + }, + [password], + ); + + // ── Loading state ────────────────────────────────────────────────────────── + if (authState === null) { + return ( +
+
+
+
+
+ ); + } + + // ── Unauthenticated — show login form ────────────────────────────────────── + if (authState === false) { + return ( +
+ + +
+
+ + + +
+
+ Access Required + + Enter the admin password to access the dashboard. + +
+ +
+
+
+ setPassword(e.target.value)} + autoFocus + /> + {error &&

{error}

} +
+
+ +
+
+
+
+ ); + } + + // ── Authenticated — render children ──────────────────────────────────────── + return <>{children}; +} diff --git a/services/frontend/src/islands/LiveShell.tsx b/services/frontend/src/islands/LiveShell.tsx new file mode 100644 index 0000000..4c8f7ad --- /dev/null +++ b/services/frontend/src/islands/LiveShell.tsx @@ -0,0 +1,142 @@ +// ─── LiveShell.tsx — Page shell for /live ──────────────────────────────────── +// Composes Sidebar, Header, ParticleBackground, and the four live islands +// (VoiceControls, ActiveSpeakers, AudioVisualizer, NowPlaying) into a full-page +// responsive layout. +// ───────────────────────────────────────────────────────────────────────────── + +import { useCallback, useEffect, useState } from "react"; +import { + getGuilds, + getVoiceChannels, + getVoiceStatus, +} from "../shared/api/client.js"; +import { Header } from "../shared/components/Header.js"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../shared/components/index.js"; +import { ParticleBackground } from "../shared/components/particles/ParticleBackground.js"; +import { Sidebar } from "../shared/components/Sidebar.js"; +import { useTheme } from "../shared/hooks/useTheme.js"; +import type { Channel, Guild } from "../shared/types/guild.js"; +import type { VoiceStatus } from "../shared/types/voice.js"; +import { useVoiceStore } from "../stores/voice-store.js"; +import ActiveSpeakers from "./ActiveSpeakers.js"; +import AudioVisualizer from "./AudioVisualizer.js"; +import NowPlaying from "./NowPlaying.js"; +import VoiceControls from "./VoiceControls.js"; + +const emptyVoiceStatus: VoiceStatus = { + connected: false, + activeGuildId: null, + activeChannelId: null, + activeChannelName: null, + connections: [], +}; + +export default function LiveShell() { + const { mode, isDark, toggle: toggleTheme } = useTheme(); + const [voiceStatus, setVoiceStatus] = useState(emptyVoiceStatus); + const [guilds, setGuilds] = useState([]); + const [voiceChannels, setVoiceChannels] = useState([]); + + // Watch store for guild changes so we can fetch voice channels + const guildId = useVoiceStore((state) => state.guildId); + + // Fetch initial data on mount + useEffect(() => { + getVoiceStatus() + .then(setVoiceStatus) + .catch(() => { + /* offline — leave default */ + }); + getGuilds() + .then(setGuilds) + .catch(() => { + /* offline — leave default */ + }); + }, []); + + // Refetch voice channels when guild selection changes + useEffect(() => { + if (guildId) { + getVoiceChannels(guildId) + .then(setVoiceChannels) + .catch(() => setVoiceChannels([])); + } else { + setVoiceChannels([]); + } + }, [guildId]); + + const handleTabChange = useCallback((tab: string) => { + if (tab !== "live") { + window.location.href = "/"; + } + }, []); + + return ( +
+ {/* Background layers */} + + + ); +} diff --git a/services/frontend/src/islands/MascotChat.tsx b/services/frontend/src/islands/MascotChat.tsx new file mode 100644 index 0000000..c24cc79 --- /dev/null +++ b/services/frontend/src/islands/MascotChat.tsx @@ -0,0 +1,122 @@ +// ─── MascotChat.tsx — Floating chat bubble island ─────────────────────────── +// Fixed-position toggle button that opens a glass-styled chat panel. +// ───────────────────────────────────────────────────────────────────────────── + +import { useCallback, useState } from "react"; + +const GREETING = "Hi! I'm Bete's mascot. Ask me anything about the server."; + +export default function MascotChat() { + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState(""); + + const toggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim()) return; + // TODO: wire up chat submission + setMessage(""); + }, + [message], + ); + + return ( +
+ {/* ── Chat Panel ──────────────────────────────────────────────────── */} + {isOpen && ( +
+ {/* Header */} +
+
+
+ B +
+ + Mascot + +
+
+ + {/* Body */} +
+

+ {GREETING} +

+ + {/* Quick suggestions */} +
+ {["What can you do?", "Show me the dashboard"].map( + (suggestion) => ( + + ), + )} +
+
+ + {/* Input */} +
+ setMessage(e.target.value)} + placeholder="Type a message..." + className="min-w-0 flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/30" + /> + +
+
+ )} + + {/* ── Toggle Button ───────────────────────────────────────────────── */} + +
+ ); +} diff --git a/services/frontend/src/islands/MessageFeed.tsx b/services/frontend/src/islands/MessageFeed.tsx new file mode 100644 index 0000000..519f0f7 --- /dev/null +++ b/services/frontend/src/islands/MessageFeed.tsx @@ -0,0 +1,356 @@ +// ─── MessageFeed.tsx — Standalone message feed island ───────────────────────── +// Self-contained message list with WebSocket bridging, infinite scroll, +// loading/empty/error states, and re-analyze per message. +// ────────────────────────────────────────────────────────────────────────────── + +import type { MessageRecord } from "@bete/shared"; +import { AlertCircle, RefreshCw, RotateCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { getAppConfig } from "../shared/api/client.js"; +import { Badge, Button, Skeleton } from "../shared/components/index.js"; +import { useMessages } from "../shared/hooks/useMessages.js"; +import { useDashboardSocket } from "../shared/ws/socket.js"; +import { useMessageStore } from "../stores/message-store.js"; + +interface MessageFeedProps { + /** Guild to fetch messages for. If omitted, the list stays empty. */ + guildId?: string; +} + +// ─── Severity badge colouring ───────────────────────────────────────────────── + +const SEVERITY_COLORS: Record = { + critical: + "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800", + high: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800", + medium: + "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800", + low: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800", +}; + +function severityColor(severity: string): string { + return ( + SEVERITY_COLORS[severity] ?? "bg-muted text-muted-foreground border-border" + ); +} + +// ─── Single message card ────────────────────────────────────────────────────── + +function MessageCard({ + message, + onReanalyze, +}: { + message: MessageRecord; + onReanalyze: (id: string) => Promise; +}) { + const [isReanalyzing, setIsReanalyzing] = useState(false); + const isDeleted = !!message.deleted_at; + + const handleReanalyze = async () => { + setIsReanalyzing(true); + try { + await onReanalyze(message.id); + } finally { + setIsReanalyzing(false); + } + }; + + return ( +
+
+ {/* Avatar */} + { + e.currentTarget.src = + "https://cdn.discordapp.com/embed/avatars/0.png"; + }} + /> + +
+ {/* Row: username + badges */} +
+ + {message.username || message.user_id} + + + {message.ai_severity && message.ai_severity !== "none" && ( + + {message.ai_severity} + + )} + + {isDeleted && ( + deleted + )} + + + {formatTimeAgo(message.created_at)} + +
+ + {/* Content */} +

+ {message.content} +

+ + {/* Re-analyze button */} +
+ + {message.ai_status === "error" && ( + + Click to retry + + )} +
+ + {/* AI analysis summary */} + {message.ai_analysis && ( +
+

+ {message.ai_analysis} +

+
+ )} + + {/* AI error */} + {message.ai_error && ( +
+ AI error: {message.ai_error} +
+ )} +
+
+
+ ); +} + +// ─── Skeleton placeholder ───────────────────────────────────────────────────── + +function MessageSkeleton() { + return ( +
+
+ +
+ + + +
+
+
+ ); +} + +// ─── Empty state ────────────────────────────────────────────────────────────── + +function EmptyState() { + return ( +
+

No messages captured

+

+ Messages will appear here once they are captured from the monitored + guild. +

+
+ ); +} + +// ─── Error state ────────────────────────────────────────────────────────────── + +function ErrorState({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+ +

Failed to load messages

+

+ {message} +

+ +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatTimeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return new Date(ts).toLocaleDateString(); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function MessageFeed({ + guildId: propGuildId, +}: MessageFeedProps) { + const { + messages, + loading, + loadingMore, + error, + fetchMessages, + loadMore, + reanalyze, + hasMore, + } = useMessages(); + const { prependMessage, updateMessage } = useMessageStore(); + const sentinelRef = useRef(null); + const [initialFetchDone, setInitialFetchDone] = useState(false); + const [resolvedGuildId, setResolvedGuildId] = useState( + propGuildId, + ); + + // ── Resolve guildId from app config when not provided as prop ──────────── + useEffect(() => { + if (propGuildId) { + setResolvedGuildId(propGuildId); + return; + } + getAppConfig() + .then((config) => { + if (config.monitorGuildId) setResolvedGuildId(config.monitorGuildId); + }) + .catch(() => { + // config unavailable — messages will stay empty until guildId is set + }); + }, [propGuildId]); + + // ── WebSocket bridge ────────────────────────────────────────────────────── + // Forward real-time Discord events into the zustand store so the message list + // updates without refetching the entire page. + useDashboardSocket({ + onMessageCreated: (data) => prependMessage(data), + onMessageUpdated: (data) => updateMessage(data.id, data), + onMessageDeleted: (data) => + updateMessage(data.id, { + type: "deleted" as const, + deleted_at: data.deleted_at, + }), + onMessageAnalyzed: (data) => updateMessage(data.id, data), + }); + + // ── Initial fetch ───────────────────────────────────────────────────────── + useEffect(() => { + if (resolvedGuildId && !initialFetchDone) { + setInitialFetchDone(true); + fetchMessages(resolvedGuildId).catch(() => { + // error is captured by the hook's state + }); + } + }, [resolvedGuildId, initialFetchDone, fetchMessages]); + + // ── Infinite scroll via IntersectionObserver ────────────────────────────── + useEffect(() => { + if (!loadMore || !hasMore) return; + const el = sentinelRef.current; + if (!el) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) loadMore(); + }, + { rootMargin: "400px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [loadMore, hasMore]); + + // ── Loading state ───────────────────────────────────────────────────────── + if (loading && messages.length === 0) { + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ); + } + + // ── Error state ─────────────────────────────────────────────────────────── + if (error && messages.length === 0) { + return ( + resolvedGuildId && fetchMessages(resolvedGuildId)} + /> + ); + } + + // ── Empty state ─────────────────────────────────────────────────────────── + if (!loading && messages.length === 0) { + return ; + } + + // ── Success — message list with infinite scroll ─────────────────────────── + return ( +
+ {messages.map((msg) => ( + + ))} + + {hasMore && ( +
+ {loadingMore ? ( + + ) : ( +
+ )} +
+ )} +
+ ); +} diff --git a/services/frontend/src/islands/NowPlaying.tsx b/services/frontend/src/islands/NowPlaying.tsx new file mode 100644 index 0000000..2ecdbc1 --- /dev/null +++ b/services/frontend/src/islands/NowPlaying.tsx @@ -0,0 +1,69 @@ +// ─── NowPlaying.tsx — Current media track display island ─────────────────── +// Self-contained: fetches /api/media/status on mount, shows current track or +// "Nothing playing" fallback. +// ───────────────────────────────────────────────────────────────────────────── + +import { Music2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { getMediaStatus } from "../shared/api/client.js"; +import type { MediaState } from "../shared/types/media.js"; + +export default function NowPlaying() { + const [mediaState, setMediaState] = useState(null); + const [errored, setErrored] = useState(false); + + useEffect(() => { + let cancelled = false; + + getMediaStatus() + .then((state) => { + if (!cancelled) setMediaState(state); + }) + .catch(() => { + if (!cancelled) setErrored(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + if (errored) { + return ( +
+

Nothing playing

+
+ ); + } + + if (!mediaState || !mediaState.current) { + return ( +
+

Nothing playing

+
+ ); + } + + const { current } = mediaState; + + return ( +
+
+
+ +
+
+
{current.title}
+
+ {current.source} +
+
+ {current.mode && ( + + {current.mode} + + )} +
+
+ ); +} diff --git a/services/frontend/src/islands/PageHeader.tsx b/services/frontend/src/islands/PageHeader.tsx new file mode 100644 index 0000000..1ceb904 --- /dev/null +++ b/services/frontend/src/islands/PageHeader.tsx @@ -0,0 +1,40 @@ +// ─── PageHeader.tsx — Standalone header island for non-SPA pages ──────────── +// Renders a title on the left and the ThemeToggle on the right. +// Does not depend on wsStatus / voiceStatus / theme context. +// ────────────────────────────────────────────────────────────────────────────── + +import ThemeToggle from "./ThemeToggle.js"; + +interface PageHeaderProps { + title: string; + subtitle?: string; +} + +export default function PageHeader({ title, subtitle }: PageHeaderProps) { + return ( +
+
+
+ IMPHNEN +

+ IMPHNEN + · + {title} +

+
+
+ {subtitle && ( +

+ {subtitle} +

+ )} + +
+
+
+ ); +} diff --git a/services/frontend/src/islands/PageSidebar.tsx b/services/frontend/src/islands/PageSidebar.tsx new file mode 100644 index 0000000..ddea34f --- /dev/null +++ b/services/frontend/src/islands/PageSidebar.tsx @@ -0,0 +1,30 @@ +// ─── PageSidebar.tsx — Standalone sidebar island for non-SPA pages ─────────── +// Wraps the existing Sidebar widget with URL-based navigation so it can be +// embedded as an Astro island without needing to pass callback functions. +// ────────────────────────────────────────────────────────────────────────────── + +import { Sidebar } from "../shared/components/Sidebar.js"; +import type { DashboardTab } from "../shared/types/ui-types.js"; + +const TAB_URLS: Record = { + live: "/live", + messages: "/messages", + dashboard: "/", + settings: "/settings", +}; + +interface PageSidebarProps { + activeTab: DashboardTab; +} + +export default function PageSidebar({ activeTab }: PageSidebarProps) { + return ( + { + window.location.href = TAB_URLS[tab] || "/messages"; + }} + /> + ); +} diff --git a/services/frontend/src/islands/Particles.tsx b/services/frontend/src/islands/Particles.tsx new file mode 100644 index 0000000..d698cdf --- /dev/null +++ b/services/frontend/src/islands/Particles.tsx @@ -0,0 +1,104 @@ +// ─── Particles.tsx — Canvas-based particle background island ──────────────── +// Renders 30 animated particles as a decorative background layer. +// Uses requestAnimationFrame, handles resize, cleans up on unmount. +// ───────────────────────────────────────────────────────────────────────────── + +import { useEffect, useRef } from "react"; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + size: number; +} + +const PARTICLE_COUNT = 30; +const PARTICLE_COLOR = "oklch(0.62 0.15 255 / 0.15)"; +const MAX_SPEED = 0.4; +const MIN_SIZE = 2; +const MAX_SIZE = 6; + +function randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function createParticle(w: number, h: number): Particle { + return { + x: Math.random() * w, + y: Math.random() * h, + vx: randomRange(-MAX_SPEED, MAX_SPEED), + vy: randomRange(-MAX_SPEED, MAX_SPEED), + size: randomRange(MIN_SIZE, MAX_SIZE), + }; +} + +export default function Particles() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationId: number; + + function resize() { + if (!canvas) return; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + } + + resize(); + window.addEventListener("resize", resize); + + const particles: Particle[] = []; + for (let i = 0; i < PARTICLE_COUNT; i++) { + particles.push(createParticle(canvas.width, canvas.height)); + } + + function animate() { + if (!canvas || !ctx) return; + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = PARTICLE_COLOR; + + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + + // Wrap around edges + if (p.x < 0) p.x = w; + if (p.x > w) p.x = 0; + if (p.y < 0) p.y = h; + if (p.y > h) p.y = 0; + + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + + animationId = requestAnimationFrame(animate); + } + + animate(); + + return () => { + cancelAnimationFrame(animationId); + window.removeEventListener("resize", resize); + }; + }, []); + + return ( +