From bf86fcda8ee832bc1a56afe2d962413ce046327a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 2 Jul 2026 03:54:44 +0700 Subject: [PATCH] Revert "feat: migrate frontend to Astro SSG with design system" This reverts commit 8ad888da288c90a556f87e73377f02592fd9b03a. --- 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 -- .../dashboard/types.ts} | 0 .../guild.ts => entities/guild/types.ts} | 0 .../media.ts => entities/media/types.ts} | 0 .../message.ts => entities/message/types.ts} | 0 .../recording/types.ts} | 0 .../ui-types.ts => entities/ui/types.ts} | 0 .../voice.ts => entities/voice/types.ts} | 2 +- .../src/features/admin/AdminPanel.tsx | 295 +++++++++ .../components => features}/auth/index.tsx | 11 +- .../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 ++ .../messages}/hooks/useMessages.ts | 60 +- .../frontend/src/features/messages/index.tsx | 315 ++++++++++ .../frontend/src/features/settings/index.tsx | 426 +++++++++++++ .../src/hooks/useNotificationBadge.ts | 31 + .../src/{shared => }/hooks/useTheme.ts | 0 .../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 +- .../components/auth/AuthGuard.client.tsx | 19 - .../src/shared/hooks/useMascotChat.ts | 2 +- .../frontend/src/shared/hooks/useUIState.ts | 2 +- .../{components => ui}/CommandPalette.tsx | 2 +- .../{components => ui}/MobileTabBar.tsx | 6 +- .../src/shared/{components => ui}/badge.tsx | 0 .../src/shared/{components => ui}/button.tsx | 0 .../src/shared/{components => ui}/card.tsx | 0 .../{components => ui}/error-boundary.tsx | 4 +- .../src/shared/{components => ui}/index.ts | 0 .../src/shared/{components => ui}/input.tsx | 0 .../{components => ui}/profile-detail.tsx | 0 .../shared/{components => ui}/scroll-area.tsx | 0 .../src/shared/{components => ui}/select.tsx | 0 .../shared/{components => ui}/skeleton.tsx | 0 .../{components => ui}/status-badge.tsx | 0 .../{components => ui}/summary-list.tsx | 0 .../src/shared/{components => ui}/tabs.tsx | 0 .../src/shared/{components => ui}/toast.tsx | 0 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 +++ .../{shared/components => widgets}/Header.tsx | 28 +- .../components => widgets}/Sidebar.tsx | 14 +- .../mascot/MascotChatbot.tsx | 4 +- .../mascot/MascotImage.tsx | 4 +- .../particles/ParticleBackground.tsx | 11 +- 110 files changed, 4980 insertions(+), 3396 deletions(-) create mode 100644 services/frontend/src/App.client.tsx create mode 100644 services/frontend/src/App.tsx delete mode 100644 services/frontend/src/components/header/Header.astro delete mode 100644 services/frontend/src/components/sidebar/NavItem.astro delete mode 100644 services/frontend/src/components/sidebar/Sidebar.astro delete mode 100644 services/frontend/src/components/states/EmptyState.astro delete mode 100644 services/frontend/src/components/states/ErrorState.astro delete mode 100644 services/frontend/src/components/states/LoadingSkeleton.astro delete mode 100644 services/frontend/src/components/ui/Badge.astro delete mode 100644 services/frontend/src/components/ui/Button.astro delete mode 100644 services/frontend/src/components/ui/Card.astro delete mode 100644 services/frontend/src/components/ui/SeverityBadge.astro delete mode 100644 services/frontend/src/components/ui/Skeleton.astro delete mode 100644 services/frontend/src/components/ui/Spinner.astro rename services/frontend/src/{shared/types/dashboard.ts => entities/dashboard/types.ts} (100%) rename services/frontend/src/{shared/types/guild.ts => entities/guild/types.ts} (100%) rename services/frontend/src/{shared/types/media.ts => entities/media/types.ts} (100%) rename services/frontend/src/{shared/types/message.ts => entities/message/types.ts} (100%) rename services/frontend/src/{shared/types/recording.ts => entities/recording/types.ts} (100%) rename services/frontend/src/{shared/types/ui-types.ts => entities/ui/types.ts} (100%) rename services/frontend/src/{shared/types/voice.ts => entities/voice/types.ts} (85%) create mode 100644 services/frontend/src/features/admin/AdminPanel.tsx rename services/frontend/src/{shared/components => features}/auth/index.tsx (95%) create mode 100644 services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx create mode 100644 services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx create mode 100644 services/frontend/src/features/dashboard/components/DashboardStats.tsx create mode 100644 services/frontend/src/features/dashboard/components/UserProfileDetail.tsx create mode 100644 services/frontend/src/features/dashboard/components/UserSummaryList.tsx create mode 100644 services/frontend/src/features/dashboard/hooks/useDashboard.ts create mode 100644 services/frontend/src/features/dashboard/index.tsx create mode 100644 services/frontend/src/features/live/components/ActiveSpeakers.tsx create mode 100644 services/frontend/src/features/live/components/AudioVisualizer.tsx create mode 100644 services/frontend/src/features/live/components/MicLevelMeter.tsx create mode 100644 services/frontend/src/features/live/components/MusicSubPanel.tsx create mode 100644 services/frontend/src/features/live/components/NowPlaying.tsx create mode 100644 services/frontend/src/features/live/components/RecordingsSubPanel.tsx create mode 100644 services/frontend/src/features/live/components/ScreenSubPanel.tsx create mode 100644 services/frontend/src/features/live/components/VoiceConnectionCard.tsx create mode 100644 services/frontend/src/features/live/components/WaveformPlayer.tsx create mode 100644 services/frontend/src/features/live/hooks/useMediaControl.ts create mode 100644 services/frontend/src/features/live/hooks/useVoiceControl.ts create mode 100644 services/frontend/src/features/live/index.tsx create mode 100644 services/frontend/src/features/messages/components/ImageGrid.tsx create mode 100644 services/frontend/src/features/messages/components/MessageCard.tsx create mode 100644 services/frontend/src/features/messages/components/MessageFeed.tsx create mode 100644 services/frontend/src/features/messages/components/ModerationAlertListener.tsx rename services/frontend/src/{shared => features/messages}/hooks/useMessages.ts (69%) create mode 100644 services/frontend/src/features/messages/index.tsx create mode 100644 services/frontend/src/features/settings/index.tsx create mode 100644 services/frontend/src/hooks/useNotificationBadge.ts rename services/frontend/src/{shared => }/hooks/useTheme.ts (100%) delete mode 100644 services/frontend/src/islands/ActiveSpeakers.tsx delete mode 100644 services/frontend/src/islands/AudioVisualizer.tsx delete mode 100644 services/frontend/src/islands/AuthGuard.tsx delete mode 100644 services/frontend/src/islands/LiveShell.tsx delete mode 100644 services/frontend/src/islands/MascotChat.tsx delete mode 100644 services/frontend/src/islands/MessageFeed.tsx delete mode 100644 services/frontend/src/islands/NowPlaying.tsx delete mode 100644 services/frontend/src/islands/PageHeader.tsx delete mode 100644 services/frontend/src/islands/PageSidebar.tsx delete mode 100644 services/frontend/src/islands/Particles.tsx delete mode 100644 services/frontend/src/islands/RecordingsList.tsx delete mode 100644 services/frontend/src/islands/SettingsForm.tsx delete mode 100644 services/frontend/src/islands/ThemeToggle.tsx delete mode 100644 services/frontend/src/islands/VoiceControls.tsx delete mode 100644 services/frontend/src/layouts/AuthLayout.astro delete mode 100644 services/frontend/src/pages/404.astro delete mode 100644 services/frontend/src/pages/live.astro delete mode 100644 services/frontend/src/pages/login.astro delete mode 100644 services/frontend/src/pages/messages.astro delete mode 100644 services/frontend/src/pages/recordings.astro delete mode 100644 services/frontend/src/pages/settings.astro delete mode 100644 services/frontend/src/shared/components/auth/AuthGuard.client.tsx rename services/frontend/src/shared/{components => ui}/CommandPalette.tsx (100%) rename services/frontend/src/shared/{components => ui}/MobileTabBar.tsx (92%) rename services/frontend/src/shared/{components => ui}/badge.tsx (100%) rename services/frontend/src/shared/{components => ui}/button.tsx (100%) rename services/frontend/src/shared/{components => ui}/card.tsx (100%) rename services/frontend/src/shared/{components => ui}/error-boundary.tsx (98%) rename services/frontend/src/shared/{components => ui}/index.ts (100%) rename services/frontend/src/shared/{components => ui}/input.tsx (100%) rename services/frontend/src/shared/{components => ui}/profile-detail.tsx (100%) rename services/frontend/src/shared/{components => ui}/scroll-area.tsx (100%) rename services/frontend/src/shared/{components => ui}/select.tsx (100%) rename services/frontend/src/shared/{components => ui}/skeleton.tsx (100%) rename services/frontend/src/shared/{components => ui}/status-badge.tsx (100%) rename services/frontend/src/shared/{components => ui}/summary-list.tsx (100%) rename services/frontend/src/shared/{components => ui}/tabs.tsx (100%) rename services/frontend/src/shared/{components => ui}/toast.tsx (100%) delete mode 100644 services/frontend/src/stores/message-store.ts delete mode 100644 services/frontend/src/stores/ui-store.ts delete mode 100644 services/frontend/src/stores/voice-store.ts create mode 100644 services/frontend/src/styles.css delete mode 100644 services/frontend/src/styles/base.css create mode 100644 services/frontend/src/widgets/DashboardLayout.tsx rename services/frontend/src/{shared/components => widgets}/Header.tsx (88%) rename services/frontend/src/{shared/components => widgets}/Sidebar.tsx (93%) rename services/frontend/src/{shared/components => widgets}/mascot/MascotChatbot.tsx (99%) rename services/frontend/src/{shared/components => widgets}/mascot/MascotImage.tsx (96%) rename services/frontend/src/{shared/components => widgets}/particles/ParticleBackground.tsx (72%) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d573ff..34efb2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,9 +267,6 @@ 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 @@ -6050,24 +6047,6 @@ 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==} @@ -11535,9 +11514,4 @@ 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 03d7cc4..f964fe0 100644 --- a/services/frontend/.astro/types.d.ts +++ b/services/frontend/.astro/types.d.ts @@ -1,2 +1 @@ /// -/// \ No newline at end of file diff --git a/services/frontend/astro.config.mjs b/services/frontend/astro.config.mjs index 332f594..3bae84b 100644 --- a/services/frontend/astro.config.mjs +++ b/services/frontend/astro.config.mjs @@ -36,8 +36,10 @@ 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 0703268..934f9da 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -23,8 +23,7 @@ "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "tailwind-merge": "^3.6.0", - "zustand": "^5.0.14" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@biomejs/biome": "latest", diff --git a/services/frontend/src/App.client.tsx b/services/frontend/src/App.client.tsx new file mode 100644 index 0000000..2ec3bd5 --- /dev/null +++ b/services/frontend/src/App.client.tsx @@ -0,0 +1,18 @@ +// ─── 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 new file mode 100644 index 0000000..ad3b3b9 --- /dev/null +++ b/services/frontend/src/App.tsx @@ -0,0 +1,512 @@ +// ─── 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 deleted file mode 100644 index eaa1884..0000000 --- a/services/frontend/src/components/header/Header.astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index 29484f0..0000000 --- a/services/frontend/src/components/sidebar/NavItem.astro +++ /dev/null @@ -1,60 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index 34fa960..0000000 --- a/services/frontend/src/components/sidebar/Sidebar.astro +++ /dev/null @@ -1,93 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index bdc19b1..0000000 --- a/services/frontend/src/components/states/EmptyState.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index f3a7cae..0000000 --- a/services/frontend/src/components/states/ErrorState.astro +++ /dev/null @@ -1,51 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index 486f801..0000000 --- a/services/frontend/src/components/states/LoadingSkeleton.astro +++ /dev/null @@ -1,60 +0,0 @@ ---- -// ─── 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 deleted file mode 100644 index 0b6ab54..0000000 --- a/services/frontend/src/components/ui/Badge.astro +++ /dev/null @@ -1,106 +0,0 @@ ---- -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 deleted file mode 100644 index 886a0db..0000000 --- a/services/frontend/src/components/ui/Button.astro +++ /dev/null @@ -1,146 +0,0 @@ ---- -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 deleted file mode 100644 index 2ab6f4f..0000000 --- a/services/frontend/src/components/ui/Card.astro +++ /dev/null @@ -1,101 +0,0 @@ ---- -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 deleted file mode 100644 index 1aa0cb3..0000000 --- a/services/frontend/src/components/ui/SeverityBadge.astro +++ /dev/null @@ -1,97 +0,0 @@ ---- -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 deleted file mode 100644 index b1a8e20..0000000 --- a/services/frontend/src/components/ui/Skeleton.astro +++ /dev/null @@ -1,74 +0,0 @@ ---- -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 deleted file mode 100644 index 13c0aba..0000000 --- a/services/frontend/src/components/ui/Spinner.astro +++ /dev/null @@ -1,60 +0,0 @@ ---- -export interface Props { - size?: "sm" | "default" | "lg"; - class?: string; -} - -const { size = "default", class: className = "" } = Astro.props; ---- - - - - - diff --git a/services/frontend/src/shared/types/dashboard.ts b/services/frontend/src/entities/dashboard/types.ts similarity index 100% rename from services/frontend/src/shared/types/dashboard.ts rename to services/frontend/src/entities/dashboard/types.ts diff --git a/services/frontend/src/shared/types/guild.ts b/services/frontend/src/entities/guild/types.ts similarity index 100% rename from services/frontend/src/shared/types/guild.ts rename to services/frontend/src/entities/guild/types.ts diff --git a/services/frontend/src/shared/types/media.ts b/services/frontend/src/entities/media/types.ts similarity index 100% rename from services/frontend/src/shared/types/media.ts rename to services/frontend/src/entities/media/types.ts diff --git a/services/frontend/src/shared/types/message.ts b/services/frontend/src/entities/message/types.ts similarity index 100% rename from services/frontend/src/shared/types/message.ts rename to services/frontend/src/entities/message/types.ts diff --git a/services/frontend/src/shared/types/recording.ts b/services/frontend/src/entities/recording/types.ts similarity index 100% rename from services/frontend/src/shared/types/recording.ts rename to services/frontend/src/entities/recording/types.ts diff --git a/services/frontend/src/shared/types/ui-types.ts b/services/frontend/src/entities/ui/types.ts similarity index 100% rename from services/frontend/src/shared/types/ui-types.ts rename to services/frontend/src/entities/ui/types.ts diff --git a/services/frontend/src/shared/types/voice.ts b/services/frontend/src/entities/voice/types.ts similarity index 85% rename from services/frontend/src/shared/types/voice.ts rename to services/frontend/src/entities/voice/types.ts index 324788c..71571df 100644 --- a/services/frontend/src/shared/types/voice.ts +++ b/services/frontend/src/entities/voice/types.ts @@ -1,4 +1,4 @@ -import type { GuildVoiceEntry } from "./guild"; +import type { GuildVoiceEntry } from "../guild/types"; export interface VoiceStatus { connected: boolean; diff --git a/services/frontend/src/features/admin/AdminPanel.tsx b/services/frontend/src/features/admin/AdminPanel.tsx new file mode 100644 index 0000000..fb3aa04 --- /dev/null +++ b/services/frontend/src/features/admin/AdminPanel.tsx @@ -0,0 +1,295 @@ +import { motion } from "framer-motion"; +import { + Eye, + EyeOff, + Globe, + Lock, + RefreshCw, + Save, + Settings, + Shield, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import type { AdminSettings } from "../../shared/api/client"; +import { + getAdminSettings, + updateAdminSettings, + clearSessionToken, + logout, +} from "../../shared/api/client"; +import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../shared/ui"; + +export function AdminPanel() { + const [settings, setSettings] = useState(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/shared/components/auth/index.tsx b/services/frontend/src/features/auth/index.tsx similarity index 95% rename from services/frontend/src/shared/components/auth/index.tsx rename to services/frontend/src/features/auth/index.tsx index 34f6aab..7f5faba 100644 --- a/services/frontend/src/shared/components/auth/index.tsx +++ b/services/frontend/src/features/auth/index.tsx @@ -1,7 +1,7 @@ import { motion } from "framer-motion"; -import { Lock, RefreshCw, Shield, Unlock, WifiOff } from "lucide-react"; -import { useCallback, useState } from "react"; -import { login, setSessionToken } from "../../api/client.js"; +import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react"; +import { useState, useCallback } from "react"; +import { login, setSessionToken } from "../../shared/api/client.js"; import { Button, Card, @@ -10,7 +10,7 @@ import { CardHeader, CardTitle, Input, -} from "../index"; +} from "../../shared/ui"; interface AuthOverlayProps { onAuthenticated: () => void; @@ -153,8 +153,7 @@ export function AuthOverlay({

