From 8166023f9196bd3663e6f6971edbb95d18132505 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 3 Jul 2026 23:11:00 +0700 Subject: [PATCH] chore: remove React frontend, rename frontend-leptos to frontend --- .gitignore | 2 +- CLAUDE.md | 32 +- package.json | 5 +- pnpm-workspace.yaml | 1 - services/frontend-leptos/.env.example | 3 - services/frontend/.env.example | 6 +- .../{frontend-leptos => frontend}/Cargo.lock | 0 .../{frontend-leptos => frontend}/Cargo.toml | 0 services/frontend/DESIGN_TOKENS.md | 753 ------------------ .../frontend/Cargo.toml | 0 .../frontend/Trunk.toml | 0 .../frontend/index.html | 0 .../frontend/public/.gitkeep | 0 .../frontend/src/api/auth.rs | 0 .../frontend/src/api/client.rs | 0 .../frontend/src/api/dashboard.rs | 0 .../frontend/src/api/mascot.rs | 0 .../frontend/src/api/messages.rs | 0 .../frontend/src/api/mod.rs | 0 .../frontend/src/api/recordings.rs | 0 .../frontend/src/api/voice.rs | 0 .../frontend/src/app.css | 0 .../frontend/src/app.rs | 0 .../frontend/src/auth.rs | 0 .../components/channel_summary_list.rs | 0 .../src/features/dashboard/components/mod.rs | 0 .../dashboard/components/stats_overview.rs | 0 .../dashboard/components/user_summary_list.rs | 0 .../frontend/src/features/dashboard/mod.rs | 0 .../frontend/src/features/live/audio/mod.rs | 0 .../src/features/live/audio/pcm_decoder.rs | 0 .../src/features/live/audio/ring_buffer.rs | 0 .../live/components/active_speakers.rs | 0 .../live/components/audio_visualizer.rs | 0 .../live/components/mic_level_meter.rs | 0 .../src/features/live/components/mod.rs | 0 .../live/components/music_sub_panel.rs | 0 .../features/live/components/now_playing.rs | 0 .../live/components/recordings_sub_panel.rs | 0 .../live/components/screen_sub_panel.rs | 0 .../live/components/voice_connection_card.rs | 0 .../live/components/waveform_player.rs | 0 .../frontend/src/features/live/hooks/mod.rs | 0 .../features/live/hooks/use_audio_playback.rs | 0 .../features/live/hooks/use_audio_transmit.rs | 0 .../features/live/hooks/use_media_control.rs | 0 .../features/live/hooks/use_voice_control.rs | 0 .../frontend/src/features/live/mod.rs | 0 .../messages/components/image_grid.rs | 0 .../messages/components/message_card.rs | 0 .../messages/components/message_feed.rs | 0 .../src/features/messages/components/mod.rs | 0 .../src/features/messages/hooks/mod.rs | 0 .../features/messages/hooks/use_messages.rs | 0 .../frontend/src/features/messages/mod.rs | 0 .../frontend/src/features/mod.rs | 0 .../polish/components/mascot_chatbot.rs | 0 .../src/features/polish/components/mod.rs | 0 .../polish/components/particle_background.rs | 0 .../polish/components/theme_toggle.rs | 0 .../frontend/src/features/polish/mod.rs | 0 .../frontend/src/layout/dashboard_layout.rs | 0 .../frontend/src/layout/header.rs | 0 .../frontend/src/layout/mobile_tab_bar.rs | 0 .../frontend/src/layout/mod.rs | 0 .../frontend/src/layout/sidebar.rs | 0 .../frontend/src/layout/tab_strip.rs | 0 .../frontend/src/lib.rs | 0 .../frontend/src/ui/badge.rs | 0 .../frontend/src/ui/button.rs | 0 .../frontend/src/ui/card.rs | 0 .../frontend/src/ui/empty_state.rs | 0 .../frontend/src/ui/input.rs | 0 .../frontend/src/ui/mod.rs | 0 .../frontend/src/ui/modal.rs | 0 .../frontend/src/ui/scroll_area.rs | 0 .../frontend/src/ui/select.rs | 0 .../frontend/src/ui/skeleton.rs | 0 .../frontend/src/ui/status_badge.rs | 0 .../frontend/src/ui/tabs.rs | 0 .../frontend/src/ui/toast.rs | 0 .../frontend/src/ws/context.rs | 0 .../frontend/src/ws/mod.rs | 0 .../frontend/src/ws/socket.rs | 0 services/frontend/index.html | 17 - services/frontend/moon.yml | 45 -- services/frontend/package.json | 38 - services/frontend/postcss.config.js | 5 - services/frontend/public/logo.svg | 44 - .../rust-toolchain.toml | 0 .../shared-types/Cargo.toml | 0 .../shared-types/src/dashboard.rs | 0 .../shared-types/src/guild.rs | 0 .../shared-types/src/lib.rs | 0 .../shared-types/src/media.rs | 0 .../shared-types/src/message.rs | 0 .../shared-types/src/recording.rs | 0 .../shared-types/src/ui_state.rs | 0 .../shared-types/src/voice.rs | 0 services/frontend/src/App.tsx | 300 ------- .../frontend/src/entities/dashboard/types.ts | 75 -- services/frontend/src/entities/guild/types.ts | 19 - services/frontend/src/entities/media/types.ts | 17 - .../frontend/src/entities/message/types.ts | 19 - .../frontend/src/entities/recording/types.ts | 23 - services/frontend/src/entities/ui/types.ts | 18 - services/frontend/src/entities/voice/types.ts | 17 - services/frontend/src/features/auth/index.tsx | 82 -- .../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 | 164 ---- .../live/components/ActiveSpeakers.tsx | 49 -- .../live/components/AudioVisualizer.tsx | 82 -- .../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 | 561 ------------- .../messages/components/MessageFeed.tsx | 120 --- .../components/ModerationAlertListener.tsx | 57 -- .../features/messages/hooks/useMessages.ts | 161 ---- .../frontend/src/features/messages/index.tsx | 315 -------- services/frontend/src/main.tsx | 19 - services/frontend/src/shared/api/client.ts | 350 -------- .../src/shared/hooks/useAsyncAction.ts | 37 - .../src/shared/hooks/useAudioPlayback.ts | 235 ------ .../src/shared/hooks/useAudioTransmit.ts | 207 ----- .../src/shared/hooks/useFramerStagger.ts | 73 -- .../src/shared/hooks/useItemDetail.ts | 58 -- .../src/shared/hooks/useLocalStorage.ts | 61 -- .../src/shared/hooks/useMascotChat.ts | 62 -- .../src/shared/hooks/usePaginatedList.ts | 105 --- .../frontend/src/shared/hooks/useTheme.ts | 39 - .../frontend/src/shared/hooks/useUIState.ts | 19 - services/frontend/src/shared/lib/logger.ts | 22 - services/frontend/src/shared/lib/utils.ts | 50 -- .../frontend/src/shared/ui/MobileTabBar.tsx | 50 -- services/frontend/src/shared/ui/badge.tsx | 54 -- services/frontend/src/shared/ui/button.tsx | 100 --- services/frontend/src/shared/ui/card.tsx | 90 --- .../frontend/src/shared/ui/empty-state.tsx | 54 -- services/frontend/src/shared/ui/index.ts | 28 - services/frontend/src/shared/ui/input.tsx | 52 -- .../frontend/src/shared/ui/profile-detail.tsx | 206 ----- .../frontend/src/shared/ui/scroll-area.tsx | 51 -- services/frontend/src/shared/ui/select.tsx | 53 -- services/frontend/src/shared/ui/skeleton.tsx | 34 - .../frontend/src/shared/ui/status-badge.tsx | 52 -- .../frontend/src/shared/ui/summary-list.tsx | 153 ---- services/frontend/src/shared/ui/tabs.tsx | 62 -- services/frontend/src/shared/ui/toast.tsx | 141 ---- services/frontend/src/shared/ws/events.ts | 77 -- services/frontend/src/shared/ws/socket.ts | 360 --------- services/frontend/src/styles.css | 675 ---------------- .../frontend/src/widgets/DashboardLayout.tsx | 95 --- services/frontend/src/widgets/Header.tsx | 133 ---- services/frontend/src/widgets/Sidebar.tsx | 208 ----- services/frontend/src/widgets/TabStrip.tsx | 49 -- .../src/widgets/mascot/MascotChatbot.tsx | 310 ------- .../src/widgets/mascot/MascotImage.tsx | 100 --- .../widgets/particles/ParticleBackground.tsx | 69 -- services/frontend/tailwind.config.js | 189 ----- services/frontend/tsconfig.json | 16 - services/frontend/vite.config.ts | 39 - 175 files changed, 22 insertions(+), 9817 deletions(-) delete mode 100644 services/frontend-leptos/.env.example rename services/{frontend-leptos => frontend}/Cargo.lock (100%) rename services/{frontend-leptos => frontend}/Cargo.toml (100%) delete mode 100644 services/frontend/DESIGN_TOKENS.md rename services/{frontend-leptos => frontend}/frontend/Cargo.toml (100%) rename services/{frontend-leptos => frontend}/frontend/Trunk.toml (100%) rename services/{frontend-leptos => frontend}/frontend/index.html (100%) rename services/{frontend-leptos => frontend}/frontend/public/.gitkeep (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/auth.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/client.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/dashboard.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/mascot.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/messages.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/recordings.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/api/voice.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/app.css (100%) rename services/{frontend-leptos => frontend}/frontend/src/app.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/auth.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/dashboard/components/channel_summary_list.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/dashboard/components/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/dashboard/components/stats_overview.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/dashboard/components/user_summary_list.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/dashboard/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/audio/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/audio/pcm_decoder.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/audio/ring_buffer.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/active_speakers.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/audio_visualizer.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/mic_level_meter.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/music_sub_panel.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/now_playing.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/recordings_sub_panel.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/screen_sub_panel.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/voice_connection_card.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/components/waveform_player.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/hooks/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/hooks/use_audio_playback.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/hooks/use_audio_transmit.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/hooks/use_media_control.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/hooks/use_voice_control.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/live/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/components/image_grid.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/components/message_card.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/components/message_feed.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/components/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/hooks/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/hooks/use_messages.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/messages/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/polish/components/mascot_chatbot.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/polish/components/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/polish/components/particle_background.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/polish/components/theme_toggle.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/features/polish/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/dashboard_layout.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/header.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/mobile_tab_bar.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/sidebar.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/layout/tab_strip.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/lib.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/badge.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/button.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/card.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/empty_state.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/input.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/modal.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/scroll_area.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/select.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/skeleton.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/status_badge.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/tabs.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ui/toast.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ws/context.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ws/mod.rs (100%) rename services/{frontend-leptos => frontend}/frontend/src/ws/socket.rs (100%) delete mode 100644 services/frontend/index.html delete mode 100644 services/frontend/moon.yml delete mode 100644 services/frontend/package.json delete mode 100644 services/frontend/postcss.config.js delete mode 100644 services/frontend/public/logo.svg rename services/{frontend-leptos => frontend}/rust-toolchain.toml (100%) rename services/{frontend-leptos => frontend}/shared-types/Cargo.toml (100%) rename services/{frontend-leptos => frontend}/shared-types/src/dashboard.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/guild.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/lib.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/media.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/message.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/recording.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/ui_state.rs (100%) rename services/{frontend-leptos => frontend}/shared-types/src/voice.rs (100%) delete mode 100644 services/frontend/src/App.tsx delete mode 100644 services/frontend/src/entities/dashboard/types.ts delete mode 100644 services/frontend/src/entities/guild/types.ts delete mode 100644 services/frontend/src/entities/media/types.ts delete mode 100644 services/frontend/src/entities/message/types.ts delete mode 100644 services/frontend/src/entities/recording/types.ts delete mode 100644 services/frontend/src/entities/ui/types.ts delete mode 100644 services/frontend/src/entities/voice/types.ts delete mode 100644 services/frontend/src/features/auth/index.tsx delete mode 100644 services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx delete mode 100644 services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx delete mode 100644 services/frontend/src/features/dashboard/components/DashboardStats.tsx delete mode 100644 services/frontend/src/features/dashboard/components/UserProfileDetail.tsx delete mode 100644 services/frontend/src/features/dashboard/components/UserSummaryList.tsx delete mode 100644 services/frontend/src/features/dashboard/hooks/useDashboard.ts delete mode 100644 services/frontend/src/features/dashboard/index.tsx delete mode 100644 services/frontend/src/features/live/components/ActiveSpeakers.tsx delete mode 100644 services/frontend/src/features/live/components/AudioVisualizer.tsx delete mode 100644 services/frontend/src/features/live/components/MicLevelMeter.tsx delete mode 100644 services/frontend/src/features/live/components/MusicSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/NowPlaying.tsx delete mode 100644 services/frontend/src/features/live/components/RecordingsSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/ScreenSubPanel.tsx delete mode 100644 services/frontend/src/features/live/components/VoiceConnectionCard.tsx delete mode 100644 services/frontend/src/features/live/components/WaveformPlayer.tsx delete mode 100644 services/frontend/src/features/live/hooks/useMediaControl.ts delete mode 100644 services/frontend/src/features/live/hooks/useVoiceControl.ts delete mode 100644 services/frontend/src/features/live/index.tsx delete mode 100644 services/frontend/src/features/messages/components/ImageGrid.tsx delete mode 100644 services/frontend/src/features/messages/components/MessageCard.tsx delete mode 100644 services/frontend/src/features/messages/components/MessageFeed.tsx delete mode 100644 services/frontend/src/features/messages/components/ModerationAlertListener.tsx delete mode 100644 services/frontend/src/features/messages/hooks/useMessages.ts delete mode 100644 services/frontend/src/features/messages/index.tsx delete mode 100644 services/frontend/src/main.tsx delete mode 100644 services/frontend/src/shared/api/client.ts delete mode 100644 services/frontend/src/shared/hooks/useAsyncAction.ts delete mode 100644 services/frontend/src/shared/hooks/useAudioPlayback.ts delete mode 100644 services/frontend/src/shared/hooks/useAudioTransmit.ts delete mode 100644 services/frontend/src/shared/hooks/useFramerStagger.ts delete mode 100644 services/frontend/src/shared/hooks/useItemDetail.ts delete mode 100644 services/frontend/src/shared/hooks/useLocalStorage.ts delete mode 100644 services/frontend/src/shared/hooks/useMascotChat.ts delete mode 100644 services/frontend/src/shared/hooks/usePaginatedList.ts delete mode 100644 services/frontend/src/shared/hooks/useTheme.ts delete mode 100644 services/frontend/src/shared/hooks/useUIState.ts delete mode 100644 services/frontend/src/shared/lib/logger.ts delete mode 100644 services/frontend/src/shared/lib/utils.ts delete mode 100644 services/frontend/src/shared/ui/MobileTabBar.tsx delete mode 100644 services/frontend/src/shared/ui/badge.tsx delete mode 100644 services/frontend/src/shared/ui/button.tsx delete mode 100644 services/frontend/src/shared/ui/card.tsx delete mode 100644 services/frontend/src/shared/ui/empty-state.tsx delete mode 100644 services/frontend/src/shared/ui/index.ts delete mode 100644 services/frontend/src/shared/ui/input.tsx delete mode 100644 services/frontend/src/shared/ui/profile-detail.tsx delete mode 100644 services/frontend/src/shared/ui/scroll-area.tsx delete mode 100644 services/frontend/src/shared/ui/select.tsx delete mode 100644 services/frontend/src/shared/ui/skeleton.tsx delete mode 100644 services/frontend/src/shared/ui/status-badge.tsx delete mode 100644 services/frontend/src/shared/ui/summary-list.tsx delete mode 100644 services/frontend/src/shared/ui/tabs.tsx delete mode 100644 services/frontend/src/shared/ui/toast.tsx delete mode 100644 services/frontend/src/shared/ws/events.ts delete mode 100644 services/frontend/src/shared/ws/socket.ts delete mode 100644 services/frontend/src/styles.css delete mode 100644 services/frontend/src/widgets/DashboardLayout.tsx delete mode 100644 services/frontend/src/widgets/Header.tsx delete mode 100644 services/frontend/src/widgets/Sidebar.tsx delete mode 100644 services/frontend/src/widgets/TabStrip.tsx delete mode 100644 services/frontend/src/widgets/mascot/MascotChatbot.tsx delete mode 100644 services/frontend/src/widgets/mascot/MascotImage.tsx delete mode 100644 services/frontend/src/widgets/particles/ParticleBackground.tsx delete mode 100644 services/frontend/tailwind.config.js delete mode 100644 services/frontend/tsconfig.json delete mode 100644 services/frontend/vite.config.ts diff --git a/.gitignore b/.gitignore index 064f75c..3eea1af 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ logs/ .moon/docker worktrees/ .worktrees/ -frontend-leptos/frontend/dist/ +services/frontend/frontend/dist/ target/ diff --git a/CLAUDE.md b/CLAUDE.md index 133aee5..ca1f4db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Built with **pnpm workspace monorepo** with 3 services and 1 shared library: |---------|------|-------------| | `discord-moderation-backend` | `services/backend` | Express HTTP/WS server, REST API, Redis bridge | | `@bete/discord-gateway` | `services/discord-gateway` | Discord client, voice recording, message capture, AI moderation | -| `@gmw/frontend` | `services/frontend` | React 19 + Vite + Tailwind web dashboard | +| `frontend` | `services/frontend/frontend` | Leptos 0.7 CSR WASM dashboard | | `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities | **Database:** PostgreSQL (Drizzle ORM) — NOT SQLite. @@ -28,7 +28,7 @@ Discord | v discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend - | pub/sub (broadcast) (React) + | pub/sub (broadcast) (Leptos WASM) | | | | <------------------+ @@ -194,19 +194,18 @@ The core service that connects to Discord using `discord.js-selfbot-v13`. - `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries - `src/shared/discord/clientOptions.ts` — Discord client configuration -### frontend (`services/frontend`) +### frontend (`services/frontend/frontend`) -React 19 + Vite 8 + Tailwind CSS 4 + TypeScript dashboard. +Leptos 0.7 CSR WASM + TypeScript + CSS dashboard. **Tech stack:** -- React 19 with hooks -- Vite 8 (rolldown) for bundling -- Tailwind CSS 4 with PostCSS -- Three.js + React Three Fiber + Drei for 3D visualizations -- GSAP + Framer Motion for animations -- Radix UI primitives (ScrollArea, Slot, Tabs) -- TanStack React Query for data fetching -- Lucide React for icons +- Leptos 0.7 CSR (WASM via `#[wasm_bindgen(start)]`) +- Trunk for bundling +- Plain CSS with design tokens +- Canvas 2D via `web-sys` for audio visualization +- CSS animations (GSAP/Framer Motion dihapus — unused) +- `web-sys` primitives (WebSocket, AudioContext, IntersectionObserver) +- `lucide-leptos` 3 for icons **Feature structure (entity + feature slices):** - `entities/` — Type exports re-exported from shared API client @@ -389,15 +388,16 @@ pnpm install # Run each service in development mode (separate terminal each) pnpm run dev:backend # Backend on port 3001 pnpm run dev:discord-gateway # Discord client + all features -pnpm run dev:web # Frontend on Vite dev server +pnpm run dev:web # Frontend via trunk serve # Build pnpm run build:backend pnpm run build:discord-gateway -pnpm run build:web +pnpm run build:web # trunk build --release -# Type checking across all packages -pnpm run typecheck +# Type checking +pnpm run typecheck # Node services (pnpm -r) +pnpm run typecheck:web # Leptos frontend (cargo check) # Lint (Biome) pnpm run lint diff --git a/package.json b/package.json index 0dba5b7..034ea7b 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "scripts": { "dev:backend": "pnpm --filter './services/backend' run dev", "dev:discord-gateway": "pnpm --filter './services/discord-gateway' run dev", - "dev:web": "pnpm --filter './services/frontend' run dev", + "dev:web": "cd services/frontend/frontend && trunk serve --address 0.0.0.0", + "build:web": "cd services/frontend/frontend && trunk build --release", + "typecheck:web": "cd services/frontend/frontend && cargo check", "build:backend": "pnpm --filter './services/backend' run build", "build:discord-gateway": "pnpm --filter './services/discord-gateway' run build", - "build:web": "pnpm --filter './services/frontend' run build", "typecheck": "pnpm -r run typecheck", "lint": "biome check --diagnostic-level=error .", "format": "biome format --write .", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ac14e76..65017cb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,4 @@ packages: - - services/frontend - services/backend - services/discord-gateway - packages/shared diff --git a/services/frontend-leptos/.env.example b/services/frontend-leptos/.env.example deleted file mode 100644 index 3634e80..0000000 --- a/services/frontend-leptos/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# API/WS endpoints — set these before building -VITE_BE_API_URL=http://localhost:3001 -VITE_BE_WS_URL=ws://localhost:3001/ws diff --git a/services/frontend/.env.example b/services/frontend/.env.example index c255d15..3634e80 100644 --- a/services/frontend/.env.example +++ b/services/frontend/.env.example @@ -1,5 +1,3 @@ -# Backend API URL (default: http://localhost:3001) +# API/WS endpoints — set these before building VITE_BE_API_URL=http://localhost:3001 - -# Backend WebSocket URL (default: ws://localhost:3001) -VITE_BE_WS_URL=ws://localhost:3001 +VITE_BE_WS_URL=ws://localhost:3001/ws diff --git a/services/frontend-leptos/Cargo.lock b/services/frontend/Cargo.lock similarity index 100% rename from services/frontend-leptos/Cargo.lock rename to services/frontend/Cargo.lock diff --git a/services/frontend-leptos/Cargo.toml b/services/frontend/Cargo.toml similarity index 100% rename from services/frontend-leptos/Cargo.toml rename to services/frontend/Cargo.toml diff --git a/services/frontend/DESIGN_TOKENS.md b/services/frontend/DESIGN_TOKENS.md deleted file mode 100644 index 0cf01ba..0000000 --- a/services/frontend/DESIGN_TOKENS.md +++ /dev/null @@ -1,753 +0,0 @@ -# Design Tokens — Bete Frontend - -## Table of Contents - -1. [CSS Custom Properties (OKLCH)](#1-css-custom-properties-oklch) -2. [Z-Index Registry](#2-z-index-registry) -3. [Spacing & Layout Patterns](#3-spacing--layout-patterns) -4. [Typography](#4-typography) -5. [Animation Keyframes](#5-animation-keyframes) -6. [Animation Utility Classes](#6-animation-utility-classes) -7. [Framer Motion Presets](#7-framer-motion-presets) -8. [GSAP Page Transitions](#8-gsap-page-transitions) -9. [Card Pattern](#9-card-pattern) -10. [Stagger Animation Pattern](#10-stagger-animation-pattern) -11. [Component Patterns & Variants](#11-component-patterns--variants) -12. [Reduced-Motion Handling](#12-reduced-motion-handling) -13. [Known Issues & Technical Debt](#13-known-issues--technical-debt) - ---- - -## 1. CSS Custom Properties (OKLCH) - -Defined in `styles.css` on `:root`. All use the OKLCH color space (`lightness chroma hue`). Consumed via `oklch(var(--name))` in CSS and `oklch(var(--name))` in the Tailwind config (`tailwind.config.js`). - -### Surface Colors - -| Variable | OKLCH Value | Description | -|------------------------|--------------------------|------------------------------------| -| `--background` | `1 0 0` | Page background (white) | -| `--foreground` | `0.141 0.005 285.823` | Body text (near-black) | -| `--card` | `1 0 0` | Card surface (white) | -| `--card-foreground` | `0.141 0.005 285.823` | Card text (near-black) | -| `--border` | `0.92 0.004 286.32` | Default border | -| `--input` | `0.92 0.004 286.32` | Input border (same as `--border`) | - -### Interaction Colors - -| Variable | OKLCH Value | Description | -|------------------------|--------------------------|------------------------------------| -| `--primary` | `0.623 0.214 259.815` | Primary accent (blue) | -| `--primary-soft` | `0.92 0.04 259.815` | Soft primary background | -| `--primary-foreground` | `0.97 0.014 254.604` | Text on primary (near-white) | -| `--secondary` | `0.967 0.001 286.375` | Secondary surface (light gray) | -| `--secondary-foreground`| `0.21 0.006 285.885` | Text on secondary | -| `--muted` | `0.967 0.001 286.375` | Muted surface (same as secondary) | -| `--muted-foreground` | `0.552 0.016 285.938` | Muted text (medium gray) | -| `--accent` | `0.967 0.001 286.375` | Accent surface | -| `--accent-foreground` | `0.21 0.006 285.885` | Text on accent | -| `--destructive` | `0.577 0.245 27.325` | Destructive action (reddish) | -| `--destructive-foreground`| `0.97 0.014 254.604` | Text on destructive | - -### Semantic / Effect Colors - -| Variable | OKLCH Value | Description | -|--------------------------|-------------------------------|--------------------------------------| -| `--ring` | `0.623 0.214 259.815` | Focus ring (same as primary) | -| `--radius` | `1rem` | Border radius base | -| `--primary-glow` | `0.623 0.214 259.815 / 0.15` | Primary glow backdrop-filter orb | -| `--accent-glow` | `0.552 0.016 285.938 / 0.15` | Accent glow | -| `--card-shadow` | `0.92 0.004 286.32 / 0.3` | Card box-shadow color | - -### Tailwind Config Mapping - -All custom properties are wired into the Tailwind theme under `theme.extend.colors`: - -```js -colors: { - border: "oklch(var(--border))", - input: "oklch(var(--input))", - ring: "oklch(var(--ring))", - background: "oklch(var(--background))", - foreground: "oklch(var(--foreground))", - "primary-soft":"oklch(var(--primary-soft))", - "primary-glow":"oklch(var(--primary-glow))", - "accent-glow": "oklch(var(--accent-glow))", - primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" }, - secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" }, - muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" }, - accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" }, - destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" }, - card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" }, -}, -borderRadius: { - lg: "var(--radius)", // 1rem - md: "calc(var(--radius) - 2px)", // 0.875rem - sm: "calc(var(--radius) - 4px)", // 0.75rem -}, -``` - -### Color vs. Variable Hardcoding - -**Prefer CSS variables**. The following are supposed to use CSS vars but are **hardcoded to Tailwind utility colors** — see [Known Issues §13.1](#131-hardcoded-utility-colors--no-css-var). - ---- - -## 2. Z-Index Registry - -All stacking contexts in the application, inventoried from `*.tsx` and `*.css` files. - -| Value | Owner | Context / Location | -|-----------|--------------------------|-------------------------------------------------| -| `-1` | `ParticleBackground.tsx` | Inline `style={{ zIndex: -1 }}` — decorative orbs sit behind all content | -| `10` | `Header.tsx` | Class `sticky top-0 z-10` — sticky page header | -| `50` | `Sidebar.tsx` line 110 | Class `relative z-50` — mascot chatbot toggle button | -| `50` | `MobileTabBar.tsx` | Class `fixed bottom-0 left-0 right-0 z-50` — mobile bottom nav | -| `50` | `toast.tsx` | Class `fixed bottom-4 right-4 z-50` — toast notification container | -| `[9999]` | `Sidebar.tsx` line 122 | Class `fixed bottom-[170px] left-[80px] z-[9999]` — MascotChatbot floating panel | - -### Stacking Order (bottom to top) - -1. **Layer -1**: Particle background orbs (`ParticleBackground`) -2. **Layer 0**: Main page content, sidebar, cards -3. **Layer 10**: Sticky header (`Header`) -4. **Layer 50**: Toast container, mobile tab bar, mascot toggle button -5. **Layer 9999**: Mascot chatbot floating panel - -**Note**: There is no established z-index scale. Values 20, 30, 40 are unused. The arbitrary `z-[9999]` for the chatbot is an outlier — future additions should use a defined scale (e.g., 10/20/30/40/50/100) rather than arbitrary large numbers. - ---- - -## 3. Spacing & Layout Patterns - -### Page Layout - -``` -DashboardLayout - ├── ParticleBackground (fixed inset-0) - ├── grid-pattern overlay (fixed inset-0, pointer-events-none) - │ - └── flex container (relative flex min-h-screen) - ├── Sidebar (shrink-0, w-16 collapsed / w-64 expanded, hidden on sm), user card 2-col, user detail layout | -| `gap-6` | Top-level panel sections (dashboard, live) | -| `gap-2` | Filter badges, button groups, message row indicators, toast container | -| `space-y-1` | Compact text+value pairs | -| `space-y-2` | Active speakers list, recording list, message metadata blocks | -| `space-y-3` | Message feed items, skeleton groups | -| `space-y-4` | Auth form, music panel | -| `space-y-6` | User profile detail sections | - -### Responsive Grid Breakpoints - -- `sm:grid-cols-2` — stat cards, user cards, recordings cards -- `lg:grid-cols-4` — stat summary cards -- `xl:grid-cols-3`, `2xl:grid-cols-4` — image grid -- `xl:grid-cols-[1fr_320px]` — live audio + sidebar layout -- `md:grid-cols-2` — voice connection guild/channel selects -- `md:grid-cols-3` — moderation queue stat cards - ---- - -## 4. Typography - -### Font Family - -```css -font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -``` - -Defined in `styles.css` and `tailwind.config.js` under `theme.extend.fontFamily.sans`. - -### Type Scale - -| Class | Used For | -|----------------|--------------------------------------------| -| `text-[10px]` | Badge text, filter labels, status chips | -| `text-[11px]` | Message timestamps, edited/deleted indicators, action hints | -| `text-[12px]` | AI analysis body text | -| `text-xs` | Captions, descriptions, metadata, secondary info, badge text | -| `text-sm` | Body text, card descriptions, message content, form labels | -| `text-base` | Card titles (sometimes), live audio heading | -| `text-lg` | Section headings, stat values | -| `text-xl` | Page title (`h1`) | -| `text-2xl` | Stat card values, moderation queue numbers | - -### Font Weights - -| Weight | Usage | -|-----------------|--------------------------------------------| -| `font-medium` | Sidebar items, timestamps, badge text, section labels, queue items | -| `font-semibold` | CardTitle, user names, tracking-tight headings | -| `font-bold` | Page titles, stat values, key numbers | - -### Tracking - -- `tracking-tight`: CardTitle, page `h1` -- `tracking-wider`: Image grid kind badges - -### Font Mono - -- `font-mono`: Channel IDs, user IDs - ---- - -## 5. Animation Keyframes - -### Defined in `styles.css` - -```css -@keyframes bar-pulse { - 0%, 100% { transform: scaleY(0.8); } - 50% { transform: scaleY(1.2); } -} - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -@keyframes fadeInUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} -``` - -### Defined in `tailwind.config.js` only (`glowPulse`) - -```js -keyframes: { - glowPulse: { - "0%, 100%": { opacity: "0.4" }, - "50%": { opacity: "0.8" }, - }, -}, -``` - -**Note**: `glowPulse` is NOT defined in `styles.css`. It only exists in `tailwind.config.js` and is used by `ParticleBackground.tsx` via the class `animate-glow-pulse`. This works because Tailwind 4 uses the JS config for keyframes, but the CSS file is the canonical keyframe source. This is a **latent inconsistency** — see Known Issues §13.3. - -### Animation Timings Summary - -| Keyframe | Duration | Timing Function | Used In | -|----------------|----------|------------------------|--------------------------------| -| bar-pulse | 0.4s | ease-in-out | AudioVisualizer (via CSS class)| -| shimmer | 1.5s (CSS) / 2s (TW config) | ease-in-out | Skeleton loading components | -| fadeInUp | 0.5s | ease-out | Utility class `.animate-fade-in-up` | -| fadeIn | 0.3s | ease-out | Utility class `.animate-fade-in` | -| glowPulse | 3s | ease-in-out | ParticleBackground glow orbs | - -**Mismatch**: `shimmer` duration is `1.5s` in `styles.css` but `2s` in `tailwind.config.js`. The CSS class `.animate-shimmer` is used by `Skeleton.tsx`, so the CSS definition wins. - ---- - -## 6. Animation Utility Classes - -Defined in `styles.css` under `@layer utilities`: - -| Class | Animation | Purpose | -|----------------------|------------------------------|--------------------------------| -| `.animate-fade-in-up`| `fadeInUp 0.5s ease-out` | Entry animation for elements | -| `.animate-fade-in` | `fadeIn 0.3s ease-out` | Simple fade-in | -| `.animate-bar-pulse` | `bar-pulse 0.4s ease-in-out infinite` | Audio visualizer bars (transform-origin: bottom) | -| `.animate-shimmer` | `shimmer 1.5s ease-in-out infinite` | Skeleton loading placeholder (gradient sweep) | - -Tailwind config also registers `animate-glow-pulse` (`glowPulse 3s ease-in-out infinite`). - -**Usage**: `Skeleton` component uses `animate-shimmer`. Inline `animate-spin` (Tailwind built-in) is used for loading spinners on re-analyze buttons. - ---- - -## 7. Framer Motion Presets - -All defined in `shared/hooks/useFramerStagger.ts`. - -### `cardStagger` — Parent container variant -```ts -{ - initial: { opacity: 0 }, - animate: { - opacity: 1, - transition: { staggerChildren: 0.08, delayChildren: 0.1 }, - }, -} -``` - -### `cardItem` — Child item (fade + slide up) -```ts -{ - initial: { opacity: 0, y: 20 }, - animate: { - opacity: 1, y: 0, - transition: { duration: 0.4, ease: "easeOut" }, - }, -} -``` - -### `fadeSlideUp` — Single element (cubic-bezier) -```ts -{ - initial: { opacity: 0, y: 24 }, - animate: { - opacity: 1, y: 0, - transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }, - }, - exit: { opacity: 0, y: -12, transition: { duration: 0.2 } }, -} -``` -Used by `Header.tsx` (key=activeTab) and `DashboardLayout.tsx` (main content area, key=activeTab). - -### `fadeIn` — Simple opacity -```ts -{ - initial: { opacity: 0 }, - animate: { opacity: 1, transition: { duration: 0.3 } }, - exit: { opacity: 0, transition: { duration: 0.2 } }, -} -``` - -### `scaleIn` — Badge/pill entrance -```ts -{ - initial: { opacity: 0, scale: 0.8 }, - animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: "backOut" } }, -} -``` - -### `springUp` — Emphasized entrance -```ts -{ - initial: { opacity: 0, y: 30 }, - animate: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 200, damping: 20 } }, -} -``` - -### Usage Pattern - -```tsx - - ... - ... - -``` - ---- - -## 8. GSAP Page Transitions - -### `useGsapTransition` (`shared/hooks/useGsapTransition.ts`) - -A custom hook for tab-level page transitions, used by `AuthOverlay.tsx`. - -**Page enter animation**: -- Container: opacity 0→1, y 20→0, `duration: 0.4`, `ease: "power2.out"` -- Stagger children: querySelectorAll `[data-stagger]`, `duration: 0.3`, `stagger: 0.05` -- Reduced motion: all durations set to 0 - -**Page exit animation** (returns Promise): -- Container: opacity→0, y→20, `duration: 0.3`, `ease: "power2.in"` - -**Card hover helper** (`gsapCardHover`): -```ts -onMouseEnter: gsap.to(target, { y: -4, boxShadow: "0 8px 25px rgba(0,0,0,0.15)", duration: 0.2 }) -onMouseLeave: gsap.to(target, { y: 0, boxShadow: "0 2px 8px rgba(0,0,0,0.08)", duration: 0.2 }) -``` -(Exported but unused in current components — kept as utility.) - ---- - -## 9. Card Pattern - -### Base `Card` component (`shared/ui/card.tsx`) - -``` -rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow -``` - -### Composition - -| Sub-component | Classes | Notes | -|----------------|------------------------------|--------------------------------| -| `Card` | See above | `transition-shadow` for hover | -| `CardHeader` | `flex flex-col space-y-1.5 p-6` | | -| `CardTitle` | `font-semibold leading-none tracking-tight` | `h3` element | -| `CardDescription` | `text-sm text-muted-foreground` | `p` element | -| `CardContent` | `p-6 pt-0` | `pt-0` to collapse with header | -| `CardFooter` | `flex items-center p-6 pt-0` | | - -### Common Card Variants - -| Context | Additional Classes | -|------------------|-----------------------------------------------------| -| Stat card | `CardContent p-4` (compact) | -| Dashboard stat | `overflow-hidden` | -| Message card | `group hover:border-primary/30 hover:shadow-md` | -| Auth card | `w-full max-w-md border-primary/30 shadow-lg shadow-primary/10` | -| Error/empty card | `border-dashed border-destructive` (error state) | -| User card | `hover:ring-1 hover:ring-primary/30 cursor-pointer` | -| Media queue item | `rounded-lg border-l-2 border-l-primary border-border` | - -### Empty State Pattern - -```tsx -
- -

No data to display

-
-``` -Wrapped in `EmptyStateMascot` component. Also used via standalone icon pattern: -```tsx -
- -

No data available yet.

-
-``` - -### Skeleton Loading Pattern - -```tsx - -``` -Used in `.animate-shimmer` containers matching real card layout. See `MessageCardSkeleton`, `StatsSkeleton`, `UserListSkeleton`, `DetailSkeleton`, `ActiveSpeakersSkeleton`. - ---- - -## 10. Stagger Animation Pattern - -### Framer Motion (primary, used in panels) - -Applied as `cardStagger` / `cardItem` pair on every major panel: - -| Panel | File | -|----------------------|----------------------------------| -| LivePanel | `features/live/index.tsx` | -| MessagesPanel | `features/messages/index.tsx` | -| DashboardStatsContent| `features/dashboard/components/DashboardStats.tsx` | -| UserSummaryList | `features/dashboard/components/UserSummaryList.tsx` | -| UserProfileDetail | `features/dashboard/components/UserProfileDetail.tsx` | -| MessageFeed | `features/messages/components/MessageFeed.tsx` | - -**Timing**: stagger 80ms, delayChildren 100ms, item duration 400ms. - -### GSAP (used in auth flow) - -`useGsapTransition` with `[data-stagger]` attributes. Stagger 50ms, duration 300ms. - ---- - -## 11. Component Patterns & Variants - -### Button (`shared/ui/button.tsx`) - -**Variants**: `default`, `secondary`, `destructive`, `outline`, `ghost` -**Sizes**: `default` (h-10), `sm` (h-9), `lg` (h-11), `icon` (h-10 w-10) - -Base classes: -``` -inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium -transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring -active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 -``` - -| Variant | Background | -|--------------|---------------------------------------| -| default | `bg-primary text-primary-foreground shadow-sm hover:bg-primary/90` | -| secondary | `bg-secondary text-secondary-foreground hover:bg-secondary/80` | -| destructive | `bg-destructive text-destructive-foreground hover:bg-destructive/90` | -| outline | `border border-input bg-background hover:bg-accent hover:text-accent-foreground` | -| ghost | `hover:bg-accent hover:text-accent-foreground` | - -### Badge (`shared/ui/badge.tsx`) - -**Variants**: `default`, `secondary`, `destructive`, `outline`, `success`, `warning` - -Base classes: -``` -inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors -``` - -| Variant | Classes (uses CSS vars where possible) | -|------------|------------------------------------------------------| -| default | `bg-primary text-primary-foreground` | -| secondary | `bg-muted text-muted-foreground` | -| destructive| `bg-destructive/15 text-destructive` | -| outline | `border-border text-foreground` | -| success | `bg-emerald-100 text-emerald-700` **[HARDCODED]** | -| warning | `bg-amber-100 text-amber-700` **[HARDCODED]** | - -### Input (`shared/ui/input.tsx`) - -``` -flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground -ring-offset-background placeholder:text-muted-foreground -focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 -disabled:cursor-not-allowed disabled:opacity-50 -``` - -### Select (`shared/ui/select.tsx`) - -Same visual treatment as Input (`flex h-10 w-full rounded-lg border border-input bg-background ...`). - -### Skeleton (`shared/ui/skeleton.tsx`) - -``` -rounded-lg bg-muted animate-shimmer -``` - -### Tabs (`shared/ui/tabs.tsx`) - -Wraps Radix `TabsPrimitive`: - -| Component | Key Classes | -|---------------|-----------------------------------------------------------| -| `TabsList` | `inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground` | -| `TabsTrigger` | `inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all ... data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm` | -| `TabsContent` | `mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | - -### Toast (`shared/ui/toast.tsx`) - -Context-based system with `addToast` / `removeToast`. Auto-dismiss after 4s. - -Container: `fixed bottom-4 right-4 z-50 flex flex-col gap-2` - -Type styles: - -| Type | Border-left class | Icon color | -|---------|----------------------------------|--------------------| -| info | `border-l-primary` | `text-primary` | -| success | `border-l-emerald-500` | `text-emerald-500` | -| error | `border-l-destructive` | `text-destructive` | -| warning | `border-l-amber-500` | `text-amber-500` | - -**Note**: `emerald-500` and `amber-500` are hardcoded Tailwind utilities — see Known Issues. - -### Glass Card (utility, `styles.css`) - -```css -.glass-card { - @apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl; -} -``` - -### Grid Pattern (utility, `styles.css`) - -```css -.grid-pattern { - background-image: - linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px), - linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px); - background-size: 40px 40px; -} -``` -Applied in `DashboardLayout.tsx` at `opacity-[0.03]`. - -### Gradient Text - -```css -.gradient-text { - @apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400; -} -``` -Used in `Header.tsx` for the "IMPHNEN" brand text. - ---- - -## 12. Reduced-Motion Handling - -Three layers of protection: - -### 1. CSS Level (`styles.css`) - -```css -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} -``` - -### 2. GSAP Hook (`useGsapTransition`) - -```ts -function prefersReducedMotion(): boolean { - if (typeof window === "undefined") return false; - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; -} -``` -Passes `instant` flag → all durations set to 0, stagger set to 0. - -### 3. Particle Background (`ParticleBackground.tsx`) - -```tsx -const [reducedMotion, setReducedMotion] = useState(false); -useEffect(() => { - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - setReducedMotion(mq.matches); - // listener for changes... -}, []); -if (reducedMotion) return null; // Entire particle layer removed -``` - -### Coverage Gap - -Framer Motion `AnimatePresence` and `motion` components (used extensively in MessageFeed, LivePanel, DashboardStats, UserSummaryList, UserProfileDetail, MascotChatbot) do **not** check `prefers-reduced-motion`. They rely solely on the CSS override. While the CSS `!important` rules do suppress Framer Motion animations (since they run via inline styles that get overridden), this is a fragile approach — some Framer Motion `transition` properties (spring physics, stagger delays) may not be fully neutralized by the CSS blanket rule. - ---- - -## 13. Known Issues & Technical Debt - -### 13.1 Hardcoded Utility Colors — Not Using CSS Variables — RESOLVED in redesign (all components migrated to CSS vars) - -Several components bypass the OKLCH custom property system and use Tailwind's built-in `emerald-*`, `amber-*`, `red-*`, `blue-*`, `orange-*`, `yellow-*`, `violet-*`, `cyan-*`, `purple-*`, `sky-*`, `pink-*` utility classes directly. These will not respond to theme changes. - -**Locations**: - -| File(s) | Hardcoded Colors Used | -|-------------------------------------------|--------------------------------------------| -| `shared/ui/badge.tsx` | `bg-emerald-100 text-emerald-700` (success variant), `bg-amber-100 text-amber-700` (warning variant) | -| `shared/ui/toast.tsx` | `border-l-emerald-500`, `text-emerald-500`, `border-l-amber-500`, `text-amber-500` | -| `shared/ui/button.tsx` | None (uses CSS vars correctly) | -| `features/dashboard/components/DashboardStats.tsx` | `text-emerald-500`, `bg-emerald-100`, `text-blue-500`, `bg-blue-100`, `text-violet-500`, `bg-violet-100`, `text-emerald-600`, `bg-cyan-100`, `text-cyan-500`, `text-amber-500`, `bg-amber-100` | -| `features/dashboard/components/UserSummaryList.tsx` | `bg-emerald-100 text-emerald-700`, `bg-amber-100 text-amber-700`, `bg-red-100 text-red-700` | -| `features/dashboard/components/UserProfileDetail.tsx` | `bg-red-100 text-red-700`, `bg-emerald-100 text-emerald-700`, `bg-amber-100 text-amber-700`, `text-emerald-600` | -| `features/messages/components/MessageCard.tsx` | `bg-red-100 text-red-700 border-red-200`, `bg-orange-100 text-orange-700 border-orange-200`, `bg-yellow-100 text-yellow-700 border-yellow-200`, `bg-blue-100 text-blue-700 border-blue-200`, `bg-emerald-50/40`, `border-l-emerald-400`, `bg-pink-50/40`, `border-l-pink-400`, `text-pink-600`, `text-pink-600/70` | -| `features/messages/components/MessagesPanel.tsx` | `bg-emerald-100 text-emerald-700 border-emerald-200`, `bg-orange-100 text-orange-700 border-orange-200`, `bg-red-100 text-red-700 border-red-200`, `text-emerald-600` | -| `features/messages/components/ImageGrid.tsx` | `bg-purple-100 text-purple-700 border-purple-200` | -| `features/live/components/ActiveSpeakers.tsx` | `text-emerald-700` | -| `features/live/components/RecordingsSubPanel.tsx` | `border-sky-200 bg-white` | -| `features/live/components/NowPlaying.tsx` | (uses Badge `success`/`warning` variants, which are hardcoded) | -| `widgets/Header.tsx` | `bg-emerald-400`, `bg-red-400`, `bg-gray-400`, `text-emerald-400`, `text-red-400` | - -**Fix**: Replace with CSS variable-based tokens, e.g.: -- `emerald-100` → `oklch(var(--success-bg))` (would need new custom property) -- `text-emerald-700` → `oklch(var(--success-fg))` -- `border-emerald-200` → `oklch(var(--success-border))` - -### 13.2 `formatTimeAgo` Forces Re-render on Every Tick - -In `features/messages/components/MessageCard.tsx`: - -```ts -function formatTimeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - // ... -} -``` - -`Date.now()` is called on every render, not via a periodic timer. This means the "X s ago" / "X m ago" labels are only accurate at the moment of render and do not update reactively. No `useEffect` interval keeps them fresh. Once rendered, a "5s ago" label will stay stale until a parent re-render. This is acceptable for a message feed that re-renders on new data, but inaccurate for pinned/static views. - -**Fix**: Either (a) accept staleness (current behavior, simplest), (b) add a `useEffect` interval that forces re-render every ~30s, or (c) use `useSyncExternalStore` with a global ticker. - -### 13.3 `AudioVisualizer.tsx` — Hardcoded IMPHNEN Gradient - -```ts -const gradient = ctx.createLinearGradient(0, 0, 0, height); -gradient.addColorStop(0, "#23a1eb"); -gradient.addColorStop(1, "#3eb0f2"); -``` - -These hex values (IMPHNEN blue `#23a1eb` → `#3eb0f2`) are hardcoded and do not reference the OKLCH `--primary` CSS variable. A theme change would not affect the visualizer. - -**Fix**: Read CSS custom property at paint time: -```ts -const primaryColor = getComputedStyle(document.documentElement) - .getPropertyValue("--primary").trim(); -// Convert OKLCH to hex or use Canvas oklch() if available -``` - -### 13.4 `glow-pulse` Keyframe Fragmentation — RESOLVED in T02 (moved to styles.css) - -The `glowPulse` keyframes are defined in `tailwind.config.js` but NOT in `styles.css`. The class `animate-glow-pulse` is referenced by `ParticleBackground.tsx`. Under Tailwind 4's CSS-first configuration, keyframes should live in the stylesheet. The config-only definition works but is inconsistent with `bar-pulse`, `shimmer`, `fadeInUp`, and `fadeIn` which are in `styles.css`. - -**Fix**: Move `@keyframes glowPulse { ... }` into `styles.css`. - -### 13.5 `shimmer` Duration Mismatch — RESOLVED in T02 (CSS canonical at 1.5s) - -`styles.css`: `animation: shimmer 1.5s ease-in-out infinite` -`tailwind.config.js`: `shimmer: "shimmer 2s linear infinite"` (also uses `linear` vs. `ease-in-out`) - -The CSS class `.animate-shimmer` (used by `Skeleton.tsx`) references the CSS-based keyframe, so `1.5s ease-in-out` is the actual runtime value. The Tailwind config value is dead unless used via `animate-shimmer` as a Tailwind class name — which maps to `shimmer 2s linear infinite`. - -**Fix**: Align both sources. Pick one canonical definition. - -### 13.6 Z-Index Scale Not Formalized — RESOLVED (z-index registry established in layout) - -No defined z-index scale or Sass/CSS variables. Values jump from 50 to 9999. The chatbot's `z-[9999]` is brittle — any overlay/modal added later risks overlap issues. - -**Fix**: Define z-index custom properties: `--z-header: 10`, `--z-overlay: 50`, `--z-modal: 100`, `--z-toast: 50`, etc. - -### 13.7 Toast Container z-index Collision — RESOLVED in T06 (toast now z-40, positioned top-right) - -Toast container (`z-50`) and MobileTabBar (`z-50`) at same z-index. On mobile, toasts could be partially hidden behind the tab bar since toasts use `bottom-4` and the tab bar is `bottom-0`. In practice the toast gap prevents overlap, but this is fragile. - -### 13.8 Retro `bg-white` Usage - -`RecordingsSubPanel.tsx` uses `bg-white` (line 86) instead of `bg-card`. This will not respect a dark theme if one is added. - ---- - -## 14. Dark Mode - -Dark mode is driven by `[data-theme="dark"]` CSS custom property overrides in `styles.css`. -The theme is managed by `useTheme()` hook (`shared/hooks/useTheme.ts`). -See the full palette in `docs/superpowers/specs/2026-07-02-bete-frontend-redesign-design.md`. - ---- - -## Appendix: File Map - -| Token / Concern | Canonical Source | -|---------------------------|----------------------------------------------| -| CSS custom properties | `styles.css` | -| Tailwind theme extension | `tailwind.config.js` | -| Framer Motion variants | `shared/hooks/useFramerStagger.ts` | -| GSAP transition hook | `shared/hooks/useGsapTransition.ts` | -| `cn()` utility | `shared/lib/utils.ts` | -| Card component | `shared/ui/card.tsx` | -| Button component | `shared/ui/button.tsx` | -| Badge component | `shared/ui/badge.tsx` | -| Input component | `shared/ui/input.tsx` | -| Select component | `shared/ui/select.tsx` | -| Skeleton component | `shared/ui/skeleton.tsx` | -| Tabs component | `shared/ui/tabs.tsx` | -| Toast component | `shared/ui/toast.tsx` | -| ScrollArea component | `shared/ui/scroll-area.tsx` | -| ParticleBackground | `widgets/particles/ParticleBackground.tsx` | -| DashboardLayout | `widgets/DashboardLayout.tsx` | diff --git a/services/frontend-leptos/frontend/Cargo.toml b/services/frontend/frontend/Cargo.toml similarity index 100% rename from services/frontend-leptos/frontend/Cargo.toml rename to services/frontend/frontend/Cargo.toml diff --git a/services/frontend-leptos/frontend/Trunk.toml b/services/frontend/frontend/Trunk.toml similarity index 100% rename from services/frontend-leptos/frontend/Trunk.toml rename to services/frontend/frontend/Trunk.toml diff --git a/services/frontend-leptos/frontend/index.html b/services/frontend/frontend/index.html similarity index 100% rename from services/frontend-leptos/frontend/index.html rename to services/frontend/frontend/index.html diff --git a/services/frontend-leptos/frontend/public/.gitkeep b/services/frontend/frontend/public/.gitkeep similarity index 100% rename from services/frontend-leptos/frontend/public/.gitkeep rename to services/frontend/frontend/public/.gitkeep diff --git a/services/frontend-leptos/frontend/src/api/auth.rs b/services/frontend/frontend/src/api/auth.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/auth.rs rename to services/frontend/frontend/src/api/auth.rs diff --git a/services/frontend-leptos/frontend/src/api/client.rs b/services/frontend/frontend/src/api/client.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/client.rs rename to services/frontend/frontend/src/api/client.rs diff --git a/services/frontend-leptos/frontend/src/api/dashboard.rs b/services/frontend/frontend/src/api/dashboard.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/dashboard.rs rename to services/frontend/frontend/src/api/dashboard.rs diff --git a/services/frontend-leptos/frontend/src/api/mascot.rs b/services/frontend/frontend/src/api/mascot.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/mascot.rs rename to services/frontend/frontend/src/api/mascot.rs diff --git a/services/frontend-leptos/frontend/src/api/messages.rs b/services/frontend/frontend/src/api/messages.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/messages.rs rename to services/frontend/frontend/src/api/messages.rs diff --git a/services/frontend-leptos/frontend/src/api/mod.rs b/services/frontend/frontend/src/api/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/mod.rs rename to services/frontend/frontend/src/api/mod.rs diff --git a/services/frontend-leptos/frontend/src/api/recordings.rs b/services/frontend/frontend/src/api/recordings.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/recordings.rs rename to services/frontend/frontend/src/api/recordings.rs diff --git a/services/frontend-leptos/frontend/src/api/voice.rs b/services/frontend/frontend/src/api/voice.rs similarity index 100% rename from services/frontend-leptos/frontend/src/api/voice.rs rename to services/frontend/frontend/src/api/voice.rs diff --git a/services/frontend-leptos/frontend/src/app.css b/services/frontend/frontend/src/app.css similarity index 100% rename from services/frontend-leptos/frontend/src/app.css rename to services/frontend/frontend/src/app.css diff --git a/services/frontend-leptos/frontend/src/app.rs b/services/frontend/frontend/src/app.rs similarity index 100% rename from services/frontend-leptos/frontend/src/app.rs rename to services/frontend/frontend/src/app.rs diff --git a/services/frontend-leptos/frontend/src/auth.rs b/services/frontend/frontend/src/auth.rs similarity index 100% rename from services/frontend-leptos/frontend/src/auth.rs rename to services/frontend/frontend/src/auth.rs diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs b/services/frontend/frontend/src/features/dashboard/components/channel_summary_list.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/dashboard/components/channel_summary_list.rs rename to services/frontend/frontend/src/features/dashboard/components/channel_summary_list.rs diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs b/services/frontend/frontend/src/features/dashboard/components/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/dashboard/components/mod.rs rename to services/frontend/frontend/src/features/dashboard/components/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs b/services/frontend/frontend/src/features/dashboard/components/stats_overview.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/dashboard/components/stats_overview.rs rename to services/frontend/frontend/src/features/dashboard/components/stats_overview.rs diff --git a/services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs b/services/frontend/frontend/src/features/dashboard/components/user_summary_list.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/dashboard/components/user_summary_list.rs rename to services/frontend/frontend/src/features/dashboard/components/user_summary_list.rs diff --git a/services/frontend-leptos/frontend/src/features/dashboard/mod.rs b/services/frontend/frontend/src/features/dashboard/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/dashboard/mod.rs rename to services/frontend/frontend/src/features/dashboard/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/live/audio/mod.rs b/services/frontend/frontend/src/features/live/audio/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/audio/mod.rs rename to services/frontend/frontend/src/features/live/audio/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/live/audio/pcm_decoder.rs b/services/frontend/frontend/src/features/live/audio/pcm_decoder.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/audio/pcm_decoder.rs rename to services/frontend/frontend/src/features/live/audio/pcm_decoder.rs diff --git a/services/frontend-leptos/frontend/src/features/live/audio/ring_buffer.rs b/services/frontend/frontend/src/features/live/audio/ring_buffer.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/audio/ring_buffer.rs rename to services/frontend/frontend/src/features/live/audio/ring_buffer.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs b/services/frontend/frontend/src/features/live/components/active_speakers.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/active_speakers.rs rename to services/frontend/frontend/src/features/live/components/active_speakers.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs b/services/frontend/frontend/src/features/live/components/audio_visualizer.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/audio_visualizer.rs rename to services/frontend/frontend/src/features/live/components/audio_visualizer.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs b/services/frontend/frontend/src/features/live/components/mic_level_meter.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/mic_level_meter.rs rename to services/frontend/frontend/src/features/live/components/mic_level_meter.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/mod.rs b/services/frontend/frontend/src/features/live/components/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/mod.rs rename to services/frontend/frontend/src/features/live/components/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/music_sub_panel.rs b/services/frontend/frontend/src/features/live/components/music_sub_panel.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/music_sub_panel.rs rename to services/frontend/frontend/src/features/live/components/music_sub_panel.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/now_playing.rs b/services/frontend/frontend/src/features/live/components/now_playing.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/now_playing.rs rename to services/frontend/frontend/src/features/live/components/now_playing.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs b/services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/recordings_sub_panel.rs rename to services/frontend/frontend/src/features/live/components/recordings_sub_panel.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/screen_sub_panel.rs b/services/frontend/frontend/src/features/live/components/screen_sub_panel.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/screen_sub_panel.rs rename to services/frontend/frontend/src/features/live/components/screen_sub_panel.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs b/services/frontend/frontend/src/features/live/components/voice_connection_card.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/voice_connection_card.rs rename to services/frontend/frontend/src/features/live/components/voice_connection_card.rs diff --git a/services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs b/services/frontend/frontend/src/features/live/components/waveform_player.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/components/waveform_player.rs rename to services/frontend/frontend/src/features/live/components/waveform_player.rs diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/mod.rs b/services/frontend/frontend/src/features/live/hooks/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/hooks/mod.rs rename to services/frontend/frontend/src/features/live/hooks/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_playback.rs b/services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/hooks/use_audio_playback.rs rename to services/frontend/frontend/src/features/live/hooks/use_audio_playback.rs diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_audio_transmit.rs b/services/frontend/frontend/src/features/live/hooks/use_audio_transmit.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/hooks/use_audio_transmit.rs rename to services/frontend/frontend/src/features/live/hooks/use_audio_transmit.rs diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs b/services/frontend/frontend/src/features/live/hooks/use_media_control.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/hooks/use_media_control.rs rename to services/frontend/frontend/src/features/live/hooks/use_media_control.rs diff --git a/services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs b/services/frontend/frontend/src/features/live/hooks/use_voice_control.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/hooks/use_voice_control.rs rename to services/frontend/frontend/src/features/live/hooks/use_voice_control.rs diff --git a/services/frontend-leptos/frontend/src/features/live/mod.rs b/services/frontend/frontend/src/features/live/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/live/mod.rs rename to services/frontend/frontend/src/features/live/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs b/services/frontend/frontend/src/features/messages/components/image_grid.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/components/image_grid.rs rename to services/frontend/frontend/src/features/messages/components/image_grid.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_card.rs b/services/frontend/frontend/src/features/messages/components/message_card.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/components/message_card.rs rename to services/frontend/frontend/src/features/messages/components/message_card.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs b/services/frontend/frontend/src/features/messages/components/message_feed.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/components/message_feed.rs rename to services/frontend/frontend/src/features/messages/components/message_feed.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/components/mod.rs b/services/frontend/frontend/src/features/messages/components/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/components/mod.rs rename to services/frontend/frontend/src/features/messages/components/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs b/services/frontend/frontend/src/features/messages/hooks/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/hooks/mod.rs rename to services/frontend/frontend/src/features/messages/hooks/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs b/services/frontend/frontend/src/features/messages/hooks/use_messages.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/hooks/use_messages.rs rename to services/frontend/frontend/src/features/messages/hooks/use_messages.rs diff --git a/services/frontend-leptos/frontend/src/features/messages/mod.rs b/services/frontend/frontend/src/features/messages/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/messages/mod.rs rename to services/frontend/frontend/src/features/messages/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/mod.rs b/services/frontend/frontend/src/features/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/mod.rs rename to services/frontend/frontend/src/features/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs b/services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/polish/components/mascot_chatbot.rs rename to services/frontend/frontend/src/features/polish/components/mascot_chatbot.rs diff --git a/services/frontend-leptos/frontend/src/features/polish/components/mod.rs b/services/frontend/frontend/src/features/polish/components/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/polish/components/mod.rs rename to services/frontend/frontend/src/features/polish/components/mod.rs diff --git a/services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs b/services/frontend/frontend/src/features/polish/components/particle_background.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/polish/components/particle_background.rs rename to services/frontend/frontend/src/features/polish/components/particle_background.rs diff --git a/services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs b/services/frontend/frontend/src/features/polish/components/theme_toggle.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/polish/components/theme_toggle.rs rename to services/frontend/frontend/src/features/polish/components/theme_toggle.rs diff --git a/services/frontend-leptos/frontend/src/features/polish/mod.rs b/services/frontend/frontend/src/features/polish/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/features/polish/mod.rs rename to services/frontend/frontend/src/features/polish/mod.rs diff --git a/services/frontend-leptos/frontend/src/layout/dashboard_layout.rs b/services/frontend/frontend/src/layout/dashboard_layout.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/dashboard_layout.rs rename to services/frontend/frontend/src/layout/dashboard_layout.rs diff --git a/services/frontend-leptos/frontend/src/layout/header.rs b/services/frontend/frontend/src/layout/header.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/header.rs rename to services/frontend/frontend/src/layout/header.rs diff --git a/services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs b/services/frontend/frontend/src/layout/mobile_tab_bar.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs rename to services/frontend/frontend/src/layout/mobile_tab_bar.rs diff --git a/services/frontend-leptos/frontend/src/layout/mod.rs b/services/frontend/frontend/src/layout/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/mod.rs rename to services/frontend/frontend/src/layout/mod.rs diff --git a/services/frontend-leptos/frontend/src/layout/sidebar.rs b/services/frontend/frontend/src/layout/sidebar.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/sidebar.rs rename to services/frontend/frontend/src/layout/sidebar.rs diff --git a/services/frontend-leptos/frontend/src/layout/tab_strip.rs b/services/frontend/frontend/src/layout/tab_strip.rs similarity index 100% rename from services/frontend-leptos/frontend/src/layout/tab_strip.rs rename to services/frontend/frontend/src/layout/tab_strip.rs diff --git a/services/frontend-leptos/frontend/src/lib.rs b/services/frontend/frontend/src/lib.rs similarity index 100% rename from services/frontend-leptos/frontend/src/lib.rs rename to services/frontend/frontend/src/lib.rs diff --git a/services/frontend-leptos/frontend/src/ui/badge.rs b/services/frontend/frontend/src/ui/badge.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/badge.rs rename to services/frontend/frontend/src/ui/badge.rs diff --git a/services/frontend-leptos/frontend/src/ui/button.rs b/services/frontend/frontend/src/ui/button.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/button.rs rename to services/frontend/frontend/src/ui/button.rs diff --git a/services/frontend-leptos/frontend/src/ui/card.rs b/services/frontend/frontend/src/ui/card.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/card.rs rename to services/frontend/frontend/src/ui/card.rs diff --git a/services/frontend-leptos/frontend/src/ui/empty_state.rs b/services/frontend/frontend/src/ui/empty_state.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/empty_state.rs rename to services/frontend/frontend/src/ui/empty_state.rs diff --git a/services/frontend-leptos/frontend/src/ui/input.rs b/services/frontend/frontend/src/ui/input.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/input.rs rename to services/frontend/frontend/src/ui/input.rs diff --git a/services/frontend-leptos/frontend/src/ui/mod.rs b/services/frontend/frontend/src/ui/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/mod.rs rename to services/frontend/frontend/src/ui/mod.rs diff --git a/services/frontend-leptos/frontend/src/ui/modal.rs b/services/frontend/frontend/src/ui/modal.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/modal.rs rename to services/frontend/frontend/src/ui/modal.rs diff --git a/services/frontend-leptos/frontend/src/ui/scroll_area.rs b/services/frontend/frontend/src/ui/scroll_area.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/scroll_area.rs rename to services/frontend/frontend/src/ui/scroll_area.rs diff --git a/services/frontend-leptos/frontend/src/ui/select.rs b/services/frontend/frontend/src/ui/select.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/select.rs rename to services/frontend/frontend/src/ui/select.rs diff --git a/services/frontend-leptos/frontend/src/ui/skeleton.rs b/services/frontend/frontend/src/ui/skeleton.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/skeleton.rs rename to services/frontend/frontend/src/ui/skeleton.rs diff --git a/services/frontend-leptos/frontend/src/ui/status_badge.rs b/services/frontend/frontend/src/ui/status_badge.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/status_badge.rs rename to services/frontend/frontend/src/ui/status_badge.rs diff --git a/services/frontend-leptos/frontend/src/ui/tabs.rs b/services/frontend/frontend/src/ui/tabs.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/tabs.rs rename to services/frontend/frontend/src/ui/tabs.rs diff --git a/services/frontend-leptos/frontend/src/ui/toast.rs b/services/frontend/frontend/src/ui/toast.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ui/toast.rs rename to services/frontend/frontend/src/ui/toast.rs diff --git a/services/frontend-leptos/frontend/src/ws/context.rs b/services/frontend/frontend/src/ws/context.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ws/context.rs rename to services/frontend/frontend/src/ws/context.rs diff --git a/services/frontend-leptos/frontend/src/ws/mod.rs b/services/frontend/frontend/src/ws/mod.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ws/mod.rs rename to services/frontend/frontend/src/ws/mod.rs diff --git a/services/frontend-leptos/frontend/src/ws/socket.rs b/services/frontend/frontend/src/ws/socket.rs similarity index 100% rename from services/frontend-leptos/frontend/src/ws/socket.rs rename to services/frontend/frontend/src/ws/socket.rs diff --git a/services/frontend/index.html b/services/frontend/index.html deleted file mode 100644 index 3921464..0000000 --- a/services/frontend/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - IMPHNEN — Discord Moderation - - - - - - -
- - - diff --git a/services/frontend/moon.yml b/services/frontend/moon.yml deleted file mode 100644 index d7af6e1..0000000 --- a/services/frontend/moon.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Frontend web application -language: typescript - -tasks: - dev: - command: vite --host 0.0.0.0 - preset: server - - build: - script: 'tsc --noEmit && NODE_NO_WARNINGS=1 vite build' - options: - runFromWorkspaceRoot: false - inputs: - - src - - tsconfig.json - - vite.config.mts - - index.html - outputs: - - dist - - preview: - command: vite preview --host 0.0.0.0 --port 3000 - preset: server - - typecheck: - command: tsc --noEmit - options: - runFromWorkspaceRoot: false - inputs: - - src - - tsconfig.json - - lint: - command: biome check --diagnostic-level=error src/ - options: - runFromWorkspaceRoot: false - inputs: - - src - - format: - command: biome format --write src/ - options: - runFromWorkspaceRoot: false - inputs: - - src diff --git a/services/frontend/package.json b/services/frontend/package.json deleted file mode 100644 index ac6e87f..0000000 --- a/services/frontend/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@gmw/frontend", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build", - "preview": "vite preview --host 0.0.0.0 --port 3000", - "typecheck": "tsc --noEmit", - "lint": "biome check --diagnostic-level=error src/", - "format": "biome format --write src/" - }, - "dependencies": { - "@bete/shared": "workspace:*", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "clsx": "^2.1.1", - "framer-motion": "^12.4.0", - "lucide-react": "^1.16.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "tailwind-merge": "^3.6.0" - }, - "devDependencies": { - "@biomejs/biome": "latest", - "@tailwindcss/postcss": "^4.3.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.5.14", - "tailwindcss": "^4.3.0", - "typescript": "^5.9.3", - "vite": "^8.0.13" - } -} diff --git a/services/frontend/postcss.config.js b/services/frontend/postcss.config.js deleted file mode 100644 index c2ddf74..0000000 --- a/services/frontend/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; diff --git a/services/frontend/public/logo.svg b/services/frontend/public/logo.svg deleted file mode 100644 index a77060f..0000000 --- a/services/frontend/public/logo.svg +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GMW - diff --git a/services/frontend-leptos/rust-toolchain.toml b/services/frontend/rust-toolchain.toml similarity index 100% rename from services/frontend-leptos/rust-toolchain.toml rename to services/frontend/rust-toolchain.toml diff --git a/services/frontend-leptos/shared-types/Cargo.toml b/services/frontend/shared-types/Cargo.toml similarity index 100% rename from services/frontend-leptos/shared-types/Cargo.toml rename to services/frontend/shared-types/Cargo.toml diff --git a/services/frontend-leptos/shared-types/src/dashboard.rs b/services/frontend/shared-types/src/dashboard.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/dashboard.rs rename to services/frontend/shared-types/src/dashboard.rs diff --git a/services/frontend-leptos/shared-types/src/guild.rs b/services/frontend/shared-types/src/guild.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/guild.rs rename to services/frontend/shared-types/src/guild.rs diff --git a/services/frontend-leptos/shared-types/src/lib.rs b/services/frontend/shared-types/src/lib.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/lib.rs rename to services/frontend/shared-types/src/lib.rs diff --git a/services/frontend-leptos/shared-types/src/media.rs b/services/frontend/shared-types/src/media.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/media.rs rename to services/frontend/shared-types/src/media.rs diff --git a/services/frontend-leptos/shared-types/src/message.rs b/services/frontend/shared-types/src/message.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/message.rs rename to services/frontend/shared-types/src/message.rs diff --git a/services/frontend-leptos/shared-types/src/recording.rs b/services/frontend/shared-types/src/recording.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/recording.rs rename to services/frontend/shared-types/src/recording.rs diff --git a/services/frontend-leptos/shared-types/src/ui_state.rs b/services/frontend/shared-types/src/ui_state.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/ui_state.rs rename to services/frontend/shared-types/src/ui_state.rs diff --git a/services/frontend-leptos/shared-types/src/voice.rs b/services/frontend/shared-types/src/voice.rs similarity index 100% rename from services/frontend-leptos/shared-types/src/voice.rs rename to services/frontend/shared-types/src/voice.rs diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx deleted file mode 100644 index 9bf169c..0000000 --- a/services/frontend/src/App.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useCallback, useEffect, useMemo, 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 { getAppConfig } from "./shared/api/client"; -import { useAudioPlayback } from "./shared/hooks/useAudioPlayback"; -import { useAudioTransmit } from "./shared/hooks/useAudioTransmit"; -import { useTheme } from "./shared/hooks/useTheme"; -import { useUIState } from "./shared/hooks/useUIState"; -import { MobileTabBar } from "./shared/ui/MobileTabBar"; -import { useDashboardSocket } from "./shared/ws/socket"; -import { DashboardLayout } from "./widgets/DashboardLayout"; - -export default function App() { - const { uiState, patchUIState } = useUIState(); - const [authenticated, setAuthenticated] = useState(() => { - return sessionStorage.getItem("admin-password") !== null; - }); - useTheme(); - - const handleAuthenticated = useCallback(() => { - setAuthenticated(true); - }, []); - - const voice = useVoiceControl(); - const media = useMediaControl(); - const messages = useMessages(); - const [activeSpeakers, setActiveSpeakers] = useState< - (ActiveSpeaker & { heardAt?: number })[] - >([]); - const [monitorGuildId, setMonitorGuildId] = useState(""); - - const audio = useAudioPlayback(); - const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true"; - const activeTab = uiState.activeTab || "messages"; - - // Reset persisted tab to "messages" once on mount if not authenticated - // Prevents localStorage carryover from a prior session on the Live tab - useEffect(() => { - if (!authenticated && !isPublicDashboard && uiState.activeTab === "live") { - patchUIState({ activeTab: "messages" }); - } - // Run only on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - 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) => - updateSpeakerList(prev, { - userId: data.userId, - username: data.username, - avatar: data.avatar, - speaking: data.speaking, - }), - ); - }, - onVoiceRecordingStarted: () => - window.dispatchEvent(new CustomEvent("voice_recording_uploaded")), - onVoiceRecordingStopped: () => - window.dispatchEvent(new CustomEvent("voice_recording_uploaded")), - onMessageCreated: (m) => - messages.setMessages((prev) => 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) => 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 app config on mount - useEffect(() => { - getAppConfig() - .then((c) => { - if (c.monitorGuildId) { - setMonitorGuildId(c.monitorGuildId); - } - }) - .catch(() => undefined); - }, []); - - // 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 - useEffect(() => { - if (!monitorGuildId) return; - const interval = setInterval(() => { - messages.fetchMessages(monitorGuildId).catch(() => undefined); - }, 15_000); - return () => clearInterval(interval); - }, [monitorGuildId, messages.fetchMessages]); - - // Stale speaker pruning — remove speakers not heard from in 30s - useEffect(() => { - const interval = setInterval(() => { - setActiveSpeakers((prev) => { - 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]); - - return ( - patchUIState({ activeTab: tab })} - recentMessages={messages.messages} - guildId={monitorGuildId} - channelId={ - uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined - } - > - {activeTab === "live" && !authenticated && !isPublicDashboard ? ( - - ) : activeTab === "live" ? ( - - patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" }) - } - onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })} - onJoin={() => - voice.joinVoice( - selectedVoiceGuild, - uiState.selectedVoiceChannel || "", - ) - } - onDisconnect={() => voice.leaveVoice()} - onListenToggle={audio.toggleListening} - onStreamingToggle={transmit.toggle} - onQueueMusic={(s) => media.enqueue(s, "music")} - onStartScreen={(s) => media.enqueue(s, "screen")} - onSkip={media.skip} - onStop={media.stop} - onVolumeChange={media.setVolume} - /> - ) : activeTab === "dashboard" ? ( - - ) : ( - - )} - patchUIState({ activeTab: tab })} - /> - - - ); -} diff --git a/services/frontend/src/entities/dashboard/types.ts b/services/frontend/src/entities/dashboard/types.ts deleted file mode 100644 index 35af6c3..0000000 --- a/services/frontend/src/entities/dashboard/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface DashboardStats { - total_messages: number; - total_users: number; - total_flagged: number; - total_clean: number; - total_warned: number; - total_error: number; - total_voice_recordings: number; - total_profiles: number; - today_messages: number; - today_flagged: number; - active_users_24h: number; - top_channels: Array<{ - channel_id: string; - channel_name: string | null; - message_count: number; - }>; - moderation_overview: { - pending: number; - processing: number; - error: number; - }; -} - -export interface DashboardUser { - user_id: string; - username: string | null; - avatar_url: string | null; - profile_summary: string | null; - total_messages: number; - flagged_count: number; - last_message_at: number | null; - trust_score: number | null; -} - -export interface DashboardUserDetail extends DashboardUser { - last_analyzed_at: number | null; - clean_message_streak: number | null; - total_infractions: number | null; - clean_count: number; - recent_messages: Array<{ - id: string; - content: string; - channel_id: string; - created_at: number; - ai_status: string | null; - }>; -} - -export interface DashboardChannel { - channel_id: string; - channel_name: string | null; - guild_id: string | null; - total_messages: number; - flagged_count: number; - last_message_at: number | null; - culture_summary: string | null; - last_analyzed_at: number | null; -} - -export interface DashboardChannelDetail extends DashboardChannel { - clean_count: number; - recent_messages: Array<{ - id: string; - content: string; - channel_id: string; - created_at: number; - ai_status: string | null; - username: string | null; - }>; -} - -export interface ChatResponse { - response?: string; -} diff --git a/services/frontend/src/entities/guild/types.ts b/services/frontend/src/entities/guild/types.ts deleted file mode 100644 index 38ddbc9..0000000 --- a/services/frontend/src/entities/guild/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface Guild { - id: string; - name: string; - icon: string | null; -} - -export interface Channel { - id: string; - name: string; - type?: string; - parentId?: string | null; -} - -export interface GuildVoiceEntry { - guildId: string; - channelId: string; - channelName: string; - connectedAt: number; -} diff --git a/services/frontend/src/entities/media/types.ts b/services/frontend/src/entities/media/types.ts deleted file mode 100644 index 838f2e8..0000000 --- a/services/frontend/src/entities/media/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type MediaMode = "music" | "screen"; - -export interface MediaItem { - id?: string; - source: string; - title: string; - mode?: "music" | "screen"; - durationMs?: number | null; - thumbnailUrl?: string | null; -} - -export interface MediaState { - playing: boolean; - musicVolume: number; - current: MediaItem | null; - queue: MediaItem[]; -} diff --git a/services/frontend/src/entities/message/types.ts b/services/frontend/src/entities/message/types.ts deleted file mode 100644 index 2d58789..0000000 --- a/services/frontend/src/entities/message/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - AIRecommendedAction, - AISeverity, - AIStatus, - MessageRecord, - PageResult, -} from "@bete/shared"; - -export interface MessageMetadata { - stickers?: Array<{ name?: string; url?: string }>; - attachments?: Array<{ name: string; url: string; contentType?: string }>; - embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; - channel?: { - channelId: string; - channelName?: string; - threadId?: string; - threadName?: string; - }; -} diff --git a/services/frontend/src/entities/recording/types.ts b/services/frontend/src/entities/recording/types.ts deleted file mode 100644 index cb688cb..0000000 --- a/services/frontend/src/entities/recording/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface VoiceRecording { - id: string; - user_id: string; - username: string; - avatar_url: string | null; - guild_id: string | null; - channel_id: string | null; - channel_name: string | null; - filename: string; - size_bytes: number; - download_url: string | null; - upload_status: "pending" | "uploaded" | "failed"; - upload_error: string | null; - transcription?: string | null; - created_at: number; - uploaded_at: number | null; -} - -export interface VoiceRecordingListResponse { - items: VoiceRecording[]; - nextCursor: string | null; - hasMore: boolean; -} diff --git a/services/frontend/src/entities/ui/types.ts b/services/frontend/src/entities/ui/types.ts deleted file mode 100644 index 8427530..0000000 --- a/services/frontend/src/entities/ui/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface UIState { - selectedGuild?: string; - selectedVoiceGuild?: string; - selectedVoiceChannel?: string; - selectedTextGuild?: string; - selectedTextChannel?: string; - selectedAnalyticsGuild?: string; - selectedAnalyticsChannel?: string; - activeTab?: "live" | "messages" | "dashboard"; - isListening?: boolean; - isStreaming?: boolean; -} - -export type DashboardTab = "live" | "messages" | "dashboard"; - -export interface AppConfig { - monitorGuildId: string | null; -} diff --git a/services/frontend/src/entities/voice/types.ts b/services/frontend/src/entities/voice/types.ts deleted file mode 100644 index 71571df..0000000 --- a/services/frontend/src/entities/voice/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { GuildVoiceEntry } from "../guild/types"; - -export interface VoiceStatus { - connected: boolean; - activeGuildId: string | null; - activeChannelId: string | null; - activeChannelName: string | null; - connections: GuildVoiceEntry[]; -} - -export interface ActiveSpeaker { - id?: string; - userId?: string; - username: string; - avatar: string; - speaking: boolean; -} diff --git a/services/frontend/src/features/auth/index.tsx b/services/frontend/src/features/auth/index.tsx deleted file mode 100644 index f57a287..0000000 --- a/services/frontend/src/features/auth/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { motion } from "framer-motion"; -import { Lock } from "lucide-react"; -import { useState } from "react"; -import { login } from "../../shared/api/client.js"; -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, -} from "../../shared/ui"; - -interface AuthOverlayProps { - onAuthenticated: () => void; -} - -export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) { - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: { preventDefault: () => void }) => { - e.preventDefault(); - setLoading(true); - setError(null); - try { - await login(password); - sessionStorage.setItem("admin-password", password); - onAuthenticated(); - } catch { - setError("Invalid password"); - } finally { - setLoading(false); - } - }; - - return ( - - - -
-
- -
-
- Admin Access Required - - Enter the admin password to access Voice and Media controls. - -
- -
-
- setPassword(e.target.value)} - autoFocus - /> - {error &&

{error}

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

{error}

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

No data available yet.

-
- ); - } - - const cards = [ - { - title: "Total Messages", - value: stats.total_messages.toLocaleString(), - icon: MessageSquare, - color: "text-primary", - bg: "bg-primary/10", - }, - { - title: "Today's Messages", - value: stats.today_messages.toLocaleString(), - icon: MessageSquare, - color: "text-success", - bg: "bg-success-soft", - }, - { - title: "Total Users", - value: stats.total_users.toLocaleString(), - icon: Users, - color: "text-primary", - bg: "bg-primary-soft", - }, - { - title: "Active Users (24h)", - value: stats.active_users_24h.toLocaleString(), - icon: UserCheck, - color: "text-tertiary", - bg: "bg-tertiary-soft", - }, - { - 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-success", - bg: "bg-success-soft", - }, - { - title: "Voice Recordings", - value: stats.total_voice_recordings.toLocaleString(), - icon: Mic, - color: "text-info", - bg: "bg-info-soft", - onClick: () => patchUIState({ activeTab: "live" }), - }, - { - title: "AI Profiles", - value: stats.total_profiles.toLocaleString(), - icon: Users, - color: "text-warning", - bg: "bg-warning-soft", - }, - ]; - - return ( - - {/* Summary cards grid */} - - {cards.map((card) => ( - - -
-
-

- {card.title} -

-

- {card.value} -

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

- No channel data yet. -

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

- {stats.moderation_overview.pending} -

-

Pending

-
-
-

- {stats.moderation_overview.processing} -

-

Processing

-
-
-

- {stats.moderation_overview.error} -

-

Errors

-
-
-
-
-
-
- ); -} - -function StatsSkeleton() { - return ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( - - -
- - -
-
-
- ))} -
-
- ); -} diff --git a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx deleted file mode 100644 index 632b252..0000000 --- a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { User } from "lucide-react"; -import type { DashboardUserDetail } from "../../../entities/dashboard/types.js"; -import { ProfileDetail } from "../../../shared/ui"; - -interface UserProfileDetailProps { - detail: DashboardUserDetail | null; - loading: boolean; - error: string | null; - onBack: () => void; - onRefetch: () => void; -} - -export function UserProfileDetail({ - detail, - loading, - error, - onBack, - onRefetch, -}: UserProfileDetailProps) { - if (!detail && !loading && !error) return null; - - const icon = detail?.avatar_url ? ( - - ) : ( - - ); - - return ( - ({ - id: msg.id, - content: msg.content, - created_at: new Date(msg.created_at).toISOString(), - ai_status: msg.ai_status, - })) ?? [] - } - /> - ); -} diff --git a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx deleted file mode 100644 index 66b51d2..0000000 --- a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { User } from "lucide-react"; -import type { DashboardUser } from "../../../entities/dashboard/types.js"; -import type { SummaryItem } from "../../../shared/ui"; -import { SummaryList } from "../../../shared/ui"; - -interface UserSummaryListProps { - users: DashboardUser[]; - loading: boolean; - error: string | null; - search: string; - onSearchChange: (value: string) => void; - onLoadMore: () => void; - hasMore: boolean; - onRefetch: () => void; - onSelectUser: (userId: string) => void; -} - -export function UserSummaryList({ - users, - loading, - error, - search, - onSearchChange, - onLoadMore, - hasMore, - onRefetch, - onSelectUser, -}: UserSummaryListProps) { - const items: SummaryItem[] = users.map((u) => ({ - id: u.user_id, - label: u.username ?? u.user_id, - subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined, - summaryText: u.profile_summary ?? `${u.total_messages} messages`, - onClick: () => onSelectUser(u.user_id), - })); - - const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url])); - - return ( - { - const avatarUrl = avatarMap.get(item.id); - return avatarUrl ? ( - - ) : ( -
- -
- ); - }} - emptyMessage="No users found." - /> - ); -} diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts deleted file mode 100644 index ee983ef..0000000 --- a/services/frontend/src/features/dashboard/hooks/useDashboard.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { DashboardStats } from "../../../entities/dashboard/types.js"; -import { - getDashboardChannelDetail, - getDashboardStats, - getDashboardUserDetail, - listDashboardChannels, - listDashboardUsers, -} from "../../../shared/api/client.js"; -import { useItemDetail } from "../../../shared/hooks/useItemDetail"; -import { usePaginatedList } from "../../../shared/hooks/usePaginatedList"; - -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-dashboard"); - -/** - * Fetch dashboard aggregate stats. - */ -export function useDashboardStats() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await getDashboardStats(); - setStats(data); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to load stats"; - setError(msg); - logger.error("[useDashboardStats]", { error: msg }); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { stats, loading, error, refetch: fetch }; -} - -/** - * Fetch paginated user list with optional search. - */ -export function useDashboardUsers() { - const paginated = usePaginatedList( - (params) => - listDashboardUsers({ - limit: params.limit, - search: params.search, - cursor: params.cursor, - }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), - "", - ); - - return { - users: paginated.data, - loading: paginated.loading, - error: paginated.error, - search: paginated.search, - setSearch: paginated.setSearch, - loadMore: paginated.loadMore, - hasMore: paginated.hasMore, - refetch: paginated.refetch, - }; -} - -/** - * Fetch a single user detail by userId. - */ -export function useDashboardUserDetail(userId: string | null) { - const { data, loading, error, refetch } = useItemDetail( - (_guildId, entityId) => getDashboardUserDetail(entityId), - "", - userId, - "user", - ); - - return { detail: data, loading, error, refetch }; -} - -/** - * Fetch paginated channel list with optional search. - */ -export function useDashboardChannels() { - const paginated = usePaginatedList( - (params) => - listDashboardChannels({ - limit: params.limit, - search: params.search, - guild_id: params.guildId, - cursor: params.cursor, - }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), - "", - ); - - return { - channels: paginated.data, - loading: paginated.loading, - error: paginated.error, - search: paginated.search, - setSearch: paginated.setSearch, - loadMore: paginated.loadMore, - hasMore: paginated.hasMore, - refetch: paginated.refetch, - }; -} - -/** - * Fetch a single channel detail by channelId. - */ -export function useDashboardChannelDetail(channelId: string | null) { - const { data, loading, error, refetch } = useItemDetail( - (_guildId, entityId) => getDashboardChannelDetail(entityId), - "", - channelId, - "channel", - ); - - return { detail: data, loading, error, refetch }; -} diff --git a/services/frontend/src/features/dashboard/index.tsx b/services/frontend/src/features/dashboard/index.tsx deleted file mode 100644 index bd9f5ba..0000000 --- a/services/frontend/src/features/dashboard/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher - * Menampilkan overview komunitas dengan IMPHNEN approachable modernism. - * Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal). - * ═══════════════════════════════════════════════════════════════════════════ */ - -import { useState } from "react"; -import { - BarChart3, - Hash, - Users, -} from "lucide-react"; -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 ( -
- {/* ── Page Header ───────────────────────────────────────────────── */} -
-

- Dashboard Guild -

-

- Pantau statistik, profil pengguna, dan aktivitas kanal komunitas - IMPHNEN secara real-time. -

-
- - {/* ── Tabs ────────────────────────────────────────────────────────── */} - - - - - Statistik - - - - Pengguna - - - - Kanal - - - - - - - - - - - - - - - -
- ); -} diff --git a/services/frontend/src/features/live/components/ActiveSpeakers.tsx b/services/frontend/src/features/live/components/ActiveSpeakers.tsx deleted file mode 100644 index a7eae09..0000000 --- a/services/frontend/src/features/live/components/ActiveSpeakers.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { ActiveSpeaker } from "../../../entities/voice/types.js"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; - -interface ActiveSpeakersProps { - speakers: ActiveSpeaker[]; -} - -export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) { - if (speakers.length === 0) { - return ; - } - - return ( -
- {speakers.map((s) => { - const key = s.userId ?? s.id ?? `speaker-${s.username}`; - return ( -
- -
-
{s.username}
-
- - - {s.speaking ? "Speaking" : "Silent"} - -
-
-
- ); - })} -
- ); -} diff --git a/services/frontend/src/features/live/components/AudioVisualizer.tsx b/services/frontend/src/features/live/components/AudioVisualizer.tsx deleted file mode 100644 index 550084d..0000000 --- a/services/frontend/src/features/live/components/AudioVisualizer.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useRef } from "react"; - -interface AudioVisualizerProps { - levels: number[]; -} - -export function AudioVisualizer({ levels }: AudioVisualizerProps) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - - const ro = new ResizeObserver(() => { - const rect = container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - canvas.width = rect.width * dpr; - canvas.height = 128 * dpr; - canvas.style.height = "128px"; - }); - ro.observe(container); - return () => ro.disconnect(); - }, []); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const width = canvas.width / dpr; - const height = canvas.height / dpr; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - const barWidth = width / levels.length; - const maxBarHeight = height * 0.85; - - const root = getComputedStyle(document.documentElement); - const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb"; - - const gradient = ctx.createLinearGradient(0, 0, 0, height); - gradient.addColorStop(0, primaryColor); - gradient.addColorStop(1, primaryColor); - - for (let i = 0; i < levels.length; i++) { - const level = levels[i]; - const barHeight = Math.min(maxBarHeight, level * maxBarHeight); - const x = i * barWidth; - const y = height - barHeight; - - ctx.fillStyle = gradient; - - const radius = barWidth * 0.4; - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + barWidth - radius, y); - ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius); - ctx.lineTo(x + barWidth, height); - ctx.lineTo(x, height); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.fill(); - } - }, [levels]); - - return ( -
- -
- ); -} diff --git a/services/frontend/src/features/live/components/MicLevelMeter.tsx b/services/frontend/src/features/live/components/MicLevelMeter.tsx deleted file mode 100644 index e83b52d..0000000 --- a/services/frontend/src/features/live/components/MicLevelMeter.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// ─── Mic level meter — vertical bar showing outgoing audio RMS level ───────── - -interface MicLevelMeterProps { - level: number; // 0-1 -} - -export function MicLevelMeter({ level }: MicLevelMeterProps) { - const pct = Math.round(level * 100); - - // Color gradient: green <-> yellow <-> red - const hue = 120 - level * 120; // 120 (green) -> 0 (red) - const bg = `hsl(${hue}, 80%, 45%)`; - - return ( -
-
-
- ); -} diff --git a/services/frontend/src/features/live/components/MusicSubPanel.tsx b/services/frontend/src/features/live/components/MusicSubPanel.tsx deleted file mode 100644 index 1b19855..0000000 --- a/services/frontend/src/features/live/components/MusicSubPanel.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Input } from "../../../shared/ui"; - -interface MusicSubPanelProps { - volume: number; - onVolumeChange: (v: number) => void; - onQueue: (source: string) => void; - onSkip: () => void; - onStop: () => void; - loading: boolean; -} - -export function MusicSubPanel({ - volume, - onVolumeChange, - onQueue, - onSkip, - onStop, - loading, -}: MusicSubPanelProps) { - const [source, setSource] = useState(""); - const safeVolume = Number.isFinite(volume) - ? Math.max(0, Math.min(1, volume)) - : 1; - const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100)); - const [muted, setMuted] = useState(false); - const prevVolumeRef = useRef(safeVolume); - const debounceRef = useRef | null>(null); - - // Proper debounce: setTimeout instead of setInterval polling - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - const normalized = draftVolume / 100; - if (Math.abs(normalized - safeVolume) >= 0.001) - onVolumeChange(normalized); - }, 200); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [draftVolume, safeVolume, onVolumeChange]); - - const handleMute = useCallback(() => { - if (muted) { - // Unmute: restore previous volume - const restore = prevVolumeRef.current; - setDraftVolume(Math.round(restore * 100)); - onVolumeChange(restore); - setMuted(false); - } else { - // Mute: save current, set to 0 - prevVolumeRef.current = safeVolume; - setDraftVolume(0); - onVolumeChange(0); - setMuted(true); - } - }, [muted, safeVolume, onVolumeChange]); - - const submit = () => { - const t = source.trim(); - if (!t) return; - onQueue(t); - setSource(""); - }; - - return ( -
- setSource(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - placeholder="YouTube URL, Spotify track, or search terms" - /> -
- - { - setDraftVolume(Number(e.target.value)); - if (muted) setMuted(false); - }} - className="h-2 w-full cursor-pointer accent-primary" - /> - - {draftVolume}% - -
-
- - - -
-
- ); -} diff --git a/services/frontend/src/features/live/components/NowPlaying.tsx b/services/frontend/src/features/live/components/NowPlaying.tsx deleted file mode 100644 index 2ea9d74..0000000 --- a/services/frontend/src/features/live/components/NowPlaying.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { MonitorUp, Music2 } from "lucide-react"; -import type { MediaItem } from "../../../entities/media/types.js"; -import { Badge } from "../../../shared/ui"; - -interface NowPlayingProps { - current: MediaItem | null; - queue: MediaItem[]; -} - -export function NowPlaying({ current, queue }: NowPlayingProps) { - if (!current) return null; - - return ( -
-
-
-
- {current.mode === "screen" ? ( - - ) : ( - - )} -
-
-
{current.title}
-
- {current.source} -
-
- - {current.mode ?? "music"} - -
-
- - {queue.length > 0 && ( -
-
Queue ({queue.length})
-
- {queue.map((item, i) => ( -
- - {i + 1} - -
-
{item.title}
-
- {item.source} -
-
-
- ))} -
-
- )} -
- ); -} diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx deleted file mode 100644 index 8ba757f..0000000 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// ─── Recordings Sub-Panel ── - -import { Download, Mic, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import type { VoiceRecording } from "../../../entities/recording/types.js"; -import { deleteRecording, listRecordings } from "../../../shared/api/client"; -import { formatBytes, formatDate } from "../../../shared/lib/utils"; -import { Badge, Button, Skeleton } from "../../../shared/ui"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; -import { WaveformPlayer } from "./WaveformPlayer"; - -export function RecordingsSubPanel() { - const [recordings, setRecordings] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [deletingIds, setDeletingIds] = useState>(new Set()); - - const loadRecordings = useCallback( - async (opts?: { signal?: AbortSignal }) => { - try { - setLoading(true); - setError(null); - const data = await listRecordings({ limit: 50 }); - if (!opts?.signal?.aborted) { - setRecordings(data.items); - setNextCursor(data.nextCursor); - setHasMore(data.hasMore); - } - } catch (err) { - if (!opts?.signal?.aborted) - setError(err instanceof Error ? err.message : String(err)); - } finally { - if (!opts?.signal?.aborted) setLoading(false); - } - }, - [], - ); - - const loadMore = useCallback(async () => { - if (!nextCursor || loadingMore) return; - try { - setLoadingMore(true); - const data = await listRecordings({ limit: 50, cursor: nextCursor }); - setRecordings((prev) => [...prev, ...data.items]); - setNextCursor(data.nextCursor); - setHasMore(data.hasMore); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoadingMore(false); - } - }, [nextCursor, loadingMore]); - - useEffect(() => { - const ab = new AbortController(); - loadRecordings({ signal: ab.signal }); - const handler = () => loadRecordings(); - window.addEventListener("voice_recording_uploaded", handler); - return () => { - ab.abort(); - window.removeEventListener("voice_recording_uploaded", handler); - }; - }, [loadRecordings]); - - const handleDelete = useCallback(async (id: string) => { - if (!confirm("Delete this recording?")) return; - setDeletingIds((prev) => new Set(prev).add(id)); - try { - await deleteRecording(id); - setRecordings((prev) => prev.filter((r) => r.id !== id)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setDeletingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - } - }, []); - - if (loading) { - return ( -
- {[1, 2, 3].map((i) => ( -
- -
- - -
-
- ))} -
- ); - } - - if (error) { - return ( -
- {error} -
- -
-
- ); - } - - if (recordings.length === 0) { - return ; - } - - return ( -
- {recordings.map((rec) => ( -
-
-
- -
-
-
{rec.filename}
-
- {rec.username} - · - {rec.channel_name ?? rec.channel_id ?? "unknown"} - · - {formatDate(rec.created_at)} - · - {formatBytes(rec.size_bytes)} -
- {rec.upload_error && ( -
- {rec.upload_error} -
- )} - {rec.transcription && ( -
- {rec.transcription} -
- )} -
-
- - - {rec.upload_status} - - {rec.download_url && ( - - - - )} -
-
- {rec.download_url && ( -
- -
- )} -
- ))} - {hasMore && ( -
- -
- )} -
- ); -} diff --git a/services/frontend/src/features/live/components/ScreenSubPanel.tsx b/services/frontend/src/features/live/components/ScreenSubPanel.tsx deleted file mode 100644 index 05c8dbd..0000000 --- a/services/frontend/src/features/live/components/ScreenSubPanel.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { MonitorUp, SkipForward, Square } from "lucide-react"; -import { useState } from "react"; -import { Button, Input } from "../../../shared/ui"; - -interface ScreenSubPanelProps { - onStart: (source: string) => void; - onSkip: () => void; - onStop: () => void; - loading: boolean; -} - -export function ScreenSubPanel({ - onStart, - onSkip, - onStop, - loading, -}: ScreenSubPanelProps) { - const [source, setSource] = useState(""); - const submit = () => { - const t = source.trim(); - if (!t) return; - onStart(t); - setSource(""); - }; - - return ( -
- setSource(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - placeholder="Screen share URL or local file path" - /> -
- - - -
-
- ); -} diff --git a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx deleted file mode 100644 index 47381de..0000000 --- a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Headphones, Radio } from "lucide-react"; -import type { Channel, Guild } from "../../../entities/guild/types.js"; -import type { VoiceStatus } from "../../../entities/voice/types.js"; -import { Button, Select } from "../../../shared/ui"; -import { MicLevelMeter } from "./MicLevelMeter"; - -interface VoiceConnectionCardProps { - guilds: Guild[]; - voiceChannels: Channel[]; - selectedGuild: string; - selectedChannel: string; - status: VoiceStatus; - voiceLoading: boolean; - isListening: boolean; - isStreaming: boolean; - micLevel: number; - onGuildChange: (id: string) => void; - onChannelChange: (id: string) => void; - onJoin: () => void; - onDisconnect: () => void; - onListenToggle: () => void; - onStreamingToggle: () => void; -} - -export function VoiceConnectionCard({ - guilds, - voiceChannels, - selectedGuild, - selectedChannel, - status, - voiceLoading, - isListening, - isStreaming, - micLevel, - onGuildChange, - onChannelChange, - onJoin, - onDisconnect, - onListenToggle, - onStreamingToggle, -}: VoiceConnectionCardProps) { - return ( -
-
-

- Voice Bridge -

-

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

- -
-
- - onChannelChange(e.target.value)} - placeholder="Select voice channel" - options={voiceChannels.map((c) => ({ - value: c.id, - label: c.name, - }))} - /> -
-
- -
- - - - - {isStreaming && ( -
- - Hold Space - - -
- )} -
-
-
- ); -} diff --git a/services/frontend/src/features/live/components/WaveformPlayer.tsx b/services/frontend/src/features/live/components/WaveformPlayer.tsx deleted file mode 100644 index 5a7418b..0000000 --- a/services/frontend/src/features/live/components/WaveformPlayer.tsx +++ /dev/null @@ -1,256 +0,0 @@ -// ─── Waveform Player — audio visualizer with seekable waveform bars ────────── - -import { Pause, Play } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { createLogger } from "../../../shared/lib/logger"; - -const logger = createLogger("waveform-player"); - -const BAR_COUNT = 64; -const SAMPLE_RATE = 24000; - -interface WaveformPlayerProps { - downloadUrl: string; - filename: string; -} - -export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) { - const [playing, setPlaying] = useState(false); - const [peaks, setPeaks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const canvasRef = useRef(null); - const containerRef = useRef(null); - const audioContextRef = useRef(null); - const sourceRef = useRef(null); - const startTimeRef = useRef(0); - const startOffsetRef = useRef(0); - const rafRef = useRef(0); - const decodedRef = useRef(null); - const durationRef = useRef(0); - - // Decode audio on mount - useEffect(() => { - let cancelled = false; - const ctx = new AudioContext(); - audioContextRef.current = ctx; - - fetch(downloadUrl) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.arrayBuffer(); - }) - .then((buf) => ctx.decodeAudioData(buf)) - .then((audioBuffer) => { - if (cancelled) return; - decodedRef.current = audioBuffer; - durationRef.current = audioBuffer.duration; - - // Compute waveform peaks - const channel = audioBuffer.getChannelData(0); - const samplesPerBar = Math.floor(channel.length / BAR_COUNT); - const peakValues: number[] = []; - for (let i = 0; i < BAR_COUNT; i++) { - let max = 0; - const start = i * samplesPerBar; - const end = Math.min(start + samplesPerBar, channel.length); - for (let j = start; j < end; j++) { - const abs = Math.abs(channel[j]); - if (abs > max) max = abs; - } - // Clamp so silent sections still show a tiny bar - peakValues.push(Math.max(0.01, max)); - } - setPeaks(peakValues); - setLoading(false); - }) - .catch((err) => { - if (cancelled) return; - const msg = err instanceof Error ? err.message : String(err); - logger.error("Failed to decode audio", { error: msg }); - setError(msg); - setLoading(false); - }); - - return () => { - cancelled = true; - ctx.close(); - }; - }, [downloadUrl]); - - // Draw waveform on canvas whenever peaks change or while playing - const drawWaveform = useCallback( - (progress = 0) => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const dpr = window.devicePixelRatio || 1; - const rect = container.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = 64 * dpr; - canvas.style.height = "64px"; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, rect.width, 64); - - if (peaks.length === 0) return; - - const barWidth = rect.width / peaks.length; - const barGap = Math.max(1, barWidth * 0.15); - const barActualWidth = barWidth - barGap; - const progressPixel = rect.width * progress; - - for (let i = 0; i < peaks.length; i++) { - const x = i * barWidth; - const height = Math.max(2, peaks[i] * 50); - const y = 32 - height / 2; - - // Color: played vs unplayed - const isPlayed = x + barWidth <= progressPixel; - ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155"; - ctx.fillRect(x, y, barActualWidth, height); - } - }, - [peaks], - ); - - // Initial draw when peaks change - useEffect(() => { - drawWaveform(); - }, [drawWaveform]); - - // Animation loop while playing - useEffect(() => { - if (!playing || !decodedRef.current) return; - - const tick = () => { - if (!audioContextRef.current) return; - const elapsed = - audioContextRef.current.currentTime - startTimeRef.current; - const progress = (elapsed + startOffsetRef.current) / durationRef.current; - drawWaveform(Math.min(1, Math.max(0, progress))); - - if (progress >= 1) { - setPlaying(false); - return; - } - rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - - return () => cancelAnimationFrame(rafRef.current); - }, [playing, drawWaveform]); - - const handleTogglePlay = useCallback(() => { - const ctx = audioContextRef.current; - const buffer = decodedRef.current; - if (!ctx || !buffer) return; - - if (playing) { - // Pause - if (sourceRef.current) { - startOffsetRef.current += ctx.currentTime - startTimeRef.current; - sourceRef.current.stop(); - sourceRef.current.disconnect(); - sourceRef.current = null; - } - setPlaying(false); - return; - } - - // Resume / start - const source = ctx.createBufferSource(); - source.buffer = buffer; - source.connect(ctx.destination); - source.start(0, startOffsetRef.current); - startTimeRef.current = ctx.currentTime; - sourceRef.current = source; - setPlaying(true); - - source.onended = () => { - if (sourceRef.current === source) { - setPlaying(false); - sourceRef.current = null; - } - }; - }, [playing]); - - const handleSeek = useCallback( - (e: React.MouseEvent) => { - if (!decodedRef.current) return; - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const progress = Math.max(0, Math.min(1, x / rect.width)); - const offset = progress * durationRef.current; - - const ctx = audioContextRef.current; - if (ctx && sourceRef.current) { - sourceRef.current.stop(); - sourceRef.current.disconnect(); - } - - startOffsetRef.current = offset; - startTimeRef.current = ctx?.currentTime ?? 0; - drawWaveform(progress); - - if (playing && ctx) { - const buffer = decodedRef.current; - const source = ctx.createBufferSource(); - source.buffer = buffer; - source.connect(ctx.destination); - source.start(0, offset); - startTimeRef.current = ctx.currentTime; - sourceRef.current = source; - source.onended = () => { - if (sourceRef.current === source) { - setPlaying(false); - sourceRef.current = null; - } - }; - } - }, - [playing, drawWaveform], - ); - - if (loading) { - return
; - } - - if (error) { - return ( -
- {error} -
- ); - } - - if (peaks.length === 0) return null; - - return ( -
- -
- -
-
- ); -} diff --git a/services/frontend/src/features/live/hooks/useMediaControl.ts b/services/frontend/src/features/live/hooks/useMediaControl.ts deleted file mode 100644 index 915f369..0000000 --- a/services/frontend/src/features/live/hooks/useMediaControl.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { MediaState } from "../../../entities/media/types.js"; -import { - getMediaStatus, - queueMedia, - setMediaVolume, - skipMedia, - stopMedia, -} from "../../../shared/api/client"; -import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js"; -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-media-control"); - -const emptyMediaState: MediaState = { - playing: false, - musicVolume: 1, - current: null, - queue: [], -}; - -export function useMediaControl() { - const [mediaState, setMediaState] = useState(emptyMediaState); - const { loading, error, execute, clearError } = useAsyncAction(); - - const refreshMedia = useCallback(async () => { - const state = await getMediaStatus(); - setMediaState(state); - return state; - }, []); - - const enqueue = useCallback( - async (source: string, mode: "music" | "screen") => { - const result = await execute(() => queueMedia(source, mode)); - if (result) { - setMediaState(result); - logger.info("Media queued", { source, mode }); - } else { - logger.error("Failed to queue media", { source, mode }); - } - return result; - }, - [execute], - ); - - const skip = useCallback(async () => { - const result = await execute(() => skipMedia()); - if (result) { - setMediaState(result); - logger.info("Media skipped"); - } else { - logger.error("Failed to skip media"); - } - return result; - }, [execute]); - - const stop = useCallback(async () => { - const result = await execute(() => stopMedia()); - if (result) { - setMediaState(result); - logger.info("Media stopped"); - } else { - logger.error("Failed to stop media"); - } - return result; - }, [execute]); - - const setVolume = useCallback( - async (volume: number) => { - clearError(); - try { - const state = await setMediaVolume(volume); - setMediaState(state); - logger.info("Volume set", { volume }); - return state; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Failed to set volume", { volume, error: message }); - throw err; - } - }, - [clearError], - ); - - useEffect(() => { - refreshMedia().catch((err) => - logger.error("Failed to refresh media state on mount", { - error: String(err), - }), - ); - }, [refreshMedia]); - - return { - mediaState, - setMediaState, - loading, - error, - refreshMedia, - enqueue, - skip, - stop, - setVolume, - }; -} diff --git a/services/frontend/src/features/live/hooks/useVoiceControl.ts b/services/frontend/src/features/live/hooks/useVoiceControl.ts deleted file mode 100644 index 318f5f0..0000000 --- a/services/frontend/src/features/live/hooks/useVoiceControl.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { Channel, Guild } from "../../../entities/guild/types.js"; -import type { VoiceStatus } from "../../../entities/voice/types.js"; -import { - connectVoice, - disconnectVoice, - getGuilds, - getTextChannels, - getVoiceChannels, - getVoiceStatus, -} from "../../../shared/api/client"; -import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js"; -import { createLogger } from "../../../shared/lib/logger.js"; - -const logger = createLogger("use-voice-control"); - -export function useVoiceControl() { - const [guilds, setGuilds] = useState([]); - const [voiceChannels, setVoiceChannels] = useState([]); - const [textChannels, setTextChannels] = useState([]); - const [voiceStatus, setVoiceStatus] = useState({ - connected: false, - activeGuildId: null, - activeChannelId: null, - activeChannelName: null, - connections: [], - }); - const { loading, error, execute, clearError } = useAsyncAction(); - - const refreshGuilds = useCallback(async () => { - clearError(); - const nextGuilds = await getGuilds(); - setGuilds(nextGuilds); - return nextGuilds; - }, [clearError]); - - const refreshVoiceStatus = useCallback(async () => { - const status = await getVoiceStatus(); - setVoiceStatus(status); - return status; - }, []); - - const loadVoiceChannels = useCallback(async (guildId: string) => { - if (!guildId) { - setVoiceChannels([]); - return []; - } - const channels = await getVoiceChannels(guildId); - setVoiceChannels(channels); - return channels; - }, []); - - const loadTextTargets = useCallback(async (guildId: string) => { - if (!guildId) { - setTextChannels([]); - return []; - } - const channels = await getTextChannels(guildId); - setTextChannels(channels); - return channels; - }, []); - - const joinVoice = useCallback( - async (guildId: string, channelId: string) => { - const result = await execute(() => connectVoice(guildId, channelId)); - if (result) { - setVoiceStatus(result); - logger.info("Connected to voice", { guildId, channelId }); - } else { - logger.error("Failed to connect to voice", { guildId, channelId }); - } - return result; - }, - [execute], - ); - - const leaveVoice = useCallback(async () => { - const result = await execute(() => disconnectVoice()); - if (result) { - setVoiceStatus(result); - logger.info("Disconnected from voice"); - } else { - logger.error("Failed to disconnect from voice"); - } - return result; - }, [execute]); - - useEffect(() => { - refreshGuilds().catch((err) => - logger.error("Failed to refresh guilds on mount", { error: String(err) }), - ); - refreshVoiceStatus().catch((err) => - logger.error("Failed to refresh voice status on mount", { - error: String(err), - }), - ); - }, [refreshGuilds, refreshVoiceStatus]); - - return { - guilds, - voiceChannels, - textChannels, - voiceStatus, - loading, - error, - refreshGuilds, - refreshVoiceStatus, - loadVoiceChannels, - loadTextTargets, - joinVoice, - leaveVoice, - }; -} diff --git a/services/frontend/src/features/live/index.tsx b/services/frontend/src/features/live/index.tsx deleted file mode 100644 index 60f5783..0000000 --- a/services/frontend/src/features/live/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// ─── Live Panel — thin composition layer ──────────────────────────────────── - -import { motion } from "framer-motion"; -import { Mic, MonitorUp, Music2 } from "lucide-react"; -import type { Channel, Guild } from "../../entities/guild/types.js"; -import type { MediaState } from "../../entities/media/types.js"; -import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js"; -import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; -import { ActiveSpeakers } from "./components/ActiveSpeakers"; -import { AudioVisualizer } from "./components/AudioVisualizer"; -import { MusicSubPanel } from "./components/MusicSubPanel"; -import { NowPlaying } from "./components/NowPlaying"; -import { RecordingsSubPanel } from "./components/RecordingsSubPanel"; -import { ScreenSubPanel } from "./components/ScreenSubPanel"; -import { VoiceConnectionCard } from "./components/VoiceConnectionCard"; - -interface LivePanelProps { - guilds: Guild[]; - voiceChannels: Channel[]; - selectedGuild: string; - selectedChannel: string; - status: VoiceStatus; - voiceLoading: boolean; - activeSpeakers: ActiveSpeaker[]; - levels: number[]; - isListening: boolean; - isStreaming: boolean; - micLevel: number; - mediaState: MediaState; - mediaLoading: boolean; - onGuildChange: (id: string) => void; - onChannelChange: (id: string) => void; - onJoin: () => void; - onDisconnect: () => void; - onListenToggle: () => void; - onStreamingToggle: () => void; - onQueueMusic: (source: string) => void; - onStartScreen: (source: string) => void; - onSkip: () => void; - onStop: () => void; - onVolumeChange: (v: number) => void; -} - -export function LivePanel({ - guilds, - voiceChannels, - selectedGuild, - selectedChannel, - status, - voiceLoading, - activeSpeakers, - levels, - isListening, - isStreaming, - micLevel, - mediaState, - mediaLoading, - onGuildChange, - onChannelChange, - onJoin, - onDisconnect, - onListenToggle, - onStreamingToggle, - onQueueMusic, - onStartScreen, - onSkip, - onStop, - onVolumeChange, -}: LivePanelProps) { - return ( - - - - - - - - - Live Audio - - - - - - - - Active Speakers - - - - - - - - - - - - - - - - Music - - - Screen Share - - - Recordings - - - - - - - - - - - - - - - ); -} diff --git a/services/frontend/src/features/messages/components/ImageGrid.tsx b/services/frontend/src/features/messages/components/ImageGrid.tsx deleted file mode 100644 index f74cb67..0000000 --- a/services/frontend/src/features/messages/components/ImageGrid.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { MessageRecord } from "../../../entities/message/types.js"; -import { parseMetadata } from "../../../shared/lib/utils.js"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; - -interface ImageItem { - url: string; - title: string; - kind: "attachment" | "embed" | "sticker"; - message: MessageRecord; -} - -function kindBadge(kind: ImageItem["kind"]): string { - switch (kind) { - case "sticker": - return "bg-primary/10 text-primary border-primary/20"; - case "attachment": - return "bg-primary-soft text-primary border-primary/30"; - case "embed": - return "bg-tertiary-soft text-tertiary border-tertiary/20"; - } -} - -export function ImageGrid({ messages }: { messages: MessageRecord[] }) { - const images: ImageItem[] = []; - - for (const message of messages) { - const metadata = parseMetadata(message.metadata); - - // Stickers - for (const sticker of metadata.stickers ?? []) { - if (sticker.url) { - images.push({ - url: sticker.url, - title: sticker.name || "sticker", - kind: "sticker", - message, - }); - } - } - - // Attachments - for (const attachment of metadata.attachments ?? []) { - if ( - attachment.url && - (attachment.contentType?.startsWith("image/") || - /\.(png|jpe?g|gif|webp)$/i.test(attachment.name)) - ) { - images.push({ - url: attachment.url, - title: attachment.name, - kind: "attachment", - message, - }); - } - } - - // Embed images - for (const embed of metadata.embeds ?? []) { - for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) { - images.push({ - url: imgUrl as string, - title: embed.title || "embed image", - kind: "embed", - message, - }); - } - } - } - - if (images.length === 0) { - return ; - } - - return ( -
- {images.map((image) => { - // Stable key using message.id + url - const stableKey = `${image.message.id}-${image.kind}-${image.url}`; - return ( - -
- {image.kind === "sticker" ? ( - {image.title} - ) : ( - {image.title} - )} - - {image.kind} - -
-
-
{image.title}
-
-
- -
- - {image.message.username} - -
-
-
- ); - })} -
- ); -} diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx deleted file mode 100644 index 99c6aef..0000000 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ /dev/null @@ -1,561 +0,0 @@ -import { - AlertCircle, - CheckCircle2, - Forward, - Hash, - Image as ImageIcon, - MessageCircle, - Pencil, - Reply, - RotateCw, - Smile, - Trash2, - Video, -} from "lucide-react"; -import { Fragment, useEffect, useMemo, useState } from "react"; -import type { MessageRecord } from "../../../entities/message/types.js"; -import { parseMetadata } from "../../../shared/lib/utils.js"; -import { getMessageById } from "../../../shared/api/client.js"; -import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui"; - -const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; - -function renderContentWithCustomEmojis(content: string): React.ReactNode { - const parts: React.ReactNode[] = []; - const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g"); - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = regex.exec(content)) !== null) { - if (match.index > lastIndex) { - parts.push(content.slice(lastIndex, match.index)); - } - const [, animated, name, id] = match; - const ext = animated ? "gif" : "png"; - const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`; - parts.push( - {name}, - ); - 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-destructive-soft text-destructive border-destructive/20"; - case "high": - return "bg-warning-soft text-warning border-warning/20"; - case "medium": - return "bg-warning-soft text-warning border-warning/20"; - case "low": - return "bg-info-soft text-info border-info/20"; - 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 - ) : ( -
- -
- )} -
- ))} -
- )} - - {/* Attached images */} - {hasImages && ( -
- {imageAttachments.slice(0, 4).map((img) => ( - - {img.name} - - ))} - {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 */} - - -
- {/* Group header: username + location + timestamp */} -
- - {firstMsg.username || firstMsg.user_id} - - {locationLabel && ( - - - {locationLabel} - - )} - - {formatTimeAgo(firstMsg.created_at)} - {hasMultiple && ` · ${messages.length} messages`} - -
- - {/* Message rows — divided by separator when multiple */} -
- {messages.map((msg, idx) => ( -
0 ? "pt-2.5" : ""} - > - -
- ))} -
-
-
-
- ); -} - -// ─── Skeleton ──────────────────────────────────────────────────────────────── - -export function MessageCardSkeleton() { - return ( -
-
- -
- - - -
- - -
-
-
-
- ); -} diff --git a/services/frontend/src/features/messages/components/MessageFeed.tsx b/services/frontend/src/features/messages/components/MessageFeed.tsx deleted file mode 100644 index 2064fd3..0000000 --- a/services/frontend/src/features/messages/components/MessageFeed.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { motion } from "framer-motion"; -import { useEffect, useMemo, useRef } from "react"; -import type { MessageRecord } from "../../../entities/message/types.js"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { ScrollArea } from "../../../shared/ui"; -import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; -import { MessageCard, MessageCardSkeleton } from "./MessageCard"; - -export interface MessageFeedProps { - messages: MessageRecord[]; - onReanalyze: (id: string) => Promise; - emptyText?: string; - loading?: boolean; - onLoadMore?: () => void; - hasMore?: boolean; - loadingMore?: boolean; -} - -/** Messages from the same user within 5 minutes are visually grouped. */ -const GROUP_WINDOW_MS = 5 * 60 * 1000; - -interface MessageGroup { - messages: MessageRecord[]; -} - -function groupMessages(messages: MessageRecord[]): MessageGroup[] { - const groups: MessageGroup[] = []; - for (const msg of messages) { - const lastGroup = groups[groups.length - 1]; - if ( - lastGroup && - lastGroup.messages[0].user_id === msg.user_id && - lastGroup.messages[lastGroup.messages.length - 1].created_at - - msg.created_at < - GROUP_WINDOW_MS - ) { - lastGroup.messages.push(msg); - } else { - groups.push({ messages: [msg] }); - } - } - return groups; -} - -export function MessageFeed({ - messages, - onReanalyze, - emptyText: _emptyText, - loading, - onLoadMore, - hasMore, - loadingMore, -}: MessageFeedProps) { - // IntersectionObserver for infinite scroll — fires when sentinel becomes visible - const sentinelRef = useRef(null); - - useEffect(() => { - if (!onLoadMore || !hasMore) return; - const el = sentinelRef.current; - if (!el) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) onLoadMore(); - }, - { rootMargin: "400px" }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [onLoadMore, hasMore]); - - const groupedMessages = useMemo(() => groupMessages(messages), [messages]); - - if (loading) { - return ( - -
- {[1, 2, 3, 4, 5].map((i) => ( - - ))} -
-
- ); - } - - if (messages.length === 0) { - return ; - } - - return ( - - - {groupedMessages.map((group) => ( - - - - ))} - - {/* Infinite-scroll sentinel */} - {hasMore && ( -
- {loadingMore ? ( - - ) : ( -
- )} -
- )} - - - ); -} diff --git a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx deleted file mode 100644 index a11afe6..0000000 --- a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// ─── Moderation alert toast listener ─────────────────────────────────────── -// Listens for "moderation_alert" custom events dispatched from WebSocket -// message_analyzed handler, and shows toast notifications for flagged -// messages so moderators don't miss important alerts. -import { useEffect } from "react"; -import { useToast } from "../../../shared/ui"; - -interface AlertDetail { - type: "flagged"; - username: string; - severity: string; - categories: string; - brief: string; -} - -function severityToToastType( - severity: string, -): "error" | "warning" | "info" | "success" { - switch (severity) { - case "critical": - case "high": - return "error"; - case "medium": - return "warning"; - case "low": - return "info"; - default: - return "warning"; - } -} - -export function ModerationAlertListener() { - const { addToast } = useToast(); - - useEffect(() => { - const handler = (e: Event) => { - const { username, severity, categories, brief } = ( - e as CustomEvent - ).detail; - - const sevLabel = severity ? `[${severity}]` : ""; - const catLabel = categories - ? ` — ${categories.split(",").slice(0, 2).join(", ")}` - : ""; - - addToast( - `🚨 ${username} ${sevLabel}${catLabel}: ${brief}`, - severityToToastType(severity), - ); - }; - - window.addEventListener("moderation_alert", handler); - return () => window.removeEventListener("moderation_alert", handler); - }, [addToast]); - - return null; -} diff --git a/services/frontend/src/features/messages/hooks/useMessages.ts b/services/frontend/src/features/messages/hooks/useMessages.ts deleted file mode 100644 index 24b23d4..0000000 --- a/services/frontend/src/features/messages/hooks/useMessages.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -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; - -export function mergeMessages( - current: MessageRecord[], - incoming: MessageRecord[], -): MessageRecord[] { - const byId = new Map(current.map((message) => [message.id, message])); - for (const message of incoming) { - byId.set(message.id, { ...byId.get(message.id), ...message }); - } - return Array.from(byId.values()).sort( - (a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id), - ); -} - -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 fetchMessages = useCallback(async (guildId?: string) => { - if (!guildId) { - setMessages([]); - setCursor(null); - setHasMore(false); - return []; - } - currentGuild.current = guildId; - setLoading(true); - setError(null); - try { - const result = await listMessages({ - guildId, - limit: PAGE_SIZE, - }); - if (currentGuild.current === guildId) { - setMessages(result.data); - setCursor(result.nextCursor); - setHasMore(!!result.nextCursor); - } - return result.data; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - logger.error("Failed to fetch messages", { guildId, error: message }); - throw err; - } finally { - setLoading(false); - } - }, []); - - const loadMore = useCallback(async () => { - if (!cursor || !currentGuild.current || loadingMore) return; - setLoadingMore(true); - try { - const result = await listMessages({ - guildId: currentGuild.current, - cursor, - limit: PAGE_SIZE, - }); - setMessages((prev) => [...prev, ...result.data]); - setCursor(result.nextCursor); - setHasMore(!!result.nextCursor); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Failed to load more messages", { error: message }); - } finally { - setLoadingMore(false); - } - }, [cursor, loadingMore]); - - const reanalyze = useCallback(async (id: string): Promise => { - // 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) => { - saved = prev.find((m) => m.id === id); - return prev.map((message) => - message.id === id - ? { - ...message, - ai_status: "pending" as const, - ai_error: null, - ai_analysis: null, - } - : message, - ); - }); - - try { - await reanalyzeMessage(id); - } catch (err) { - // HTTP failed — revert the optimistic update so the UI stays truthful. - if (saved) { - const snapshot = saved; - setMessages((prev) => - prev.map((message) => (message.id === id ? snapshot : message)), - ); - } - const message = err instanceof Error ? err.message : String(err); - logger.error("Failed to reanalyze message", { id, error: message }); - throw err; - } - }, []); - - 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 deleted file mode 100644 index 78071fc..0000000 --- a/services/frontend/src/features/messages/index.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { motion } from "framer-motion"; -import { Filter, RotateCw, Search, X } from "lucide-react"; -import { useMemo, useState } from "react"; -import type { MessageRecord } from "../../shared/api/client"; -import { request } from "../../shared/api/client"; -import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../shared/ui"; -import { ImageGrid } from "./components/ImageGrid"; -import { MessageFeed } from "./components/MessageFeed"; - -interface MessagesPanelProps { - guildName: string | null; - messages: MessageRecord[]; - onReanalyze: (id: string) => Promise; - onReanalyzeAllErrors?: () => Promise; - onLoadMore?: () => void; - hasMore?: boolean; - loadingMore?: boolean; -} - -type AiFilter = "all" | "analyzed" | "clean" | "flagged" | "error" | "pending"; - -export function MessagesPanel({ - guildName, - messages, - onReanalyze, - onReanalyzeAllErrors, - onLoadMore, - hasMore, - loadingMore, -}: MessagesPanelProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [showSearch, setShowSearch] = useState(false); - const [aiFilter, setAiFilter] = useState("analyzed"); - const [viewTab, setViewTab] = useState<"all" | "images">("all"); - const [retryingAll, setRetryingAll] = useState(false); - const [retriedCount, setRetriedCount] = useState(null); - - const handleSearch = async () => { - if (!searchQuery.trim()) { - setSearchResults([]); - setShowSearch(false); - return; - } - setIsSearching(true); - try { - const params = new URLSearchParams({ q: searchQuery, limit: "50" }); - const data = await request<{ results: MessageRecord[] }>( - `/api/analysis/search?${params}`, - ); - setSearchResults(data.results || []); - setShowSearch(true); - } catch { - setSearchResults([]); - } finally { - setIsSearching(false); - } - }; - - const stats = useMemo(() => { - const base = showSearch ? searchResults : messages; - return { - total: base.length, - clean: base.filter((m) => m.ai_status === "clean").length, - flagged: base.filter((m) => m.ai_status === "flagged").length, - error: base.filter((m) => m.ai_status === "error").length, - pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status) - .length, - deleted: base.filter((m) => m.deleted_at).length, - edited: base.filter((m) => m.edited_at).length, - }; - }, [messages, searchResults, showSearch]); - - const filteredMessages = useMemo(() => { - const base = showSearch ? searchResults : messages; - if (aiFilter === "all") return base; - return base.filter((m) => { - const status = m.ai_status ?? "pending"; - if (aiFilter === "analyzed") - return status !== "pending" && status !== null && status !== undefined; - if (aiFilter === "pending") - return status === "pending" || status === null || status === undefined; - return status === aiFilter; - }); - }, [messages, searchResults, showSearch, aiFilter]); - - return ( - - - - - Messages - {guildName && ( -

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

- )} -
- -

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

-
-
-
- - {stats.total > 0 && ( - - - {stats.total} total{hasMore && !showSearch ? "+" : ""} - - - {stats.clean} clean - - - {stats.flagged} flagged - - - {stats.error} error - - - {stats.pending} pending - - {stats.deleted > 0 && ( - - {stats.deleted} deleted - - )} - {stats.edited > 0 && ( - - {stats.edited} edited - - )} - - )} - - -
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - disabled={isSearching} - /> -
- - {showSearch && ( - - )} - {stats.error > 0 && onReanalyzeAllErrors && ( - - )} - {retriedCount !== null && ( - - {retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for - re-analysis - - )} -
- - {( - [ - "all", - "analyzed", - "clean", - "flagged", - "error", - "pending", - ] as AiFilter[] - ).map((f) => ( - - ))} -
-
- - {showSearch && searchResults.length > 0 && ( - - Found {searchResults.length} result - {searchResults.length !== 1 ? "s" : ""} - - )} - - - setViewTab(v as "all" | "images")} - > - - - {showSearch - ? `Search (${filteredMessages.length})` - : `All (${filteredMessages.length})`} - - Images - - - - - - - - - -
- ); -} diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx deleted file mode 100644 index 1b16609..0000000 --- a/services/frontend/src/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; -import { ToastProvider } from "./shared/ui"; -import "./styles.css"; - -const root = document.getElementById("root"); - -if (!root) { - throw new Error("Root element not found"); -} - -ReactDOM.createRoot(root).render( - - - - - , -); diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts deleted file mode 100644 index e70e99a..0000000 --- a/services/frontend/src/shared/api/client.ts +++ /dev/null @@ -1,350 +0,0 @@ -// ─── Shared HTTP client — all API endpoints in one file ────────────────────── - -import type { MessageRecord, PageResult } from "@bete/shared"; -import type { - ChatResponse, - DashboardChannel, - DashboardChannelDetail, - DashboardStats, - DashboardUser, - DashboardUserDetail, -} from "../../entities/dashboard/types.js"; -import type { - Channel, - Guild, - GuildVoiceEntry, -} from "../../entities/guild/types.js"; -import type { - MediaItem, - MediaMode, - MediaState, -} from "../../entities/media/types.js"; -import type { - VoiceRecording, - VoiceRecordingListResponse, -} from "../../entities/recording/types.js"; -import type { - AppConfig, - DashboardTab, - UIState, -} from "../../entities/ui/types.js"; -import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js"; -import { createLogger } from "../lib/logger.js"; - -const logger = createLogger("api"); - -const BE_API_URL = import.meta.env.VITE_BE_API_URL || "http://localhost:3001"; - -const DEFAULT_TIMEOUT_MS = 15000; - -class ApiError extends Error { - code: string; - statusCode: number; - - constructor(code: string, message: string, statusCode: number) { - super(message); - this.name = "ApiError"; - this.code = code; - this.statusCode = statusCode; - } -} - -// Cache admin password in memory — read from sessionStorage once on first call -let _cachedPassword: string | null = null; - -function getAdminPassword(): string | null { - if (_cachedPassword === null) { - try { - _cachedPassword = sessionStorage.getItem("admin-password"); - } catch { - _cachedPassword = null; - } - } - return _cachedPassword; -} - -function buildSearchParams( - params: Record, -): URLSearchParams { - const sp = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value != null && value !== "") { - sp.set(key, String(value)); - } - } - return sp; -} - -export async function request( - path: string, - init?: RequestInit, - timeoutMs?: number, -): Promise { - const password = getAdminPassword(); - const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`; - const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS); - logger.debug("Request", { method: init?.method ?? "GET", url }); - - const res = await fetch(url, { - headers: { - "Content-Type": "application/json", - ...(password ? { "X-Admin-Password": password } : {}), - }, - signal, - ...init, - }); - - if (!res.ok) { - let message = res.statusText; - let code = "REQUEST_FAILED"; - try { - const body = (await res.json()) as { error?: string; message?: string }; - if (body.message) message = body.message; - if (body.error) code = body.error; - } catch { - // ignore parse errors - } - logger.error("Request failed", { url, status: res.status, code, message }); - throw new ApiError(code, message, res.status); - } - - const result = (await res.json()) as T; - logger.debug("Response", { url, status: res.status }); - return result; -} - -export function getAPIURL(): string { - return BE_API_URL; -} - -// ─── Re-exports ────────────────────────────────────────────────────────────── - -export type { - ActiveSpeaker, - AppConfig, - Channel, - ChatResponse, - DashboardChannel, - DashboardChannelDetail, - DashboardStats, - DashboardTab, - DashboardUser, - DashboardUserDetail, - Guild, - GuildVoiceEntry, - MediaItem, - MediaMode, - MediaState, - MessageRecord, - PageResult, - UIState, - VoiceRecording, - VoiceRecordingListResponse, - VoiceStatus, -}; - -// ─── Messages ──────────────────────────────────────────────────────────────── - -export function listMessages(params: { - guildId: string; - channelId?: string; - limit?: number; - cursor?: string; -}): Promise> { - const sp = buildSearchParams({ - guildId: params.guildId, - limit: params.limit ?? 100, - channelId: params.channelId, - cursor: params.cursor, - }); - return request>(`/api/messages?${sp}`); -} - -export function listReview( - params: URLSearchParams, -): Promise> { - return request>(`/api/review?${params}`); -} - -export function reanalyzeMessage(id: string): Promise { - return request(`/api/messages/${id}/reanalyze`, { method: "POST" }); -} - -export function getMessageById( - id: string, -): Promise { - return request(`/api/messages/detail/${id}`); -} - -export function reanalyzeErrorBatch(opts: { - guildId?: string; - channelId?: string; - messageIds?: string[]; -}): Promise<{ ok: boolean; count: number }> { - return request<{ ok: boolean; count: number }>( - "/api/messages/reanalyze-batch", - { method: "POST", body: JSON.stringify(opts) }, - ); -} - -// ─── Guilds / Config ───────────────────────────────────────────────────────── - -export function getGuilds(): Promise { - return request("/api/guilds"); -} - -export function getAppConfig(): Promise { - return request("/api/config"); -} - -// ─── Voice ─────────────────────────────────────────────────────────────────── - -export function getVoiceChannels(guildId: string): Promise { - return request(`/api/guilds/${guildId}/voice-channels`); -} - -export function getTextChannels(guildId: string): Promise { - return request(`/api/guilds/${guildId}/channels`); -} - -export function getVoiceStatus(): Promise { - return request("/api/voice/status"); -} - -export function connectVoice( - guildId: string, - channelId: string, -): Promise { - return request("/api/voice/connect", { - method: "POST", - body: JSON.stringify({ guildId, channelId }), - }); -} - -export function disconnectVoice(): Promise { - return request("/api/voice/disconnect", { method: "POST" }); -} - -// ─── Media ─────────────────────────────────────────────────────────────────── - -export function getMediaStatus(): Promise { - return request("/api/media/status"); -} - -export function queueMedia( - source: string, - mode: "music" | "screen", -): Promise { - return request("/api/media/queue", { - method: "POST", - body: JSON.stringify({ source, mode }), - }); -} - -export function skipMedia(): Promise { - return request("/api/media/skip", { method: "POST" }); -} - -export function stopMedia(): Promise { - return request("/api/media/stop", { method: "POST" }); -} - -export function setMediaVolume(volume: number): Promise { - return request("/api/media/volume", { - method: "POST", - body: JSON.stringify({ volume }), - }); -} - -// ─── Recordings ────────────────────────────────────────────────────────────── - -export function listRecordings(params?: { - limit?: number; - cursor?: string; -}): Promise { - const sp = buildSearchParams({ - limit: params?.limit ?? 50, - cursor: params?.cursor, - }); - return request(`/api/recordings?${sp}`); -} - -export function deleteRecording(id: string): Promise { - return request(`/api/recordings/${id}`, { method: "DELETE" }); -} - -// ─── Auth ──────────────────────────────────────────────────────────────────── - -export function login(password: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>("/api/auth/login", { - method: "POST", - body: JSON.stringify({ password }), - }); -} - -// ─── Dashboard ───────────────────────────────────────────────────────────────── - -export function getDashboardStats(): Promise { - return request("/api/dashboard/stats"); -} - -export function listDashboardUsers( - params: { limit?: number; cursor?: string; search?: string } = {}, -): Promise<{ data: DashboardUser[]; nextCursor: string | null }> { - const sp = buildSearchParams({ - limit: params.limit, - cursor: params.cursor, - search: params.search, - }); - return request<{ data: DashboardUser[]; nextCursor: string | null }>( - `/api/dashboard/users?${sp}`, - ); -} - -export function getDashboardUserDetail( - userId: string, -): Promise { - return request(`/api/dashboard/users/${userId}`); -} - -// ─── Dashboard Channels ───────────────────────────────────────────────────────── - -export function listDashboardChannels( - params: { - limit?: number; - search?: string; - guild_id?: string; - cursor?: string; - } = {}, -): Promise<{ data: DashboardChannel[]; nextCursor: string | null }> { - const sp = buildSearchParams({ - limit: params.limit, - search: params.search, - guild_id: params.guild_id, - cursor: params.cursor, - }); - return request<{ data: DashboardChannel[]; nextCursor: string | null }>( - `/api/dashboard/channels?${sp}`, - ); -} - -export function getDashboardChannelDetail( - channelId: string, -): Promise { - return request( - `/api/dashboard/channels/${channelId}`, - ); -} - -// ─── UI State ──────────────────────────────────────────────────────────────── - -export function getUIState(): Promise { - return request("/api/ui-state"); -} - -export function updateUIState(patch: Partial): Promise { - return request("/api/ui-state", { - method: "POST", - body: JSON.stringify(patch), - }); -} diff --git a/services/frontend/src/shared/hooks/useAsyncAction.ts b/services/frontend/src/shared/hooks/useAsyncAction.ts deleted file mode 100644 index a71f887..0000000 --- a/services/frontend/src/shared/hooks/useAsyncAction.ts +++ /dev/null @@ -1,37 +0,0 @@ -// ─── Generic async action state hook ────────────────────────────────────── - -import { useCallback, useState } from "react"; - -interface AsyncActionState { - loading: boolean; - error: string | null; -} - -export function useAsyncAction() { - const [state, setState] = useState({ - loading: false, - error: null, - }); - - const execute = useCallback( - async (fn: () => Promise): Promise => { - setState({ loading: true, error: null }); - try { - const result = await fn(); - setState({ loading: false, error: null }); - return result; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setState({ loading: false, error: message }); - return null; - } - }, - [], - ); - - const clearError = useCallback(() => { - setState((prev) => ({ ...prev, error: null })); - }, []); - - return { ...state, execute, clearError }; -} diff --git a/services/frontend/src/shared/hooks/useAudioPlayback.ts b/services/frontend/src/shared/hooks/useAudioPlayback.ts deleted file mode 100644 index 7377a8e..0000000 --- a/services/frontend/src/shared/hooks/useAudioPlayback.ts +++ /dev/null @@ -1,235 +0,0 @@ -// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ── -import { - type RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { createLogger } from "../lib/logger.js"; - -const logger = createLogger("use-audio-playback"); - -const SAMPLE_RATE = 24000; -const CHANNELS = 1; -const LEVEL_COUNT = 32; - -// Pre-computed level distribution shape — computed once at module load, not per render -const LEVEL_SHAPE = Array.from( - { length: LEVEL_COUNT }, - (_, i) => 0.3 + (Math.sin(i * 0.6) * 0.35 + 0.65) * 0.7, -); - -/** Reverse lookup: userIdHash → userId, populated by handleIncomingBinary */ -const userIdHashToId = new Map(); - -export function useAudioPlayback(): { - isListening: boolean; - levels: number[]; - handleIncomingPcm: (data: { userId: string; pcm: string }) => void; - handleIncomingBinary: (data: ArrayBuffer) => void; - registerUserId: (userId: string) => void; - toggleListening: () => Promise; - audioContextRef: RefObject; -} { - const [isListening, setIsListening] = useState(false); - const [levels, setLevels] = useState( - Array.from({ length: LEVEL_COUNT }, () => 0.04), - ); - const audioContextRef = useRef(null); - const userTimelinesRef = useRef(new Map()); - - // Cleanup AudioContext on unmount to prevent leak - useEffect(() => { - return () => { - const ctx = audioContextRef.current; - if (ctx) { - ctx.close(); - audioContextRef.current = null; - } - userTimelinesRef.current.clear(); - }; - }, []); - - // Prune stale timeline entries (> 30s old) based on current audioContext time - const pruneTimelines = useCallback(() => { - const now = - audioContextRef.current?.currentTime ?? performance.now() / 1000; - for (const [userId, endTime] of userTimelinesRef.current) { - if (endTime + 30 < now) userTimelinesRef.current.delete(userId); - } - }, []); - - /** - * Handle incoming binary PCM from WS. - * Format per chunk: 4-byte userId hash (UInt32LE) + raw PCM (Int16). - * userId hash → userId mapping is populated by voice_active_user events. - */ - const handleIncomingBinary = useCallback( - (buffer: ArrayBuffer) => { - const view = new DataView(buffer); - if (buffer.byteLength < 5) return; // Need at least 4-byte hash + 1 PCM byte - const userIdHash = view.getUint32(0, true); - const userId = userIdHashToId.get(userIdHash) ?? `user:${userIdHash}`; - const pcmBytes = buffer.byteLength - 4; - if (pcmBytes === 0) return; - - const int16Array = new Int16Array(buffer, 4, pcmBytes / 2); - if (int16Array.length === 0) return; - - // RMS + level computation (same as before) - let sumSquares = 0; - const float32Array = new Float32Array(int16Array.length); - for (let i = 0; i < int16Array.length; i++) { - const normalized = int16Array[i] / 32768; - float32Array[i] = normalized; - sumSquares += normalized * normalized; - } - const rms = Math.sqrt(sumSquares / int16Array.length); - const dbLevel = Math.min(1, Math.max(0.04, rms * 8)); - - setLevels((prev) => - prev.map((_, index) => - Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5), - ), - ); - - const audioContext = audioContextRef.current; - if (!isListening || !audioContext) return; - - const audioBuffer = audioContext.createBuffer( - CHANNELS, - float32Array.length, - SAMPLE_RATE, - ); - audioBuffer.getChannelData(0).set(float32Array); - - const source = audioContext.createBufferSource(); - source.buffer = audioBuffer; - source.connect(audioContext.destination); - - const currentTime = audioContext.currentTime; - let nextStart = userTimelinesRef.current.get(userId) || 0; - if (nextStart < currentTime) nextStart = currentTime + 0.05; - source.start(nextStart); - userTimelinesRef.current.set(userId, nextStart + audioBuffer.duration); - pruneTimelines(); - }, - [isListening, pruneTimelines], - ); - - /** - * Register a userId → hash mapping from voice_active_user events. - */ - const registerUserId = useCallback((userId: string) => { - const hash = fnv1a32(userId); - userIdHashToId.set(hash, userId); - }, []); - - // Legacy JSON handler kept for backward compat - const handleIncomingPcm = useCallback( - (data: { userId: string; pcm: string }) => { - // Decode base64 PCM data - try { - const bytes = Uint8Array.from(atob(data.pcm), (c) => c.charCodeAt(0)); - if (bytes.length === 0) return; - const int16Array = new Int16Array( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength / 2, - ); - - // 5b/5d: Real RMS calculation + Float32Array conversion in single pass - let sumSquares = 0; - const float32Array = new Float32Array(int16Array.length); - for (let i = 0; i < int16Array.length; i++) { - const normalized = int16Array[i] / 32768; - float32Array[i] = normalized; - sumSquares += normalized * normalized; - } - const rms = Math.sqrt(sumSquares / int16Array.length); - // Scale RMS to a lively visualization range, clamp to [0.04, 1.0] - const dbLevel = Math.min(1, Math.max(0.04, rms * 8)); - - // 5c: Use pre-computed LEVEL_SHAPE (no Date.now() per PCM frame) - setLevels((prev) => - prev.map((_, index) => - Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5), - ), - ); - - const audioContext = audioContextRef.current; - if (!isListening || !audioContext) return; - - const audioBuffer = audioContext.createBuffer( - CHANNELS, - float32Array.length, - SAMPLE_RATE, - ); - audioBuffer.getChannelData(0).set(float32Array); - - const source = audioContext.createBufferSource(); - source.buffer = audioBuffer; - source.connect(audioContext.destination); - - // Schedule playback per user to avoid overlaps - const currentTime = audioContext.currentTime; - let nextStart = userTimelinesRef.current.get(data.userId) || 0; - if (nextStart < currentTime) nextStart = currentTime + 0.05; - source.start(nextStart); - userTimelinesRef.current.set( - data.userId, - nextStart + audioBuffer.duration, - ); - pruneTimelines(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error("Failed to decode PCM audio", { - userId: data.userId, - error: message, - }); - } - }, - [isListening, pruneTimelines], - ); - - const toggleListening = useCallback(async () => { - if (isListening) { - await audioContextRef.current?.suspend(); - userTimelinesRef.current.clear(); - setIsListening(false); - logger.info("Audio playback paused"); - return; - } - const AudioContextCtor = - window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext; - audioContextRef.current ??= new AudioContextCtor({ - sampleRate: SAMPLE_RATE, - }); - await audioContextRef.current.resume(); - setIsListening(true); - logger.info("Audio playback started"); - }, [isListening]); - - return { - isListening, - levels, - handleIncomingPcm, - handleIncomingBinary, - registerUserId, - toggleListening, - audioContextRef, - }; -} - -/** 32-bit FNV-1a hash for userId → consistent 4-byte identifier */ -function fnv1a32(str: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < str.length; i++) { - hash ^= str.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} diff --git a/services/frontend/src/shared/hooks/useAudioTransmit.ts b/services/frontend/src/shared/hooks/useAudioTransmit.ts deleted file mode 100644 index 2c8f3c9..0000000 --- a/services/frontend/src/shared/hooks/useAudioTransmit.ts +++ /dev/null @@ -1,207 +0,0 @@ -// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ── -import { useCallback, useRef, useState } from "react"; -import { getAPIURL } from "../api/client.js"; -// note: this hook only uses API functions, not entity types -import { createLogger } from "../lib/logger"; - -const SAMPLE_RATE = 24000; -const LEVEL_THROTTLE_MS = 50; // 20Hz mic level updates -const logger = createLogger("useAudioTransmit"); - -async function sendTransmitCommand(command: string): Promise { - // Send via HTTP API (kept as exported function for backward compatibility) - const resp = await fetch(`${getAPIURL()}/api/voice/command`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command }), - }); - if (!resp.ok) { - logger.warn("HTTP command response", { - status: resp.status, - statusText: resp.statusText, - }); - const text = await resp.text().catch(() => resp.statusText); - logger.warn("HTTP command failed", { error: text }); - throw new Error(`HTTP ${resp.status}: ${text}`); - } -} - -function sendWsCommand( - socketRef: { readonly current: WebSocket | null }, - command: string, -): boolean { - if (socketRef.current?.readyState === WebSocket.OPEN) { - socketRef.current.send( - JSON.stringify({ - type: "voice_command", - command, - }), - ); - return true; - } - return false; -} - -export function useAudioTransmit(socketRef: { - readonly current: WebSocket | null; -}): { - isStreaming: boolean; - micError: string | null; - micLevel: number; - toggle: () => Promise; - stopTransmit: () => void; - startTransmit: () => Promise; - stop: () => void; - start: () => Promise; -} { - const [isStreaming, setIsStreaming] = useState(false); - const [micError, setMicError] = useState(null); - const [micLevel, setMicLevel] = useState(0); - const streamRef = useRef(null); - const audioContextRef = useRef(null); - const processorRef = useRef(null); - const sourceRef = useRef(null); - const isTransmittingRef = useRef(false); - const lastLevelUpdateRef = useRef(0); - - const stop = useCallback(() => { - // 6c: Prefer WebSocket round-trip over HTTP for lower latency - if (!sendWsCommand(socketRef, "voice:transmit:stop")) { - sendTransmitCommand("voice:transmit:stop").catch(() => {}); - } - - setMicError(null); - setIsStreaming(false); - isTransmittingRef.current = false; - if (processorRef.current) { - processorRef.current.disconnect(); - processorRef.current = null; - } - if (sourceRef.current) { - sourceRef.current.disconnect(); - sourceRef.current = null; - } - if (audioContextRef.current) { - audioContextRef.current.close(); - audioContextRef.current = null; - } - if (streamRef.current) { - for (const track of streamRef.current.getTracks()) track.stop(); - streamRef.current = null; - } - setMicLevel(0); - }, [socketRef]); - - const start = useCallback(async () => { - // Reset mic error on new attempt - setMicError(null); - - // 6c: Prefer WebSocket round-trip over HTTP for lower latency - if (!sendWsCommand(socketRef, "voice:transmit:start")) { - await sendTransmitCommand("voice:transmit:start"); - } - - let stream: MediaStream; - try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch (err) { - if (err instanceof DOMException && err.name === "NotAllowedError") { - setMicError( - "Microphone access denied. Please allow microphone permissions.", - ); - } else { - const message = err instanceof Error ? err.message : String(err); - setMicError(`Microphone access failed: ${message}`); - } - logger.error("getUserMedia failed", { error: String(err) }); - return; - } - streamRef.current = stream; - setIsStreaming(true); - isTransmittingRef.current = true; - const AudioContextCtor = - window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext; - const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE }); - audioContextRef.current = audioContext; - const source = audioContext.createMediaStreamSource(stream); - sourceRef.current = source; - const processor = audioContext.createScriptProcessor(1024, 1, 1); - processorRef.current = processor; - source.connect(processor); - processor.connect(audioContext.destination); - processor.onaudioprocess = (event) => { - const inputData = event.inputBuffer.getChannelData(0); - - // Compute RMS from input buffer for mic level metering - let sumSquares = 0; - for (let i = 0; i < inputData.length; i++) { - sumSquares += inputData[i] * inputData[i]; - } - const rms = Math.sqrt(sumSquares / inputData.length); - const now = Date.now(); - if (now - lastLevelUpdateRef.current >= LEVEL_THROTTLE_MS) { - lastLevelUpdateRef.current = now; - // Scale so conversational speech hits ~0.3-0.6 - setMicLevel(Math.min(1, rms * 3)); - } - - if (!isTransmittingRef.current) return; - if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) - return; - - const pcmData = new Int16Array(inputData.length); - for (let i = 0; i < inputData.length; i++) - pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767; - - // Send as binary: 4-byte magic "PCM\0" + raw PCM Int16 - const magic = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0" - const pcmBytes = new Uint8Array(pcmData.buffer); - const buf = new Uint8Array(magic.length + pcmBytes.length); - buf.set(magic, 0); - buf.set(pcmBytes, magic.length); - socketRef.current.send(buf.buffer); - }; - }, [socketRef]); - - const stopTransmit = useCallback(() => { - if (!isTransmittingRef.current) return; - isTransmittingRef.current = false; - if (!sendWsCommand(socketRef, "voice:transmit:stop")) { - sendTransmitCommand("voice:transmit:stop").catch(() => {}); - } - setIsStreaming(false); - }, [socketRef]); - - const startTransmit = useCallback(async () => { - if (isTransmittingRef.current) return; - isTransmittingRef.current = true; - if (!sendWsCommand(socketRef, "voice:transmit:start")) { - await sendTransmitCommand("voice:transmit:start"); - } - setIsStreaming(true); - }, [socketRef]); - - const toggle = useCallback(async () => { - if (isStreaming) { - stopTransmit(); - } else if (streamRef.current) { - // Mic already captured, resume transmission without re-acquiring - await startTransmit(); - } else { - await start(); - } - }, [isStreaming, startTransmit, stopTransmit, start]); - - return { - isStreaming, - micError, - micLevel, - toggle, - stopTransmit, - startTransmit, - stop, - start, - }; -} diff --git a/services/frontend/src/shared/hooks/useFramerStagger.ts b/services/frontend/src/shared/hooks/useFramerStagger.ts deleted file mode 100644 index 85f000f..0000000 --- a/services/frontend/src/shared/hooks/useFramerStagger.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type Variants } from "framer-motion"; - -/** - * Parent container variant for staggerChildren. - * Use on motion.div wrapping a list of cardItem children. - */ -export const cardStagger: Variants = { - initial: { opacity: 0 }, - animate: { - opacity: 1, - transition: { staggerChildren: 0.08, delayChildren: 0.1 }, - }, -}; - -/** - * Child item variant for fade + slide up. - * Intended as a child of cardStagger. - */ -export const cardItem: Variants = { - initial: { opacity: 0, y: 20 }, - animate: { - opacity: 1, - y: 0, - transition: { duration: 0.4, ease: "easeOut" }, - }, -}; - -/** - * Single element variant: fade + slide up with a cubic-bezier ease. - * Supports exit animation (fade out + slide up). - */ -export const fadeSlideUp: Variants = { - initial: { opacity: 0, y: 24 }, - animate: { - opacity: 1, - y: 0, - transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }, - }, - exit: { opacity: 0, y: -12, transition: { duration: 0.2 } }, -}; - -/** - * Simple fade variant for generic element transitions. - */ -export const fadeIn: Variants = { - initial: { opacity: 0 }, - animate: { opacity: 1, transition: { duration: 0.3 } }, - exit: { opacity: 0, transition: { duration: 0.2 } }, -}; - -/** - * Scale + fade variant for badges, pills, or small decorative elements. - */ -export const scaleIn: Variants = { - initial: { opacity: 0, scale: 0.8 }, - animate: { - opacity: 1, - scale: 1, - transition: { duration: 0.3, ease: "backOut" }, - }, -}; - -/** - * Spring-based entrance for important elements. - */ -export const springUp: Variants = { - initial: { opacity: 0, y: 30 }, - animate: { - opacity: 1, - y: 0, - transition: { type: "spring", stiffness: 200, damping: 20 }, - }, -}; diff --git a/services/frontend/src/shared/hooks/useItemDetail.ts b/services/frontend/src/shared/hooks/useItemDetail.ts deleted file mode 100644 index a14425c..0000000 --- a/services/frontend/src/shared/hooks/useItemDetail.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -/** - * Generic hook for fetching a single item by ID. - * Automatically refetches when guildId or entityId changes. - */ -export function useItemDetail( - fetchFn: (guildId: string, entityId: string) => Promise, - guildId: string, - entityId: string | null, - entityName = "item", -) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchFnRef = useRef(fetchFn); - fetchFnRef.current = fetchFn; - const guildIdRef = useRef(guildId); - guildIdRef.current = guildId; - const fetchIdRef = useRef(0); - - const fetch = useCallback(async () => { - if (!entityId) return; - const id = ++fetchIdRef.current; - - setLoading(true); - setError(null); - try { - const result = await fetchFnRef.current(guildIdRef.current, entityId); - if (id !== fetchIdRef.current) return; - if (!result) { - setError(`${entityName} not found`); - return; - } - setData(result); - } catch (e) { - if (id !== fetchIdRef.current) return; - const msg = - e instanceof Error ? e.message : `Failed to load ${entityName}`; - setError(msg); - } finally { - if (id === fetchIdRef.current) { - setLoading(false); - } - } - }, [entityId, entityName]); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - const refetch = useCallback(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { data, loading, error, refetch }; -} diff --git a/services/frontend/src/shared/hooks/useLocalStorage.ts b/services/frontend/src/shared/hooks/useLocalStorage.ts deleted file mode 100644 index b9122d3..0000000 --- a/services/frontend/src/shared/hooks/useLocalStorage.ts +++ /dev/null @@ -1,61 +0,0 @@ -// ─── Validated localStorage hook with shape checking ──────────────────────── -import { useCallback, useState } from "react"; - -interface ShapeValidator { - /** Returns true if the parsed value matches the expected shape */ - validate: (value: unknown) => value is T; - /** Default value when storage is empty or invalid */ - defaults: T; -} - -export function useLocalStorage(key: string, validator: ShapeValidator) { - const [value, setValue] = useState(() => loadStored(key, validator)); - - const update = useCallback( - (patch: T | ((prev: T) => T)) => { - setValue((prev) => { - const next = - typeof patch === "function" ? (patch as (prev: T) => T)(prev) : patch; - try { - localStorage.setItem(key, JSON.stringify(next)); - } catch { - // ignore quota errors - } - return next; - }); - }, - [key], - ); - - return { value, setValue: update }; -} - -function loadStored(key: string, validator: ShapeValidator): T { - try { - const raw = localStorage.getItem(key); - if (!raw) return validator.defaults; - const parsed = JSON.parse(raw) as unknown; - if (validator.validate(parsed)) return parsed; - return validator.defaults; - } catch { - return validator.defaults; - } -} - -// ─── Pre-built validators for common shapes ───────────────────────────────── - -export function recordValidator(): ShapeValidator> { - return { - validate: (v): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v), - defaults: {}, - }; -} - -export function uiStateValidator(): ShapeValidator> { - return { - validate: (v): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v), - defaults: { activeTab: "messages" }, - }; -} diff --git a/services/frontend/src/shared/hooks/useMascotChat.ts b/services/frontend/src/shared/hooks/useMascotChat.ts deleted file mode 100644 index 43eaf6f..0000000 --- a/services/frontend/src/shared/hooks/useMascotChat.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback, useState } from "react"; -import type { ChatResponse } from "../../entities/dashboard/types.js"; -import { request } from "../api/client"; -import { createLogger } from "../lib/logger"; - -const logger = createLogger("useMascotChat"); - -export interface ChatContext { - messageCount: number; - activeParticipants: number; - lastActivity: string; - topicsDiscussed: string[]; - guildId?: string; - channelId?: string; -} - -export function useMascotChat(context?: ChatContext) { - const [isOpen, setIsOpen] = useState(false); - - const handleSendMessage = useCallback( - async (message: string): Promise => { - try { - const data = await request("/api/chat", { - method: "POST", - body: JSON.stringify({ message, context }), - }); - return data.response || fallbackResponse(message, context); - } catch (error) { - logger.warn("Mascot backend unavailable, using fallback", { error }); - return fallbackResponse(message, context); - } - }, - [context], - ); - - return { - isOpen, - setIsOpen, - handleSendMessage, - }; -} - -function fallbackResponse(input: string, context?: ChatContext): string { - const lower = input.toLowerCase(); - - if (lower.includes("ringkasan") || lower.includes("summary")) { - return `Aku rangkum cepat ya ✨ Ada ${context?.messageCount || 0} pesan dari ${context?.activeParticipants || 0} user aktif. Backend belum bisa dihubungi, jadi ini ringkasan lokal sementara.`; - } - - if (lower.includes("berapa") && lower.includes("pesan")) { - return `Ada ${context?.messageCount || 0} pesan di konteks dashboard saat ini 📊`; - } - - if ( - lower.includes("berapa") && - (lower.includes("orang") || lower.includes("user")) - ) { - return `Ada ${context?.activeParticipants || 0} user aktif yang terdeteksi 👥`; - } - - return `Aku belum bisa menghubungi backend, tapi dari konteks lokal ada ${context?.messageCount || 0} pesan dan ${context?.activeParticipants || 0} user aktif. Coba tanya "ringkasan obrolan" atau "berapa pesan" ya.`; -} diff --git a/services/frontend/src/shared/hooks/usePaginatedList.ts b/services/frontend/src/shared/hooks/usePaginatedList.ts deleted file mode 100644 index 05b844c..0000000 --- a/services/frontend/src/shared/hooks/usePaginatedList.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -export interface PaginatedResult { - data: T[]; - nextCursor: string | null; -} - -export interface UsePaginatedListParams { - limit: number; - search?: string; - cursor?: string; - guildId?: string; -} - -/** - * Generic hook for fetching a paginated list with cursor-based pagination, - * search support, and automatic refetch when search/guildId changes. - */ -export function usePaginatedList( - fetchFn: (params: UsePaginatedListParams) => Promise>, - guildId = "", - initialSearch = "", -) { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - const [search, setSearch] = useState(initialSearch); - - // Stable refs so fetch() can be a stable callback that reads latest values - const fetchFnRef = useRef(fetchFn); - fetchFnRef.current = fetchFn; - const guildIdRef = useRef(guildId); - guildIdRef.current = guildId; - const searchRef = useRef(search); - searchRef.current = search; - const fetchIdRef = useRef(0); - - const fetch = useCallback(async (cursor?: string) => { - const id = ++fetchIdRef.current; - - if (cursor) { - setLoadingMore(true); - } else { - setLoading(true); - setData([]); - } - setError(null); - - try { - const result = await fetchFnRef.current({ - limit: 20, - search: searchRef.current || undefined, - cursor, - guildId: guildIdRef.current || undefined, - }); - if (id !== fetchIdRef.current) return; - - if (cursor) { - setData((prev) => [...prev, ...result.data]); - } else { - setData(result.data); - } - setNextCursor(result.nextCursor); - } catch (e) { - if (id !== fetchIdRef.current) return; - const msg = e instanceof Error ? e.message : "Failed to load data"; - setError(msg); - } finally { - if (id === fetchIdRef.current) { - setLoading(false); - setLoadingMore(false); - } - } - }, []); - - // Re-fetch on mount, search change, or guildId change - useEffect(() => { - fetch().catch(() => undefined); - }, [search, guildId, fetch]); - - const loadMore = useCallback(() => { - if (nextCursor && !loading && !loadingMore) { - fetch(nextCursor).catch(() => undefined); - } - }, [nextCursor, loading, loadingMore, fetch]); - - const refetch = useCallback(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { - data, - loading, - loadingMore, - error, - nextCursor, - search, - setSearch, - hasMore: !!nextCursor, - loadMore, - refetch, - }; -} diff --git a/services/frontend/src/shared/hooks/useTheme.ts b/services/frontend/src/shared/hooks/useTheme.ts deleted file mode 100644 index 9b8263c..0000000 --- a/services/frontend/src/shared/hooks/useTheme.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark' | 'system'; -const STORAGE_KEY = 'imphnen-theme'; - -function getSystemTheme(): 'light' | 'dark' { - if (typeof window === 'undefined') return 'light'; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -} - -function applyTheme(resolved: 'light' | 'dark') { - const root = document.documentElement; - const transitioning = root.classList.contains('theme-transitioning'); - if (!transitioning) root.classList.add('theme-transitioning'); - root.dataset.theme = resolved; - if (!transitioning) { - requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-transitioning'))); - } -} - -export function useTheme() { - const [theme, setThemeState] = useState(() => { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === 'light' || stored === 'dark' || stored === 'system') return stored; - return 'system'; - }); - const resolvedTheme = theme === 'system' ? getSystemTheme() : theme; - const setTheme = useCallback((t: Theme) => { setThemeState(t); localStorage.setItem(STORAGE_KEY, t); }, []); - const toggle = useCallback(() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), [resolvedTheme, setTheme]); - useEffect(() => { applyTheme(resolvedTheme); }, [resolvedTheme]); - useEffect(() => { - if (theme !== 'system') return; - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = () => applyTheme(getSystemTheme()); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, [theme]); - return { theme, setTheme, resolvedTheme, toggle }; -} diff --git a/services/frontend/src/shared/hooks/useUIState.ts b/services/frontend/src/shared/hooks/useUIState.ts deleted file mode 100644 index 2ad1acc..0000000 --- a/services/frontend/src/shared/hooks/useUIState.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useCallback } from "react"; -import type { UIState } from "../../entities/ui/types.js"; -import { uiStateValidator, useLocalStorage } from "./useLocalStorage"; - -export function useUIState() { - const { value: uiState, setValue: setUIState } = useLocalStorage( - "bete-dashboard-ui-state", - uiStateValidator(), - ); - - const patchUIState = useCallback( - (patch: Partial) => { - setUIState((prev) => ({ ...prev, ...patch })); - }, - [setUIState], - ); - - return { uiState, setUIState, patchUIState, loading: false, error: null }; -} diff --git a/services/frontend/src/shared/lib/logger.ts b/services/frontend/src/shared/lib/logger.ts deleted file mode 100644 index b031cd1..0000000 --- a/services/frontend/src/shared/lib/logger.ts +++ /dev/null @@ -1,22 +0,0 @@ -// ─── Client-side structured logger ──────────────────────────────────────── - -const LOG_PREFIX = "[Bete]"; - -export function createLogger(context: string) { - const prefix = `${LOG_PREFIX} [${context}]`; - - return { - debug: (msg: string, data?: Record) => { - if (import.meta.env.DEV) console.debug(prefix, msg, data ?? ""); - }, - info: (msg: string, data?: Record) => { - console.info(prefix, msg, data ?? ""); - }, - warn: (msg: string, data?: Record) => { - console.warn(prefix, msg, data ?? ""); - }, - error: (msg: string, data?: Record) => { - console.error(prefix, msg, data ?? ""); - }, - }; -} diff --git a/services/frontend/src/shared/lib/utils.ts b/services/frontend/src/shared/lib/utils.ts deleted file mode 100644 index cf8ff05..0000000 --- a/services/frontend/src/shared/lib/utils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -export function formatBytes(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`; -} - -export function formatDate(value: number): string { - return new Date(value).toLocaleString(); -} - -export interface MessageMetadata { - stickers?: Array<{ name?: string; url?: string }>; - attachments?: Array<{ name: string; url: string; contentType?: string }>; - embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; - channel?: { - channelId: string; - channelName?: string; - threadId?: string; - threadName?: string; - }; - reference?: { - messageId: string | null; - channelId: string | null; - guildId: string | null; - type: string | null; - content: string | null; - repliedUsername: string | null; - repliedUserId: string | null; - } | null; - isCrosspost?: boolean; -} - -export function parseMetadata(value: string | null): MessageMetadata { - if (!value) return {}; - try { - const parsed = JSON.parse(value) as MessageMetadata; - return parsed; - } catch { - return {}; - } -} diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx deleted file mode 100644 index fb1a3a6..0000000 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { motion } from "framer-motion"; -import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; -import type { DashboardTab } from "../../entities/ui/types.js"; -import { cn } from "../lib/utils"; - -const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [ - { id: "messages", label: "Messages", Icon: MessageSquare }, - { id: "live", label: "Voice & Media", Icon: Radio }, - { id: "dashboard", label: "Dashboard", Icon: LayoutDashboard }, -]; - -interface MobileTabBarProps { - activeTab: DashboardTab; - onTabChange: (tab: DashboardTab) => void; -} - -export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { - return ( - - ); -} diff --git a/services/frontend/src/shared/ui/badge.tsx b/services/frontend/src/shared/ui/badge.tsx deleted file mode 100644 index a486d69..0000000 --- a/services/frontend/src/shared/ui/badge.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Badge — Pill untuk status, kategori, dan label micro-interaction - * rounded-full (9999px), padding 4px 12px, font label-sm (12px, 500 weight) - * ═══════════════════════════════════════════════════════════════════════════ */ - -import type * as React from "react"; -import { cn } from "../lib/utils"; - -type BadgeVariant = - | "default" /* Primary soft — #e1f0fd bg, #0d4a7a text */ - | "primary" /* Same as default, explicit alias */ - | "secondary" /* #e7f1ff bg, #003d99 text */ - | "tertiary" /* #eef0ff bg, #1a2466 text */ - | "destructive" /* #ffebee bg, #e4405f text */ - | "outline" /* Border only, no fill */ - | "success" /* #dcfce7 bg, green text */ - | "warning" /* #fef3c7 bg, amber text */ - | "info"; /* #dbeafe bg, blue text */ - -const variants: Record = { - default: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent", - primary: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent", - secondary: "bg-[#e7f1ff] text-[#003d99] border-transparent", - tertiary: "bg-[#eef0ff] text-[#1a2466] border-transparent", - destructive: "bg-[#ffebee] text-[#e4405f] border-transparent", - outline: "bg-transparent text-[#666666] border-[#e0e0e0]", - success: "bg-success-soft text-success border-transparent", - warning: "bg-warning-soft text-warning border-transparent", - info: "bg-[#dbeafe] text-[#1e40af] border-transparent", -}; - -export interface BadgeProps extends React.HTMLAttributes { - variant?: BadgeVariant; -} - -export function Badge({ - className, - variant = "default", - ...props -}: BadgeProps) { - return ( -
- ); -} diff --git a/services/frontend/src/shared/ui/button.tsx b/services/frontend/src/shared/ui/button.tsx deleted file mode 100644 index 28682b8..0000000 --- a/services/frontend/src/shared/ui/button.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Button — Friendly, percaya diri, responsif. - * Primary: #23a1eb → #1a8fd9 → #0877c1 - * Secondary: transparan dengan 1px border, fill subtle di hover - * ═══════════════════════════════════════════════════════════════════════════ */ - -import { Slot } from "@radix-ui/react-slot"; -import type * as React from "react"; -import { cn } from "../lib/utils"; - -type ButtonVariant = - | "default" /* Primary IMPHNEN blue */ - | "secondary" /* Outline with subtle fill */ - | "tertiary" /* Discord-style blurple */ - | "destructive"/* Red semantic */ - | "outline" /* Light border, no fill */ - | "ghost" /* No border, fill on hover */ - | "link"; /* Text-only */ -type ButtonSize = "default" | "sm" | "lg" | "icon" | "icon-sm"; - -const variants: Record = { - default: - "bg-[#23a1eb] text-white shadow-sm " + - "hover:bg-[#1a8fd9] " + - "active:bg-[#0877c1] " + - "focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40", - secondary: - "bg-transparent text-[#23a1eb] border border-[#e0e0e0] " + - "hover:bg-[#f0f0f0] hover:border-[#23a1eb] " + - "active:bg-[#e1f0fd] " + - "focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40", - destructive: - "bg-[#e4405f] text-white shadow-sm " + - "hover:bg-[#d63856] " + - "active:bg-[#c2304d] " + - "focus-visible:ring-2 focus-visible:ring-[#e4405f]/40", - outline: - "bg-transparent text-[#1a1a1a] border border-[#e0e0e0] " + - "hover:bg-[#f0f0f0] hover:text-[#23a1eb] " + - "active:bg-[#e1f0fd] " + - "focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40", - ghost: - "bg-transparent text-[#1a1a1a] " + - "hover:bg-[#f0f0f0] hover:text-[#23a1eb] " + - "active:bg-[#e1f0fd] " + - "focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40", - link: - "bg-transparent text-[#23a1eb] underline-offset-4 " + - "hover:underline " + - "active:text-[#0877c1]", - tertiary: - "bg-[#5865f2] text-white shadow-sm " + - "hover:bg-[#5865f2]/90 " + - "focus-visible:ring-2 focus-visible:ring-[#5865f2]/40", -}; - -const sizes: Record = { - default: "h-11 px-6 py-3", /* 44px height, 24px horizontal */ - sm: "h-9 rounded-lg px-3 py-2", /* 36px compact */ - lg: "h-12 rounded-lg px-8 py-3", /* 48px spacious */ - icon: "h-11 w-11", /* Square 44x44 */ - 'icon-sm': 'h-8 w-8', /* Square 32x32 */ -}; - -export interface ButtonProps - extends React.ButtonHTMLAttributes { - asChild?: boolean; - variant?: ButtonVariant; - size?: ButtonSize; -} - -export function Button({ - className, - variant = "default", - size = "default", - asChild = false, - disabled, - ...props -}: ButtonProps) { - const Comp = asChild ? Slot : "button"; - return ( - - ); -} diff --git a/services/frontend/src/shared/ui/card.tsx b/services/frontend/src/shared/ui/card.tsx deleted file mode 100644 index 626a51b..0000000 --- a/services/frontend/src/shared/ui/card.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Card — Primary content container - * rounded-xl (1.5rem), border subtle, shadow-sm default → shadow-md hover - * ═══════════════════════════════════════════════════════════════════════════ */ - -import type * as React from "react"; -import { cn } from "../lib/utils"; - -type CardVariant = 'default' | 'elevated' | 'bordered'; - -const variantClasses: Record = { - default: 'shadow-sm hover:shadow-md', - elevated: 'shadow-md hover:shadow-lg', - bordered: 'shadow-none border-2', -}; - -export function Card({ - className, - variant = 'default', - ...props -}: React.HTMLAttributes & { variant?: CardVariant }) { - return ( -
- ); -} - -export function CardHeader({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
- ); -} - -export function CardTitle({ - className, - ...props -}: React.HTMLAttributes) { - return ( -

- ); -} - -export function CardDescription({ - className, - ...props -}: React.HTMLAttributes) { - return ( -

- ); -} - -export function CardContent({ - className, - ...props -}: React.HTMLAttributes) { - return

; -} - -export function CardFooter({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
- ); -} diff --git a/services/frontend/src/shared/ui/empty-state.tsx b/services/frontend/src/shared/ui/empty-state.tsx deleted file mode 100644 index 6ec58f6..0000000 --- a/services/frontend/src/shared/ui/empty-state.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { motion } from 'framer-motion'; -import type { LucideIcon } from 'lucide-react'; -import { Inbox } from 'lucide-react'; -import type { ReactNode } from 'react'; -import { cn } from '../lib/utils'; - -interface EmptyStateProps { - icon?: LucideIcon; - title?: string; - description?: string; - action?: ReactNode; - className?: string; - compact?: boolean; -} - -const fadeSlideUp = { - initial: { opacity: 0, y: 20 }, - animate: { - opacity: 1, - y: 0, - transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const }, - }, -}; - -export function EmptyState({ - icon: Icon = Inbox, - title, - description, - action, - className, - compact = false, -}: EmptyStateProps) { - return ( - -
- -
- {title &&

{title}

} - {description && ( -

{description}

- )} - {action &&
{action}
} -
- ); -} diff --git a/services/frontend/src/shared/ui/index.ts b/services/frontend/src/shared/ui/index.ts deleted file mode 100644 index 980ea66..0000000 --- a/services/frontend/src/shared/ui/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -// ─── Shared UI barrel export ──────────────────────────────────────────────── - -export { Badge } from "./badge"; -export { Button } from "./button"; -export { EmptyState } from "./empty-state"; -export { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "./card"; -export { Input } from "./input"; -export type { - ProfileDetailMessage, - ProfileDetailStats, -} from "./profile-detail"; -export { ProfileDetail } from "./profile-detail"; -export { ScrollArea } from "./scroll-area"; -export { Select } from "./select"; -export { Skeleton } from "./skeleton"; -export type { StatusType } from "./status-badge"; -export { StatusBadge } from "./status-badge"; -export type { SummaryItem } from "./summary-list"; -export { SummaryList } from "./summary-list"; -export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; -export { ToastProvider, useToast } from "./toast"; diff --git a/services/frontend/src/shared/ui/input.tsx b/services/frontend/src/shared/ui/input.tsx deleted file mode 100644 index 0ce92c0..0000000 --- a/services/frontend/src/shared/ui/input.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Input — Clean, approachable, dengan focus glow signature - * rounded DEFAULT (0.5rem), bg #f0f0f0, border #e0e0e0 - * Focus: border #23a1eb + 3px glow - * ═══════════════════════════════════════════════════════════════════════════ */ - -import type * as React from "react"; -import { cn } from "../lib/utils"; - -export interface InputProps - extends React.InputHTMLAttributes { - errorId?: string; -} - -type InputVariant = 'default' | 'soft'; - -const variantClasses: Record = { - default: 'border border-[#e0e0e0] bg-white', - soft: 'border-transparent bg-[#f5f5f5] focus-visible:border-[#23a1eb]', -}; - -export function Input({ className, type, errorId, variant = 'default', ...props }: InputProps & { variant?: InputVariant }) { - return ( - - ); -} diff --git a/services/frontend/src/shared/ui/profile-detail.tsx b/services/frontend/src/shared/ui/profile-detail.tsx deleted file mode 100644 index effb68a..0000000 --- a/services/frontend/src/shared/ui/profile-detail.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { ArrowLeft, RefreshCw } from "lucide-react"; -import { type ReactNode } from "react"; -import { cn } from "../lib/utils"; -import { Card, CardContent } from "./card"; -import { Skeleton } from "./skeleton"; -import { StatusBadge, type StatusType } from "./status-badge"; - -export interface ProfileDetailStats { - totalLabel: string; - totalValue: number; - cleanLabel: string; - cleanValue: number; - flaggedLabel: string; - flaggedValue: number; -} - -export interface ProfileDetailMessage { - id: string; - content: string | null; - created_at: string | null; - ai_status: string | null; -} - -interface ProfileDetailProps { - loading: boolean; - error: string | null; - onRetry: () => void; - onBack: () => void; - icon: ReactNode; - title: string; - subtitle?: string; - summaryLabel: string; - summaryText?: string; - lastAnalyzedLabel?: string; - stats: ProfileDetailStats; - messages: ProfileDetailMessage[]; - messagesTitle?: string; - messagesEmptyText?: string; - className?: string; -} - -function DetailSkeleton() { - return ( -
- - - -
- -
- - -
-
-
-
-
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
-
- ); -} - -export function ProfileDetail({ - loading, - error, - onRetry, - onBack, - icon, - title, - subtitle, - summaryLabel, - summaryText, - lastAnalyzedLabel, - stats, - messages, - messagesTitle = "Recent Messages", - messagesEmptyText = "No messages found", - className, -}: ProfileDetailProps) { - if (loading) return ; - - if (error) { - return ( -
-

{error}

- -
- ); - } - - return ( -
- {/* Back button */} - - - {/* Header */} - - -
-
- {icon} -
-
-

{title}

- {subtitle && ( -

{subtitle}

- )} -

- {summaryLabel}: {summaryText ?? "N/A"} -

- {lastAnalyzedLabel && ( -

- {lastAnalyzedLabel} -

- )} -
-
-
-
- - {/* Stats */} -
- - -

- {stats.totalLabel} -

-

- {stats.totalValue} -

-
-
- - -

- {stats.cleanLabel} -

-

- {stats.cleanValue} -

-
-
- - -

- {stats.flaggedLabel} -

-

- {stats.flaggedValue} -

-
-
-
- - {/* Recent Messages */} -
-

- {messagesTitle} -

- {messages.length === 0 ? ( -

- {messagesEmptyText} -

- ) : ( -
- {messages.map((msg) => ( - - -
-

- {msg.content ?? "(no content)"} -

- -
- {msg.created_at && ( -

- {new Date(msg.created_at).toLocaleString()} -

- )} -
-
- ))} -
- )} -
-
- ); -} diff --git a/services/frontend/src/shared/ui/scroll-area.tsx b/services/frontend/src/shared/ui/scroll-area.tsx deleted file mode 100644 index df8fa51..0000000 --- a/services/frontend/src/shared/ui/scroll-area.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN ScrollArea — Radix-based, scrollbar dengan primary accent - * ═══════════════════════════════════════════════════════════════════════════ */ - -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import type * as React from "react"; -import { cn } from "../lib/utils"; - -export function ScrollArea({ - className, - children, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - - {children} - - - - - ); -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentPropsWithoutRef< - typeof ScrollAreaPrimitive.ScrollAreaScrollbar ->) { - return ( - - - - ); -} diff --git a/services/frontend/src/shared/ui/select.tsx b/services/frontend/src/shared/ui/select.tsx deleted file mode 100644 index 618f813..0000000 --- a/services/frontend/src/shared/ui/select.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Select — Native select dengan styling IMPHNEN - * ═══════════════════════════════════════════════════════════════════════════ */ - -import type * as React from "react"; -import { cn } from "../lib/utils"; - -export interface SelectOption { - value: string; - label: string; -} - -export interface SelectProps - extends React.SelectHTMLAttributes { - options: SelectOption[]; - placeholder?: string; -} - -export function Select({ - className, - options, - placeholder, - ...props -}: SelectProps) { - return ( - - ); -} diff --git a/services/frontend/src/shared/ui/skeleton.tsx b/services/frontend/src/shared/ui/skeleton.tsx deleted file mode 100644 index 033f38e..0000000 --- a/services/frontend/src/shared/ui/skeleton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* ═══════════════════════════════════════════════════════════════════════════ - * IMPHNEN Skeleton — Loading state yang subtle & smooth - * ═══════════════════════════════════════════════════════════════════════════ */ - -import type { HTMLAttributes } from "react"; -import { cn } from "../lib/utils"; - -type SkeletonVariant = "rounded" | "circular" | "rectangular"; - -const variantClasses: Record = { - rounded: "rounded-lg", - circular: "rounded-full", - rectangular: "rounded-none", -}; - -export function Skeleton({ - variant = "rounded", - className, - ...props -}: HTMLAttributes & { variant?: SkeletonVariant }) { - return ( -