The dashboard is in public mode — most data is visible without - authentication. Admin password is only needed for management - actions. + authentication. Admin password is only needed for management actions.

)} diff --git a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx new file mode 100644 index 0000000..fb44b92 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..2f82463 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..11a5537 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/DashboardStats.tsx @@ -0,0 +1,246 @@ +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 new file mode 100644 index 0000000..632b252 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..66b51d2 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx @@ -0,0 +1,67 @@ +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 new file mode 100644 index 0000000..ee983ef --- /dev/null +++ b/services/frontend/src/features/dashboard/hooks/useDashboard.ts @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..9d4ecb8 --- /dev/null +++ b/services/frontend/src/features/dashboard/index.tsx @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..96e80ce --- /dev/null +++ b/services/frontend/src/features/live/components/ActiveSpeakers.tsx @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000..ab6c113 --- /dev/null +++ b/services/frontend/src/features/live/components/AudioVisualizer.tsx @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000..e83b52d --- /dev/null +++ b/services/frontend/src/features/live/components/MicLevelMeter.tsx @@ -0,0 +1,29 @@ +// ─── 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 new file mode 100644 index 0000000..1b19855 --- /dev/null +++ b/services/frontend/src/features/live/components/MusicSubPanel.tsx @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..2ea9d74 --- /dev/null +++ b/services/frontend/src/features/live/components/NowPlaying.tsx @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..43ecfad --- /dev/null +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -0,0 +1,207 @@ +// ─── 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 new file mode 100644 index 0000000..05c8dbd --- /dev/null +++ b/services/frontend/src/features/live/components/ScreenSubPanel.tsx @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..47381de --- /dev/null +++ b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..5a7418b --- /dev/null +++ b/services/frontend/src/features/live/components/WaveformPlayer.tsx @@ -0,0 +1,256 @@ +// ─── 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 new file mode 100644 index 0000000..915f369 --- /dev/null +++ b/services/frontend/src/features/live/hooks/useMediaControl.ts @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..318f5f0 --- /dev/null +++ b/services/frontend/src/features/live/hooks/useVoiceControl.ts @@ -0,0 +1,113 @@ +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 new file mode 100644 index 0000000..60f5783 --- /dev/null +++ b/services/frontend/src/features/live/index.tsx @@ -0,0 +1,171 @@ +// ─── 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 new file mode 100644 index 0000000..c01b629 --- /dev/null +++ b/services/frontend/src/features/messages/components/ImageGrid.tsx @@ -0,0 +1,132 @@ +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 new file mode 100644 index 0000000..8fcf787 --- /dev/null +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -0,0 +1,577 @@ +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 new file mode 100644 index 0000000..2064fd3 --- /dev/null +++ b/services/frontend/src/features/messages/components/MessageFeed.tsx @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..a11afe6 --- /dev/null +++ b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx @@ -0,0 +1,57 @@ +// ─── 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/shared/hooks/useMessages.ts b/services/frontend/src/features/messages/hooks/useMessages.ts similarity index 69% rename from services/frontend/src/shared/hooks/useMessages.ts rename to services/frontend/src/features/messages/hooks/useMessages.ts index e6c802b..24b23d4 100644 --- a/services/frontend/src/shared/hooks/useMessages.ts +++ b/services/frontend/src/features/messages/hooks/useMessages.ts @@ -1,17 +1,16 @@ -import type { MessageRecord } from "@bete/shared"; import { useCallback, useRef, useState } from "react"; -import { useMessageStore } from "../../stores/message-store.js"; -import { listMessages, reanalyzeMessage } from "../api/client.js"; -import { createLogger } from "../lib/logger.js"; +import type { MessageRecord } from "../../../entities/message/types.js"; +import { + listMessages, + reanalyzeErrorBatch, + reanalyzeMessage, +} from "../../../shared/api/client"; +import { createLogger } from "../../../shared/lib/logger.js"; const logger = createLogger("use-messages"); const PAGE_SIZE = 100; -/** - * Merge two message arrays, deduplicating by id and sorting by created_at desc. - * Later entries overwrite earlier ones for the same id (useful for WS updates). - */ export function mergeMessages( current: MessageRecord[], incoming: MessageRecord[], @@ -25,20 +24,14 @@ export function mergeMessages( ); } -/** - * Hook for fetching, paginating, and re-analyzing messages. - * Reads/writes through the zustand `useMessageStore` so that WebSocket updates - * (handled by the store directly via prependMessage/updateMessage/removeMessage) - * are reflected without duplicating state. - */ export function useMessages() { + const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(false); const currentGuild = useRef(null); - const { messages, setMessages } = useMessageStore(); const fetchMessages = useCallback(async (guildId?: string) => { if (!guildId) { @@ -55,7 +48,6 @@ export function useMessages() { guildId, limit: PAGE_SIZE, }); - // Guard against stale responses if guildId changed mid-flight if (currentGuild.current === guildId) { setMessages(result.data); setCursor(result.nextCursor); @@ -81,7 +73,7 @@ export function useMessages() { cursor, limit: PAGE_SIZE, }); - setMessages((prev) => mergeMessages(prev, result.data)); + setMessages((prev) => [...prev, ...result.data]); setCursor(result.nextCursor); setHasMore(!!result.nextCursor); } catch (err) { @@ -93,7 +85,8 @@ export function useMessages() { }, [cursor, loadingMore]); const reanalyze = useCallback(async (id: string): Promise => { - // Snapshot the current state so we can revert on HTTP failure + // Capture prior state inside the functional updater so we don't need + // `messages` as a useCallback dependency (avoids stale closure churn). let saved: MessageRecord | undefined; setMessages((prev) => { @@ -113,7 +106,7 @@ export function useMessages() { try { await reanalyzeMessage(id); } catch (err) { - // Revert optimistic update on failure + // HTTP failed — revert the optimistic update so the UI stays truthful. if (saved) { const snapshot = saved; setMessages((prev) => @@ -126,13 +119,42 @@ export function useMessages() { } }, []); + const reanalyzeAllErrors = useCallback(async (): Promise => { + // Optimistically mark all error messages as pending + setMessages((prev) => + prev.map((message) => + message.ai_status === "error" + ? { + ...message, + ai_status: "pending" as const, + ai_error: null, + ai_analysis: null, + } + : message, + ), + ); + try { + const { count } = await reanalyzeErrorBatch({ + guildId: currentGuild.current ?? undefined, + }); + logger.info("Reanalyze all errors complete", { count }); + return count; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("Failed to reanalyze error batch", { error: message }); + throw err; + } + }, []); + return { messages, + setMessages, loading, loadingMore, error, fetchMessages, reanalyze, + reanalyzeAllErrors, loadMore, hasMore, }; diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx new file mode 100644 index 0000000..b52b1fd --- /dev/null +++ b/services/frontend/src/features/messages/index.tsx @@ -0,0 +1,315 @@ +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 new file mode 100644 index 0000000..c5f5b66 --- /dev/null +++ b/services/frontend/src/features/settings/index.tsx @@ -0,0 +1,426 @@ +import { motion } from "framer-motion"; +import { + Bell, + BellOff, + Moon, + Palette, + Sun, + Monitor, + Settings, + Shield, + Globe, + Lock, + Volume2, + VolumeX, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ThemeMode } from "../../hooks/useTheme"; +import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; +import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui"; +import { + getAdminSettings, + updateAdminSettings, + clearSessionToken, +} from "../../shared/api/client"; +import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types"; + +/* ─── Storage keys ─────────────────────────────────────────────────────── */ + +const NOTIF_ENABLED_KEY = "bete-notif-enabled"; +const NOTIF_SOUND_KEY = "bete-notif-sound"; + +/* ─── Types ────────────────────────────────────────────────────────────── */ + +interface NotificationPrefs { + enabled: boolean; + sound: boolean; +} + +function loadNotifPrefs(): NotificationPrefs { + try { + const raw = localStorage.getItem(NOTIF_ENABLED_KEY); + const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY); + return { + enabled: raw !== "false", // default true + sound: soundRaw !== "false", // default true + }; + } catch { + return { enabled: true, sound: true }; + } +} + +/* ─── Props ────────────────────────────────────────────────────────────── */ + +interface SettingsPanelProps { + themeMode: ThemeMode; + isDark: boolean; + onThemeModeChange: (mode: ThemeMode) => void; +} + +/* ─── Component ────────────────────────────────────────────────────────── */ + +export function SettingsPanel({ + themeMode, + isDark, + onThemeModeChange, +}: SettingsPanelProps) { + const [notifPrefs, setNotifPrefs] = useState(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 new file mode 100644 index 0000000..4c75f04 --- /dev/null +++ b/services/frontend/src/hooks/useNotificationBadge.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Tracks incoming moderation_alert events and maintains a badge counter. + * Clears when the user navigates to the messages tab. + */ +export function useNotificationBadge(activeTab: string) { + const [count, setCount] = useState(0); + const prevActiveTab = useRef(activeTab); + + // Clear badge when user switches TO messages tab + useEffect(() => { + if (activeTab === "messages" && prevActiveTab.current !== "messages") { + setCount(0); + } + prevActiveTab.current = activeTab; + }, [activeTab]); + + const increment = useCallback(() => { + setCount((c) => c + 1); + }, []); + + // Listen for moderation_alert custom events + useEffect(() => { + const handler = () => increment(); + window.addEventListener("moderation_alert", handler); + return () => window.removeEventListener("moderation_alert", handler); + }, [increment]); + + return { count, clear: () => setCount(0) }; +} diff --git a/services/frontend/src/shared/hooks/useTheme.ts b/services/frontend/src/hooks/useTheme.ts similarity index 100% rename from services/frontend/src/shared/hooks/useTheme.ts rename to services/frontend/src/hooks/useTheme.ts diff --git a/services/frontend/src/islands/ActiveSpeakers.tsx b/services/frontend/src/islands/ActiveSpeakers.tsx deleted file mode 100644 index 42f6c9d..0000000 --- a/services/frontend/src/islands/ActiveSpeakers.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// ─── 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 deleted file mode 100644 index c6d8421..0000000 --- a/services/frontend/src/islands/AudioVisualizer.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// ─── 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 deleted file mode 100644 index de72a4e..0000000 --- a/services/frontend/src/islands/AuthGuard.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// ─── 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 deleted file mode 100644 index 4c8f7ad..0000000 --- a/services/frontend/src/islands/LiveShell.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// ─── 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 deleted file mode 100644 index c24cc79..0000000 --- a/services/frontend/src/islands/MascotChat.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// ─── 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 deleted file mode 100644 index 519f0f7..0000000 --- a/services/frontend/src/islands/MessageFeed.tsx +++ /dev/null @@ -1,356 +0,0 @@ -// ─── 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 deleted file mode 100644 index 2ecdbc1..0000000 --- a/services/frontend/src/islands/NowPlaying.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// ─── 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 deleted file mode 100644 index 1ceb904..0000000 --- a/services/frontend/src/islands/PageHeader.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// ─── 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 deleted file mode 100644 index ddea34f..0000000 --- a/services/frontend/src/islands/PageSidebar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// ─── 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 deleted file mode 100644 index d698cdf..0000000 --- a/services/frontend/src/islands/Particles.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// ─── 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 ( -