From ce899e9c56cd38cfe304226ff93b1831f4655c88 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 31 Jul 2026 16:34:23 +0700 Subject: [PATCH] refactor: remove outdated frontend and services specifications - Deleted the frontend refactor design document to streamline project scope. - Removed the services refactoring design document to eliminate redundancy. - Eliminated the visual redesign document as part of the cleanup process. - Purged the Discord Automod redesign document to focus on current objectives. --- CLAUDE.md | 695 ---- .../plans/2026-07-27-cicd-overhaul.md | 542 --- .../2026-07-27-frontend-refactor-plan.md | 1346 ------- .../2026-07-27-refactor-backend-gateway-p1.md | 736 ---- .../plans/2026-07-27-services-refactoring.md | 579 --- .../2026-07-28-discord-automod-redesign.md | 3581 ----------------- .../specs/2026-07-27-cicd-overhaul-design.md | 455 --- .../2026-07-27-frontend-refactor-design.md | 154 - .../2026-07-27-services-refactoring-design.md | 134 - .../specs/2026-07-27-visual-redesign.md | 37 - .../2026-07-28-discord-automod-redesign.md | 508 --- 11 files changed, 8767 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/superpowers/plans/2026-07-27-cicd-overhaul.md delete mode 100644 docs/superpowers/plans/2026-07-27-frontend-refactor-plan.md delete mode 100644 docs/superpowers/plans/2026-07-27-refactor-backend-gateway-p1.md delete mode 100644 docs/superpowers/plans/2026-07-27-services-refactoring.md delete mode 100644 docs/superpowers/plans/2026-07-28-discord-automod-redesign.md delete mode 100644 docs/superpowers/specs/2026-07-27-cicd-overhaul-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-frontend-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-services-refactoring-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-visual-redesign.md delete mode 100644 docs/superpowers/specs/2026-07-28-discord-automod-redesign.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index dc17f09..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,695 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**Bete (Discord Moderation Watcher)** — A comprehensive microservice-based Discord monitoring and moderation bot. Captures text messages, images, voice audio, and screenshares from Discord servers. Features AI-powered content moderation with auto-delete, voice recording with real-time streaming, music playback, and a React dashboard. - -Built with **pnpm workspace monorepo** with 3 services and 1 shared library: - -| Package | Path | Description | -|---------|------|-------------| -| `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 | -| `frontend` | `services/frontend` | Next.js 16 (React 19) static dashboard, Tailwind v4, shadcn/ui | -| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities | - -**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite. - -**Inter-service communication:** Redis pub/sub. - -## Architecture - -### High-Level Flow - -``` -Discord - | - v -discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend - | pub/sub (broadcast) (Next.js static) - | | - | | - <------------------+ - (command channel) -``` - -1. **discord-gateway** connects to Discord via `discord.js-selfbot-v13`, captures events (messages, voice, attachments), stores in PostgreSQL, and publishes events to Redis channels (e.g., `discord:message:created`, `discord:voice:pcm`). - -2. **backend** subscribes to Redis channels, broadcasts events to WebSocket clients, and serves REST API endpoints. - -3. **frontend** connects via WebSocket and HTTP to the backend, provides a dashboard for live monitoring (text, voice, media) and AI moderation oversight. - -4. **Command flow (reverse):** Frontend -> Backend HTTP/WS -> Redis (`backend:command`) -> discord-gateway (command handler) - for actions like connect voice, play media, moderate message. - -### Data Flow - -``` -Message Capture: - Discord -> messageCapture.ts -> messageStore.ts (PostgreSQL) - | - +> eventBroadcaster -> Redis -> backend -> WS clients - -Voice Recording: - Discord -> voiceController.ts -> recorder.ts -> OGG files on disk - | | - +> eventBroadcaster +> decoder.ts -> PCM -> Redis -> WS clients - -AI Moderation: - messageStore -> aiAnalyzer.ts -> LLM API -> moderation result - | | - +> eventBroadcaster +> update message in DB -``` - -## Service Breakdown - -### backend (`services/backend`) - -Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default). - -**REST API endpoints (all public):** -- `GET /api/health` — Health check with optional `?verbose=true` -- `GET /api/config` — App configuration -- `GET /api/messages` — List messages (cursor pagination) -- `GET /api/messages/:channelId` — Messages by channel -- `GET /api/messages/:channelId/attachments` — Attachments by channel -- `GET /api/messages/detail/:id` — Single message -- `POST /api/messages/reanalyze-batch` — Bulk retry AI analysis -- `POST /api/messages/:id/reanalyze` — Retry single message -- `POST /api/messages/:id/moderate` — Dispatch moderation action -- `GET /api/review` — Flagged/warned messages -- `GET /api/analysis/search` — Full-text search with `?q=` -- `POST /api/chat` — AI chatbot chat -- `GET /api/chat/history` — Chat history -- `POST /api/chat/clear` — Clear chat history -- `POST /api/voice/command` — Send voice transmit commands -- `GET /api/status` — Voice connection status -- `POST /api/connect` — Connect to voice channel -- `POST /api/disconnect` — Disconnect from voice -- `GET /api/guilds` — List guilds -- `GET /api/guilds/:guildId/channels` — Text channels -- `GET /api/guilds/:guildId/voice-channels` — Voice channels -- `GET /api/media/status` — Media player status -- `POST /api/media/queue` — Queue media (music/screen) -- `POST /api/media/skip` — Skip current track -- `POST /api/media/stop` — Stop playback -- `POST /api/media/volume` — Set volume -- `GET /api/recordings` — Voice recordings list -- `GET /api/ui-state` — Get persistent UI state -- `POST /api/ui-state` — Save UI state - -**Modules (feature-based, under `src/modules/`):** -- `health/` — Database connectivity check -- `messages/` — Message + attachment CRUD, review, reanalyze -- `voice/` — Voice connection, guilds, channels -- `media/` — Music/screenshare player control -- `analysis/` — Full-text search across analyzed messages -- `chatbot/` — AI chatbot with server context -- `recordings/` — Voice recording listing -- `ui-state/` — Persistent UI state for dashboard -- `config/` — App config endpoint - -**WebSocket events (outbound to frontend):** -- `message_created`, `message_updated`, `message_deleted`, `message_analyzed` -- `attachment_created`, `attachment_uploaded` -- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded` -- `voice_active_user`, `voice_pcm_data` -- `analysis_queue_status` -- `user_state`, `ui_state`, `media_state` -- `heartbeat` (every 30s) - -**WebSocket inbound (from frontend):** -- JSON `{ type: "voice_transmit", buffer: "" }` — forwarded to Redis -- JSON `{ type: "voice_command", command: "..." }` — forwarded to discord-gateway - -### discord-gateway (`services/discord-gateway`) - -The core service that connects to Discord using `discord.js-selfbot-v13`. - -**Modules:** - -- **`message-capture/`** — Listens to `messageCreate`, `messageUpdate`, `messageDelete` events. Stores messages in PostgreSQL. Handles edits, deletes, and backlog sync. - - `messageCapture.ts` — Event listeners - - `messageStore.ts` — Database operations (upsert, update, delete) - - `messageMetadata.ts` — User/channel metadata extraction - - `broadcaster.ts` — Internal event dispatch - - `pagination.ts` — Backlog sync for historical messages - - `analyticsStore.ts` — Per-channel analytics tracking - -- **`voice-recording/`** — Voice channel connection, recording, and real-time PCM streaming. - - `voiceController.ts` — Connection lifecycle (connect/disconnect per guild+channel) - - `recorder.ts` — Manages speaking users, subscribes to audio streams - - `recorder/audioStream.ts` — Opus packet subscription per user - - `recorder/decoder.ts` — Opus to PCM decoding with rotation/cooldown - - `recorder/segment.ts` — OGG file segment rotation (default 5s) - - `recorder/metadata.ts` — User metadata JSON for each segment - - `recorder/sessionRecording.ts` — Session-scoped recording management - - `recorder/uploader.ts` — Upload completed segments - - `player.ts` — Discord player (music/screenshare playback) - - `transmitter.ts` — Browser-to-Discord audio transmission (Redis -> Opus -> Discord) - - `muxer.ts` — Audio muxing logic - - `packetFilter.ts` — Opus packet filtering - - `ffmpegProcess.ts` — FFmpeg-based processing - - `mediaTypes.ts` — Audio/video format definitions - - `teleUpload.ts` — Upload to tele/picser - -- **`attachment-upload/`** — Downloads Discord attachments, uploads to external service. - - `attachmentUploader.ts` — Download + upload with retry - - `imageResizer.ts` — Resize images before upload - - `teleUpload.ts` — Upload to tele/picser API - -- **`ai-moderation/`** — AI-powered content moderation pipeline. - - `aiAnalyzer.ts` — Analysis worker (batch + individual fallback) - - `aiAnalysisWorker.ts` — Piscina worker thread for batch processing - - `llmClient.ts` — Generic LLM API client - - `llmModerationClient.ts` — Moderation-specific LLM client - - `moderationPrompt.ts` — System prompt builder with few-shot - - `autoDeleteManager.ts` — Auto-delete flagged messages - - `conversationContext.ts` — Conversation window builder - - `concurrencyLimiter.ts` — Rate limiter for LLM calls - - `channelCultureStore.ts` — Channel norms/slang context - - `cultureLearner.ts` — Learn channel culture over time - - `userReputationStore.ts` — User trust scores - - `textCacheStore.ts` — Deduplicate repeated text analysis - - `stickerCache.ts` — Upload and cache sticker images - - `stickerPrompt.ts` — Sticker analysis prompt - - `urlFetcher.ts` — Fetch URL content for analysis - - `responseLogger.ts` — Log moderation responses - -- **`event-broadcaster/`** — Redis pub/sub publisher for all events. - - `eventBroadcaster.ts` — `EventBroadcaster` class with typed methods - - `eventTypes.ts` — Channel constants and event interfaces - -- **`command-handler/`** — Listens on `backend:command` Redis channel for backend requests. - - `commandHandler.ts` — Handles voice connect/disconnect, guilds, channels, media, transmit - -**Infrastructure:** -- `src/shared/config/config.ts` — Zod-validated env config (DISCORD_TOKEN, REDIS_URL, AI_LLM_*, etc.) -- `src/shared/database/schema.ts` — Full PostgreSQL schema definition -- `src/shared/database/drizzle.ts` — Drizzle + pg pool initialization -- `src/shared/database/migrate.ts` — Migration runner with advisory locking -- `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries -- `src/shared/discord/clientOptions.ts` — Discord client configuration - -### frontend (`services/frontend`) - -Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind v4 + shadcn/ui + base-ui. - -**Tech stack:** -- Next.js 16 (App Router, static export) -- React 19 with React Compiler -- TypeScript strict -- Tailwind v4 + shadcn/ui + base-ui components -- lucide-react icons - -**Feature structure:** -- `src/app/` — App Router pages (login, dashboard with tabs) -- `src/features/` — Feature components (dashboard, messages, live, chatbot) -- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks) -- `src/components/` — Shared UI components (layout, ui) - -### shared (`packages/shared`) - -Shared library used by both backend and discord-gateway. - -**Exports:** -- `@bete/shared` — Everything below -- `@bete/shared/types` — AppConfig, MessageRecord, AttachmentRecord, VoiceSegment, etc. -- `@bete/shared/errors` — AppError, ValidationError, NotFoundError, UnauthorizedError, DatabaseError, ConfigError, DiscordError, TimeoutError, etc. -- `@bete/shared/logger` — Pino-based `createChildLogger(context)` -- `@bete/shared/utils` — Shared utilities - -## Database Schema (PostgreSQL) - -All tables defined in `services/discord-gateway/src/shared/database/schema.ts`. - -### messages -Stores text messages with AI moderation results. -- `id` (text PK), `guild_id`, `channel_id`, `thread_id` -- `user_id`, `username`, `avatar_url` -- `content`, `edited_content`, `type` (text|edited|deleted) -- `created_at`, `edited_at`, `deleted_at` -- `ai_status` (pending|processing|clean|warn|flagged|error) -- `ai_moderation_flags`, `ai_moderation_score`, `ai_analysis`, `ai_categories` -- `ai_severity` (none|low|medium|high|critical), `ai_confidence` -- `ai_recommended_action` (none|monitor|warn|review|delete|escalate) -- `ai_analyzed_at`, `ai_error`, `metadata` -- Indexes: channel, user, created_at, thread, channel+created, thread+created, ai_status+created, guild+ai_status+created, guild+created+deleted, channel+ai_status+created, thread+ai_status+created - -### attachments -Discord attachment metadata with upload tracking. -- `id` (text PK), `message_id` (FK -> messages cascade), `guild_id`, `channel_id` -- `filename`, `size`, `type` (MIME), `discord_url`, `uploaded_url` -- `upload_status` (pending|uploaded|failed), `upload_error` -- `created_at`, `uploaded_at` -- Indexes: channel, message, upload_status, channel+created, thread+created - -### voice_recordings -Voice segment metadata. -- `id` (text PK), `user_id`, `username`, `avatar_url` -- `guild_id`, `channel_id`, `channel_name` -- `filename`, `size_bytes`, `download_url` -- `upload_status` (pending|uploaded|failed), `upload_error` -- `created_at`, `uploaded_at` -- Indexes: user_id, channel_id, created_at - -### ui_state -Persistent dashboard UI state (key-value). -- `key` (text PK), `value` (text), `updated_at` - -### ai_analysis_runs -Tracks AI analysis batch runs. -- `id` (text PK), `conversation_key`, `target_message_ids` (JSON) -- `model`, `request_tokens_estimate`, `response_raw` -- `status` (pending|processing|completed|failed), `error` -- `created_at`, `completed_at` -- Indexes: conversation_key, status, created_at - -### user_reputations -User trust scores for AI context. -- `user_id` (text PK), `guild_id`, `trust_score`, `clean_message_streak` -- `total_infractions`, `last_infraction_at`, `created_at`, `updated_at` -- Indexes: guild_id, trust_score - -### channel_cultures -AI-generated channel norms and slang summaries. -- `channel_id` (text PK), `guild_id`, `culture_summary`, `last_analyzed_at` -- Index: guild_id - -### message_reviews -Manual review tracking for flagged messages. -- `id` (text PK), `message_id`, `guild_id`, `channel_id` -- `reviewer_id`, `status` (pending|approved|rejected|escalated) -- `notes`, `created_at`, `reviewed_at` -- Indexes: message_id, status, created_at, guild+status+created - -### moderation_actions -Action audit log (delete/mute/warn/kick/ban). -- `id` (text PK), `message_id`, `user_id`, `guild_id` -- `action_type` (delete_message|mute_user|warn_user|kick_user|ban_user) -- `reason`, `executed_by`, `status` (pending|executed|failed) -- `error`, `created_at`, `executed_at` -- Indexes: message_id, user_id, status, guild+status+created - -### retention_policies -Data retention rules per guild/channel. -- `id` (text PK), `guild_id`, `channel_id` -- `retention_days`, `apply_to_media`, `apply_to_voice`, `enabled` -- `created_at`, `updated_at` -- Indexes: guild_id, enabled - -### text_analysis_cache -Caches normalized-text moderation results to avoid redundant LLM calls. -- `text` (text PK), `flags` (JSON array), `source` (local|primary_ai|vision_llm) -- `analyzed_at`, `expires_at`, `hit_count` -- Indexes: expires_at, source - -### sticker_cache -Uploaded sticker image URLs for vision analysis. -- `name` (text PK), `image_url`, `mime_type`, `fetched_at` -- Index: fetched_at - -### corrected_moderations -Manual corrections (false positives) for few-shot injection. -- `id` (text PK), `message_id`, `original_flags`, `corrected_flags` -- `correction_notes`, `content_snippet`, `created_at` -- Indexes: created_at, message_id - -### muxer_jobs -Audio post-processing job queue. -- `id` (text PK), `data` (JSON), `status` (pending|processing|completed|failed) -- `attempts`, `maxAttempts`, `created_at`, `updated_at`, `error` -- Indexes: status, created_at - -## Redis Communication - -### discord-gateway publishes (event channels): -| Channel | Event type | When | -|---------|-----------|------| -| `discord:message:created` | `message_created` | New message | -| `discord:message:updated` | `message_updated` | Message edited | -| `discord:message:deleted` | `message_deleted` | Message deleted | -| `discord:message:analyzed` | `message_analyzed` | AI analysis complete | -| `discord:attachment:created` | `attachment_created` | New attachment | -| `discord:attachment:uploaded` | `attachment_uploaded` | Upload complete | -| `discord:voice:started` | `voice_recording_started` | Recording started | -| `discord:voice:stopped` | `voice_recording_stopped` | Recording stopped | -| `discord:voice:uploaded` | `voice_recording_uploaded` | Upload complete | -| `discord:voice:active_user` | `voice_active_user` | Speaker state change | -| `discord:voice:pcm` | `voice_pcm_data` | Live PCM audio chunk | -| `discord:analysis:queue_status` | `analysis_queue_status` | Queue stats | - -### backend publishes (command channel): -| Channel | Command type | Description | -|---------|-------------|-------------| -| `backend:command` | `voice:connect` | Connect to voice | -| `backend:command` | `voice:disconnect` | Disconnect voice | -| `backend:command` | `voice:channels` | List voice channels | -| `backend:command` | `voice:transmit:start/stop` | Audio transmit | -| `backend:command` | `guilds:list` | List guilds | -| `backend:command` | `guilds:text-channels` | List text channels | -| `backend:command` | `media:queue/skip/stop/volume` | Media control | -| `backend:command` | `moderation:action` | Execute moderation action | - -Envelope format: `{ id, type, payload, replyChannel }`. - -Status keys: `voice:status`, `media:status` (set by discord-gateway, read by backend). - -## Development Commands - -```bash -# Install all dependencies -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 via next dev (port 3000) - -# Build -pnpm run build:backend -pnpm run build:discord-gateway -pnpm run build:web # next build (static export) - -# Type checking -pnpm run typecheck # Node services (pnpm -r) -pnpm run typecheck:web # Frontend typecheck (next build) - -# Lint (Biome) -pnpm run lint - -# Format (Biome) -pnpm run format - -# Run tests across all packages -pnpm run test - -# Database migrations (Drizzle) -pnpm run db:generate # Generate new migration -pnpm run db:migrate # Apply pending migrations -pnpm run db:studio # Open Drizzle Studio - -# Install yt-dlp for media download -pnpm run install:yt-dlp - -# Deploy to VPS (build + hot-patch running containers) -./deploy.sh # Build + deploy all services -./deploy.sh --frontend # Frontend (Next.js) only -./deploy.sh --backend # Backend TypeScript only -./deploy.sh --no-build # Skip build, just copy files -``` - -## Configuration - -Configuration via `.env` (see `.env.example`). Managed by Zod schemas: -- discord-gateway: `services/discord-gateway/src/shared/config/config.ts` -- backend: `services/backend/src/shared/config/index.ts` - -### Core (both services) -- `DISCORD_TOKEN` — Discord user token (required) -- `MONITOR_GUILD_ID` — Target guild for text monitoring -- `NODE_ENV` — development|production|test -- `LOG_LEVEL` — Pino log level (default: info) -- `VERBOSE` — Enable debug logging (default: false) - -### Database (PostgreSQL) -- `DATABASE_URL` — Connection string (overrides individual params) -- `POSTGRES_HOST`, `POSTGRES_PORT` (5432), `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` -- `POSTGRES_POOL_MIN` (2), `POSTGRES_POOL_MAX` (10) -- `AUTO_MIGRATE_ON_STARTUP` (default: true) - -### Redis -- `REDIS_URL` — Connection string (default: redis://localhost:6379) - -### Voice Recording (discord-gateway) -- `RECORDINGS_DIR` — Audio file output (default: ./recordings) -- `RECORDING_SEGMENT_MS` — OGG segment duration (default: 5000) -- `DECODER_ROTATE_MS` — Opus decoder rotation (default: 5000) -- `DECODER_COOLDOWN_MS` — Decoder error cooldown (default: 30000) -- `AUDIO_STREAM_SILENCE_DURATION_MS` — Silence threshold (default: 3000) -- `VOICE_CONNECTION_TIMEOUT_MS` — Connection timeout (default: 15000) -- `RECONNECT_TIMEOUT_MS` — Reconnect timeout (default: 5000) -- `PACKET_FILTER_MIN_SIZE` — Minimum Opus packet size (default: 8) -- `OPUS_FRAME_SIZE` (960), `AUDIO_SAMPLE_RATE` (48000), `AUDIO_CHANNELS` (2) -- `VOICE_GUILD_ID`, `VOICE_CHANNEL_ID` - -### Attachments -- `TELE_UPLOAD_URL` — Upload endpoint (default: https://upload.asepharyana.my.id/api/upload) -- `ATTACHMENT_UPLOAD_TIMEOUT_MS` (30000), `ATTACHMENT_MAX_SIZE_MB` (100), `ATTACHMENT_RETRY_ATTEMPTS` (3) - -### AI Moderation (discord-gateway) -- `AI_ANALYSIS_ENABLED` — Enable AI analysis (default: false) -- `AI_LLM_API_KEY` — LLM API key (required if enabled) -- `AI_LLM_BASE_URL` — LLM endpoint (default: https://9router.asepharyana.my.id/v1) -- `AI_LLM_MODEL` — Text model (default: text) -- `AI_LLM_VISION_MODEL` — Vision model (optional fallback) -- `AI_LLM_MAX_CONCURRENT` (5), `AI_LLM_TEXT_BATCH_SIZE` (20) -- `AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS` (60000), `AI_LLM_IMAGE_MAX_DIMENSION` (1024) -- `AI_ANALYSIS_DEBOUNCE_MS` (500), `AI_ANALYSIS_MAX_BATCH_SIZE` (200) -- `AI_ANALYSIS_PROCESSING_TIMEOUT_MS` (120000) -- `PISCINA_MAX_THREADS` — Worker pool size (optional) - -### Auto-Delete -- `AUTO_DELETE_FLAGGED_ENABLED` (true), `AUTO_DELETE_FLAGGED_DRY_RUN` (true) -- `AUTO_DELETE_FLAGGED_DELAY_MS` (0), `AUTO_DELETE_MIN_CONFIDENCE` (0.5) -- `AUTO_DELETE_ALLOWED_SEVERITIES`, `AUTO_DELETE_ALLOWED_CATEGORIES` -- `AUTO_DELETE_EXCLUDED_CHANNEL_IDS`, `AUTO_DELETE_EXCLUDED_USER_IDS` -- `AUTO_DELETE_NOTIFY_USER`, `AUTO_DELETE_LOG_CHANNEL_ID` - -### OpenAI Moderation (optional separate endpoint) -- `OPENAI_MODERATION_API_KEY`, `OPENAI_MODERATION_BASE_URL`, `OPENAI_MODERATION_MODEL` - -### Backend -- `WEBSERVER_PORT` (3001) -- `BACKLOG_SYNC_HOURS` (24), `BACKLOG_SYNC_BATCH_SIZE` (100) - -### Retention -- `RETENTION_MESSAGES_DAYS` (0=off), `RETENTION_ATTACHMENTS_DAYS`, `RETENTION_VOICE_DAYS` -- `RETENTION_CLEANUP_INTERVAL_MS` (86400000), `RETENTION_DRY_RUN` (true) - -## Testing - -Tests use **Vitest**. Currently minimal test coverage. Test directories should be created per service: - -``` -services/backend/tests/ -services/discord-gateway/tests/ -services/frontend/tests/ -``` - -Run tests: `pnpm run test` (runs `vitest run` in each package). - -## Code Style - -- **Formatter**: Biome (2-space indent) -- **Linter**: Biome with strict rules -- **Language**: TypeScript with strict mode -- **Logging**: Use `createChildLogger(context)` from `@bete/shared/logger` -- **Errors**: Throw custom `AppError` subclasses with `code` + `statusCode` -- **Database**: Use Drizzle ORM or raw parameterized queries (never string interpolation) -- **Imports**: Use `.js` extensions in source files (ESM convention) - -## Key Patterns - -### Event-Driven Architecture - -All inter-service communication happens through Redis pub/sub. The discord-gateway publishes events on typed channels, the backend subscribes and broadcasts to WebSocket clients. The backend publishes commands on `backend:command` with reply channels for request-response patterns. - -### Message Capture Lifecycle - -1. Discord event fires (`messageCreate`, `messageUpdate`, `messageDelete`) -2. Check guild matches MONITOR_GUILD_ID -3. Extract message metadata (user, channel, content, timestamp) -4. Upsert into `messages` table in PostgreSQL -5. Publish event to Redis (`discord:message:*`) -6. If attachments exist, insert into `attachments` table with `status='pending'` -7. Start async upload to tele/picser (non-blocking) -8. On success: update `uploaded_url`, `status='uploaded'` -9. On failure: store error, `status='failed'` - -### AI Moderation Pipeline - -1. Messages with `ai_status='pending'` are picked up by `aiAnalyzer.ts` -2. Batches messages by conversation (thread/channel proximity) -3. Builds context window (recent messages + channel culture + user reputation) -4. Calls LLM via `llmModerationClient.ts` with moderation prompt -5. Updates message with `ai_status`, `ai_moderation_flags`, `ai_severity`, `ai_confidence`, `ai_recommended_action` -6. If `AUTO_DELETE_FLAGGED_ENABLED` and confidence meets threshold, triggers auto-delete -7. Falls back to individual analysis for messages that could not be batched -8. Caches normalized text results in `text_analysis_cache` to avoid repeat calls - -### Voice Recording Lifecycle - -1. `VoiceController.connect(guildId, channelId)` via Redis command -2. Joins Discord voice channel, sets up audio receiver -3. On user start speaking: create per-user stream, OGG segment manager, Opus decoder -4. Opus packets -> OGG segments on disk + PCM decode for WebSocket broadcast -5. PCM data published to Redis (`discord:voice:pcm`) -> backend -> WS clients -6. On silence (3s timeout): close stream, finalize segment -7. After segment complete: upload to external storage, update database -8. `VoiceController.disconnect()` stops all recording - -### WebSocket Protocol (frontend) - -**Outbound (backend -> frontend):** -- Binary: PCM audio (24kHz mono s16le), prefixed with 4-byte user hash -- JSON events: all typed in `WSEventMap` — `message_*`, `voice_*`, `attachment_*`, `user_state`, `ui_state`, `media_state` - -**Inbound (frontend -> backend):** -- JSON `{ type: "voice_transmit", buffer: "" }` for mic-to-Discord -- JSON `{ type: "voice_command", command: "..." }` for voice control - -### Graceful Shutdown - -discord-gateway handles SIGINT/SIGTERM/uncaughtException/unhandledRejection: -1. Close database pool -2. Disconnect voice controller -3. Close event broadcaster (Redis) -4. Close command handler (Redis) -5. Destroy Discord client -6. Exit process - -### Public API - -All backend endpoints are publicly accessible — no authentication required. - -## Recording Structure - -``` -recordings/ - +-- / - | +-- --0.ogg - | +-- --0.json - | +-- --1.ogg - | +-- ... -``` - -Each segment is 5s (configurable via `RECORDING_SEGMENT_MS`). Metadata JSON includes user info, roles, timestamps, duration. - -## Vendor Packages - -### discord.js-selfbot-v13 (`vendor/discord.js-selfbot-v13`) -Fork of discord.js-selfbot-v13 (git submodule). Provides Discord API access via user account. - -### discord-video-stream (`vendor/discord-video-stream`) -Go Live / video streaming support library. Includes: -- H264 encoding (NVENC, VAAPI, software) -- WebRTC wrapper for Discord voice/video connections -- Stream connection management - -## Dependencies - -**Shared (`@bete/shared`):** -- pino — Structured logging -- zod — Schema validation - -**Backend:** -- express 5 — HTTP server -- ws — WebSocket server -- helmet — Security headers -- @discordjs/voice — Voice state querying (minimal) -- drizzle-orm + pg — PostgreSQL ORM -- ioredis — Redis client -- pino, pino-http — Logging -- prom-client — Prometheus metrics -- axios — HTTP client -- zod — Config validation - -**discord-gateway:** -- discord.js-selfbot-v13 — Discord client (user account) -- @discordjs/voice — Voice connection -- @discordjs/opus — Native Opus codec -- prism-media — Audio encode/decode -- @snazzah/davey — DA-VEY (Discord Audio Video End-to-end encryption) -- ioredis — Redis client -- drizzle-orm + pg — PostgreSQL ORM -- sharp — Image processing -- openai — OpenAI API client -- piscina — Worker threads for AI analysis -- tiktoken — Token counting -- p-retry, p-limit — Async utilities -- lru-cache — In-memory caching -- libsodium-wrappers — Encryption -- node-crc — CRC checksums -- imghash — Image hashing -- ws — WebSocket (internal) -- zod — Config validation - -**Frontend:** -- react 19, react-dom 19 -- @tanstack/react-query — Data fetching -- three, @react-three/fiber, @react-three/drei — 3D -- gsap, framer-motion — Animations -- @radix-ui/* — Accessible UI primitives -- tailwindcss 4, @tailwindcss/postcss — Styling -- lucide-react — Icons -- clsx, tailwind-merge — Class management -- vite 8 — Bundler - -## Notes - -- Bot uses selfbot variant (user account) — check Discord ToS -- Opus decoding requires native `@discordjs/opus` or `opusscript` under Node.js -- OGG segments include metadata JSON for each segment (user info, timestamps, duration) -- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord -- Graceful shutdown ensures clean disconnection and resource cleanup -- All database operations use parameterized queries to prevent SQL injection -- Attachment uploads are non-blocking (async) to avoid blocking message capture -- Message capture continues even if AI analysis or attachment upload fails - -## Common Tasks - -### Add a new config variable -1. Add to config schema in both `services/backend/src/shared/config/index.ts` and `services/discord-gateway/src/shared/config/config.ts` with Zod validation -2. Add to `.env.example` with description -3. Use via `config.VARIABLE_NAME` - -### Add a new REST endpoint -1. Create route handler in `services/backend/src/modules//.routes.ts` -2. Register in `services/backend/src/http/app.ts` -3. Use `asyncHandler` wrapper for error handling -4. Return JSON response - -### Add a new WebSocket event -1. Add to `eventTypes.ts` in discord-gateway -2. Add publish method to `EventBroadcaster` in discord-gateway -3. Add subscription + broadcast mapping in `services/backend/src/ws/redis-bridge.ts` -4. Add event type to `WSEventMap` in frontend `events.ts` -5. Add handler to `WsHandlers` in frontend `socket.ts` - -### Add a new database table -1. Add table definition in `services/discord-gateway/src/shared/database/schema.ts` -2. Generate migration: `pnpm run db:generate` -3. Check migration file in `drizzle/migrations/` -4. Apply: `pnpm run db:migrate` - -### Add a new Redis command -1. Add handler case in `commandHandler.ts` switch statement -2. Add publish call on backend side (see `voice.service.ts` or `media.service.ts`) -3. Update frontend API client if needed - -### Debug AI moderation -- Set `AI_ANALYSIS_ENABLED=true` and `VERBOSE=true` -- Check `ai_status`, `ai_error` fields in messages table -- Monitor `/api/analysis/search?q=` for analysis results -- Check `ai_analysis_runs` table for batch run status -- Adjust `AI_ANALYSIS_*` tuning variables - -### Debug voice recording -- Set `VERBOSE=true` -- Check `/api/status` for active connection -- Monitor segment files in `recordings//` -- Check `voice_recordings` table for upload status - -## CodeGraph Usage (Required) - -- Use CodeGraph first for repo-level questions: architecture, dependencies, references, callers/callees, impact, flow, routes, components. -- If graph is missing or stale, run scan first to refresh `.codegraph/graph.json`. -- Prefer graph-backed flow: - 1. scan-codegraph (build/refresh graph) - 2. query-codegraph (find definitions/references/callers/dependencies) - 3. analyze-codegraph (architecture, impact, risk, cycles, orphans, hotspots) - 4. export-codegraph (json/mermaid/dot/markdown/html when needed) - 5. open-codegraph-ui (interactive visualization when requested) -- Avoid broad grep/find or repeated wide file reads before graph lookup, except for exact literal search or known single-file edits. diff --git a/docs/superpowers/plans/2026-07-27-cicd-overhaul.md b/docs/superpowers/plans/2026-07-27-cicd-overhaul.md deleted file mode 100644 index 3efb2c5..0000000 --- a/docs/superpowers/plans/2026-07-27-cicd-overhaul.md +++ /dev/null @@ -1,542 +0,0 @@ -# CI/CD Overhaul Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate from hybrid CI/CD (GitHub Actions + GitLab CI + hot-deploy) to single Gitea CI pipeline with container registry — VPS pulls only. - -**Architecture:** Three Docker images (backend, discord-gateway, proxy) built in Gitea CI, pushed to `git.imrnes.team/MythEclipse/GMW/*`, VPS pulls and restarts via SSH. No more hot-deploy bind-mounts. - -**Tech Stack:** Gitea CI (Act Runner, GitHub Actions-compatible syntax), Docker Buildx, Gitea Container Registry, appleboy/ssh-action - -## Global Constraints - -- Docker images must be self-contained (no bind-mount overlay at runtime) -- All three images must be built from monorepo root using `infra/docker/Dockerfile.*` -- Frontend static export built inside proxy Dockerfile (multi-stage, Next.js → Nginx) -- Gitea CI variables: GITEA_REGISTRY_TOKEN (secret), VPS_HOST (secret), VPS_USER (secret), VPS_SSH_KEY (secret), ENV_FILE (secret), GITEA_REGISTRY (variable) -- Registry URL: `git.imrnes.team/MythEclipse/GMW/` -- Work on `main` branch only -- Must preserve voice recordings volume persistence across container restarts - ---- -### Task 1: Create Gitea CI workflow - -**Files:** -- Create: `.gitea/workflows/deploy.yml` - -**Interfaces:** -- Consumes: Dockerfiles at `infra/docker/Dockerfile.{backend,discord-gateway,proxy}` -- Produces: Docker images pushed to `git.imrnes.team/MythEclipse/GMW/bete-*:latest` and `:{sha}` -- Depends on: Task 2 (proxy Dockerfile), Task 3 (backend Dockerfile) — but workflow can reference files that are being written in the same commit - -- [ ] **Step 1: Create `.gitea/workflows/` directory and `deploy.yml`** - -```bash -mkdir -p .gitea/workflows -``` - -- [ ] **Step 2: Write the workflow file** - -Create `.gitea/workflows/deploy.yml`: - -```yaml -name: Build & Deploy -run-name: "Build & Deploy ${{ gitea.sha }}" - -on: - push: - branches: [main] - -jobs: - build-and-push: - runs-on: ubuntu-latest - strategy: - fail-fast: false - max-parallel: 2 - matrix: - service: [backend, discord-gateway, proxy] - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to Gitea Registry - uses: docker/login-action@v4 - with: - registry: ${{ vars.GITEA_REGISTRY }} - username: ${{ gitea.actor }} - password: ${{ secrets.GITEA_REGISTRY_TOKEN }} - - - name: Build & Push ${{ matrix.service }} - uses: docker/build-push-action@v7 - with: - context: . - file: infra/docker/Dockerfile.${{ matrix.service }} - push: true - tags: | - ${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:${{ gitea.sha }} - ${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:latest - cache-from: type=gha,scope=bete-${{ matrix.service }} - cache-to: type=gha,mode=max,scope=bete-${{ matrix.service }} - build-args: | - VITE_BE_API_URL=https://imphnen.asepharyana.my.id - VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id - - deploy: - needs: build-and-push - runs-on: ubuntu-latest - if: gitea.ref == 'refs/heads/main' - steps: - - name: Deploy to VPS - uses: appleboy/ssh-action@v1.2.5 - env: - ENV_FILE: ${{ secrets.ENV_FILE }} - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - envs: ENV_FILE - script: | - set -eu - APP_DIR=/opt/imphenbot - cd "$APP_DIR/infra/docker" - printf '%s\n' "$ENV_FILE" | tr -d '\r' > .env - docker compose pull - docker compose up -d --remove-orphans - docker image prune -f -``` - -Note: Gitea's Act Runner supports `gitea.*` context variables (`gitea.sha`, `gitea.actor`, `gitea.ref`). If `gitea.*` vars don't resolve, fall back to `github.*` equivalents (Act Runner emulates GitHub context). - -- [ ] **Step 3: Commit** - -```bash -git add .gitea/workflows/deploy.yml -git commit -m "ci: add Gitea CI workflow for build & deploy - -Gitea CI builds three Docker images (backend, discord-gateway, proxy), -pushes to Gitea Container Registry, then deploys to VPS via SSH pull. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 2: Rewrite Dockerfile.proxy for Next.js static export - -**Files:** -- Rewrite: `infra/docker/Dockerfile.proxy` - -**Interfaces:** -- Consumes: `services/frontend/` (Next.js app), `packages/shared/` (workspace dep), `infra/docker/nginx/nginx.conf` -- Produces: Nginx image serving Next.js static export at `/usr/share/nginx/html/` - -- [ ] **Step 1: Rewrite Dockerfile.proxy** - -Replace entire content with: - -```dockerfile -# ---- Stage 1: Build Next.js static export ---- -FROM node:22-slim AS frontend-builder - -WORKDIR /app - -# Install pnpm -RUN corepack enable - -# Install build essentials for native deps -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ - python3 make g++ && rm -rf /var/lib/apt/lists/* - -# Copy dependency manifests first for layer caching -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY packages/shared/package.json ./packages/shared/package.json -COPY services/frontend/package.json ./services/frontend/package.json -COPY services/frontend/tsconfig.json ./services/frontend/tsconfig.json - -# Install dependencies (frontend + shared) -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm install --frozen-lockfile --filter './packages/shared' --filter './services/frontend' - -# Copy source code -COPY packages/shared/ ./packages/shared/ -COPY services/frontend/ ./services/frontend/ - -# Pass API/WS URLs as build args for the frontend -ARG VITE_BE_API_URL -ARG VITE_BE_WS_URL -ENV VITE_BE_API_URL=${VITE_BE_API_URL} -ENV VITE_BE_WS_URL=${VITE_BE_WS_URL} - -# Build shared lib first, then frontend static export -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm --filter './packages/shared' run build -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm --filter frontend run build - -# ---- Stage 2: Nginx ---- -FROM nginx:alpine - -# Nginx config (API/WS proxy + static file serving) -COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf - -# Static export from frontend builder -COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/ - -EXPOSE 80 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:80/ || exit 1 - -CMD ["nginx", "-g", "daemon off;"] -``` - -- [ ] **Step 2: Validate nginx.conf handles static files correctly** - -Read and confirm `infra/docker/nginx/nginx.conf`. - -```bash -cat infra/docker/nginx/nginx.conf -``` - -Verify it has: -- Static file location with `try_files $uri /index.html` (SPA fallback) -- `/api` and `/ws` proxied to `http://backend:3000` - -- [ ] **Step 3: Commit** - -```bash -git add infra/docker/Dockerfile.proxy -git commit -m "docker(proxy): rewrite for Next.js static export - -Replaced stale Rust WASM build with multi-stage Docker build: -stage 1 builds Next.js static export, stage 2 serves via Nginx. -Includes VITE_BE_API_URL/VITE_BE_WS_URL build args. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 3: Add build args to Dockerfile.backend - -**Files:** -- Modify: `infra/docker/Dockerfile.backend` - -- [ ] **Step 1: Add VITE build args to Dockerfile.backend** - -Insert after `WORKDIR /app`: - -```dockerfile -# Build args for frontend API URLs (passed through for future use) -ARG VITE_BE_API_URL -ARG VITE_BE_WS_URL -ENV VITE_BE_API_URL=${VITE_BE_API_URL} -ENV VITE_BE_WS_URL=${VITE_BE_WS_URL} -``` - -Note: These are consumed by the proxy Dockerfile (Task 2), not needed by backend itself but passed through the CI workflow to all three images for consistency. - -- [ ] **Step 2: Commit** - -```bash -git add infra/docker/Dockerfile.backend -git commit -m "docker(backend): add VITE_BE_API_URL and VITE_BE_WS_URL build args - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 4: Rewrite docker-compose.yml for Gitea registry + no bind-mounts - -**Files:** -- Rewrite: `infra/docker/docker-compose.yml` - -- [ ] **Step 1: Write new docker-compose.yml** - -Replace entire content: - -```yaml -version: '3.8' - -services: - proxy: - image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-proxy:${IMAGE_TAG:-latest} - container_name: imphenbot-proxy - restart: unless-stopped - labels: - - "traefik.enable=true" - - "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.my.id`)" - - "traefik.http.routers.imphenbot.entrypoints=websecure" - - "traefik.http.routers.imphenbot.tls=true" - - "traefik.http.services.imphenbot.loadbalancer.server.port=80" - depends_on: - - backend - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] - interval: 30s - timeout: 5s - retries: 3 - deploy: - resources: - limits: - memory: 64M - networks: - - app-shared-net - - backend: - image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-backend:${IMAGE_TAG:-latest} - container_name: imphenbot-backend - restart: unless-stopped - env_file: - - .env - environment: - NODE_ENV: production - WEBSERVER_PORT: 3000 - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - start_period: 15s - retries: 3 - deploy: - resources: - limits: - memory: 256M - networks: - - app-shared-net - - discord-gateway: - image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-discord-gateway:${IMAGE_TAG:-latest} - container_name: imphenbot-discord-gateway - restart: unless-stopped - env_file: - - .env - environment: - NODE_ENV: production - volumes: - - recordings:/app/recordings - healthcheck: - test: ["CMD-SHELL", "kill -0 1 || exit 1"] - interval: 30s - timeout: 5s - start_period: 30s - retries: 3 - deploy: - resources: - limits: - memory: 512M - networks: - - app-shared-net - -volumes: - recordings: - -networks: - app-shared-net: - name: app-shared-net - external: true -``` - -Key changes: -- Image refs: `registry.gitlab.com/...` → `${GITEA_REGISTRY}/MythEclipse/GMW/...` -- Removed all bind-mount volumes: `./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist` -- Changed `./recordings` bind-mount → named volume `recordings:` (persists across restarts) -- Added `depends_on: backend` to proxy (proxy needs backend for API/WS, though Nginx handles startup gracefully) - -- [ ] **Step 2: Commit** - -```bash -git add infra/docker/docker-compose.yml -git commit -m "docker(compose): switch to Gitea registry, remove bind-mounts - -Images now come from git.imrnes.team/MythEclipse/GMW. All hot-deploy -bind-mounts removed — containers are fully self-contained. Voice -recordings use a named volume instead of bind-mount. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 5: Create lightweight deploy.sh - -**Files:** -- Create: `deploy.sh` - -- [ ] **Step 1: Write deploy.sh** - -```bash -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -INFRA_DIR="$SCRIPT_DIR/infra/docker" - -: "${VPS_HOST:?required}" -: "${VPS_USER:?required}" -: "${VPS_SSH_KEY:?required}" - -echo "=== Deploy to $VPS_HOST ===" - -# Copy local .env if it exists (overrides CI env) -if [ -f "$INFRA_DIR/.env" ]; then - scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env" -fi - -ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT' - set -eu - cd /opt/imphenbot/infra/docker - echo "=== Pulling images ===" - docker compose pull - echo "=== Restarting containers ===" - docker compose up -d --remove-orphans - echo "=== Cleaning up ===" - docker image prune -f - echo "=== Active containers ===" - docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" -REMOTESCRIPT - -echo "=== Deploy complete ===" -``` - -- [ ] **Step 2: Make executable** - -```bash -chmod +x deploy.sh -``` - -- [ ] **Step 3: Commit** - -```bash -git add deploy.sh -git commit -m "chore: rewrite deploy.sh as lightweight SSH pull script - -Replaced hot-deploy tar-pipe script with simple SSH-based deploy -that pulls latest images from Gitea registry and restarts containers. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 6: Disable old CI files - -**Files:** -- Disable: `.github/workflows/deploy-docker.yml` -- Keep: `.gitlab-ci.yml` if exists (already may have been removed) - -- [ ] **Step 1: Rename GitHub Actions workflow to .disabled** - -```bash -mv .github/workflows/deploy-docker.yml .github/workflows/deploy-docker.yml.disabled -``` - -- [ ] **Step 2: Remove docker compose file's old frontend-dist directory from git** (if tracked) - -```bash -# Check if frontend-dist is tracked (it should be gitignored, but check) -git ls-files infra/docker/frontend-dist 2>/dev/null || echo "Not tracked — OK" -``` - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/deploy-docker.yml.disabled -git rm --cached .github/workflows/deploy-docker.yml 2>/dev/null || true -git commit -m "ci: disable GitHub Actions workflow - -Renamed to .disabled. All CI now goes through Gitea CI (.gitea/workflows/). - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 7: Update .gitignore - -**Files:** -- Modify: `.gitignore` - -- [ ] **Step 1: Add .gitea exclusion note and any missing entries** - -Read current `.gitignore`: - -```bash -cat .gitignore -``` - -Then append (only if not already present): - -``` -# Gitea workflow logs (local runners) -.gitea/workflows/*.log -``` - -The `.gitea/workflows/` YAML files themselves should be tracked in git. - -- [ ] **Step 2: Commit** - -```bash -git add .gitignore -git commit -m "chore: update gitignore for Gitea CI artifacts - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- -### Task 8: Push and verify CI pipeline - -- [ ] **Step 1: Verify all changes** - -```bash -git status -git log --oneline -10 -``` - -Expected: clean working tree, all 7 commits ready to push. - -- [ ] **Step 2: Push to main** - -```bash -git push origin main -``` - -- [ ] **Step 3: Monitor CI run** - -Watch Gitea CI at `https://git.imrnes.team/MythEclipse/GMW/actions`. - -Expected outcome: -1. `build-and-push` job runs 3 matrix builds (backend, discord-gateway, proxy) in parallel (max 2) -2. Each image is pushed to `git.imrnes.team/MythEclipse/GMW/bete-*` with both `:latest` and `:{sha}` tags -3. `deploy` job SSHes into VPS, pulls images, restarts containers -4. All 3 containers `imphenbot-proxy`, `imphenbot-backend`, `imphenbot-discord-gateway` are running - -- [ ] **Step 4: Verify containers on VPS** - -```bash -# SSH into VPS and check -ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" " - docker ps --filter 'name=imphenbot' --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' - docker compose -f /opt/imphenbot/infra/docker/docker-compose.yml ps -" -``` - -- [ ] **Step 5: Verify no hot-deploy artifacts remain** - -```bash -ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" " - ls -la /opt/imphenbot/infra/docker/ | grep -E 'dist$' || echo 'No dist dirs — clean' -" -``` - ---- -## Rollback - -If the pipeline fails at any point: - -1. **Fix and re-push**: Edit the broken file, commit, push to main — CI re-runs automatically -2. **Emergency rollback**: SSH to VPS, run `docker compose up -d` with a known-good IMAGE_TAG: - ```bash - IMAGE_TAG= docker compose up -d - ``` -3. **Restore old CI**: Move `.github/workflows/deploy-docker.yml.disabled` back and push diff --git a/docs/superpowers/plans/2026-07-27-frontend-refactor-plan.md b/docs/superpowers/plans/2026-07-27-frontend-refactor-plan.md deleted file mode 100644 index eef1d94..0000000 --- a/docs/superpowers/plans/2026-07-27-frontend-refactor-plan.md +++ /dev/null @@ -1,1346 +0,0 @@ -# Frontend Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor frontend for cleaner structure, API alignment, data-fetching consistency, rebranding, and dead code removal. - -**Architecture:** Extract inline page components into `components//` directories; split `voiceApi` into `voiceApi` + `mediaApi`; consolidate shared types; convert manual state hooks to React Query; rebrand names. - -**Tech Stack:** React 19, Next.js 16 (App Router), TypeScript, TanStack Query, Tailwind v4 - -## Global Constraints - -- Every page in `app/(dashboard)/` remains a `"use client"` `default export` function -- No changes to backend API endpoints or their paths -- No functional changes — visual output must be identical -- Import paths use `@/` alias throughout -- All existing exports from `hooks/index.ts` must remain (consumers may import from there) -- Rename "bete"/"GMW" → "Discord Automod" in user-visible text only - ---- - -## File Map - -### Infrastructure Changes -| Action | File | Purpose | -|--------|------|---------| -| Create | `src/lib/ws-hook.ts` | Shared `WsHook` type consumed by 3 hook files | -| Modify | `src/hooks/use-messages.ts` | Import `WsHook` from shared | -| Modify | `src/hooks/use-media.ts` | Import `WsHook` from shared, fix import order | -| Modify | `src/hooks/use-recordings.ts` | Import `WsHook` from shared | -| Modify | `src/lib/navigation.ts` | Fix Settings icon (BarChart3 → Settings) | -| Modify | `src/components/shared/loading-skeleton.tsx` | Fix Tailwind v4 dynamic class | -| Modify | `src/components/layout/app-sidebar.tsx` | Consolidate `isActive` into shared utility | -| Modify | `src/components/layout/app-header.tsx` | Consolidate `isActive` into shared utility | -| Modify | `src/components/layout/mobile-nav.tsx` | Consolidate `isActive` into shared utility | - -### API Separation -| Action | File | Purpose | -|--------|------|---------| -| Create | `src/lib/api/media.ts` | Media player API (moved from voiceApi) | -| Modify | `src/lib/api/voice.ts` | Remove media methods, keep voice-only | -| Modify | `src/lib/api/index.ts` | Add `mediaApi` export | -| Modify | `src/hooks/use-media.ts` | Import from `mediaApi` instead of `voiceApi` | - -### Dead Code Removal -| Action | File | Purpose | -|--------|------|---------| -| Delete | `src/components/landing/live-stats.tsx` | Unused — landing page redirects | -| Delete | `src/components/ui/item.tsx` | Unused component | -| Modify | `src/hooks/use-messages.ts` | Remove `useSearch` export | -| Modify | `src/hooks/index.ts` | Remove `useSearch` from re-exports | -| Modify | `src/lib/format.ts` | Remove duplicate `extractImage` (if present) | - -### Feature Extraction: Messages -| Action | File | Purpose | -|--------|------|---------| -| Create | `src/components/messages/message-card.tsx` | Extracted from messages/page.tsx | -| Create | `src/components/messages/ai-status-badge.tsx` | Extracted from messages/page.tsx | -| Create | `src/components/messages/message-detail-view.tsx` | Extracted: DetailView + MiniStat | -| Create | `src/components/messages/images-grid.tsx` | Images tab content (from messages/page.tsx) | -| Create | `src/components/messages/review-list.tsx` | Review tab content (from messages/page.tsx) | -| Modify | `src/app/(dashboard)/messages/page.tsx` | Use extracted components | - -### Feature Extraction: Dashboard -| Action | File | Purpose | -|--------|------|---------| -| Create | `src/components/dashboard/stats-section.tsx` | Extracted from dashboard/page.tsx StatsSection | -| Create | `src/components/dashboard/users-section.tsx` | Extracted from dashboard/page.tsx UsersSection | -| Create | `src/components/dashboard/user-detail-section.tsx` | Extracted from dashboard/page.tsx | -| Create | `src/components/dashboard/channels-section.tsx` | Extracted from dashboard/page.tsx | -| Create | `src/components/dashboard/channel-detail-section.tsx` | Extracted from dashboard/page.tsx | -| Modify | `src/app/(dashboard)/dashboard/page.tsx` | Use extracted components | - -### Feature Extraction: Other Pages -| Action | File | Purpose | -|--------|------|---------| -| Create | `src/components/voice/voice-connection-card.tsx` | Voice connection UI | -| Create | `src/components/voice/active-speakers-panel.tsx` | Active speakers list | -| Create | `src/components/voice/microphone-card.tsx` | Mic toggle UI | -| Modify | `src/app/(dashboard)/voice/page.tsx` | Use extracted components | -| Create | `src/components/media/music-player.tsx` | Media player UI | -| Modify | `src/app/(dashboard)/media/page.tsx` | Use extracted components | -| Create | `src/components/recordings/recording-list.tsx` | Recording list UI | -| Modify | `src/app/(dashboard)/recordings/page.tsx` | Use extracted components | -| Create | `src/components/analysis/search-panel.tsx` | Analysis search UI | -| Modify | `src/app/(dashboard)/analysis/page.tsx` | Use extracted components | - -### Data Fetching Consistency -| Modify | `src/components/shared/guild-selector.tsx` | Use `useGuilds` + `useConfig` instead of manual fetch | -| Modify | `src/hooks/use-voice.ts` | `useVoiceChannels` → React Query | -| Modify | `src/components/chatbot/chatbot.tsx` | Use React Query for history + mutate for send | - -### Rebranding -| Modify | `src/app/layout.tsx` | Title: "Discord Automod — Moderation Dashboard" | -| Modify | `src/app/(dashboard)/settings/page.tsx` | Text references | -| Modify | Various comments/files | "bete" → "Discord Automod", "GMW" → "Discord Automod" | - -### Unused shadcn Cleanup -| Delete | `src/components/ui/*.tsx` | Components verified unused by grep | - ---- - -## Tasks - -### Task 1: Shared Infrastructure - -**Files:** -- Create: `src/lib/ws-hook.ts` -- Modify: `src/hooks/use-messages.ts` (import WsHook) -- Modify: `src/hooks/use-media.ts` (import WsHook, fix import placement) -- Modify: `src/hooks/use-recordings.ts` (import WsHook) -- Modify: `src/lib/navigation.ts` (Settings icon) -- Modify: `src/components/shared/loading-skeleton.tsx` (fix grid) -- Modify: `src/components/layout/app-sidebar.tsx` (isActive → shared or inline) -- Modify: `src/components/layout/app-header.tsx` (same) -- Modify: `src/components/layout/mobile-nav.tsx` (same) - -**Interfaces:** -- Produces: `WsHook` type in `lib/ws-hook.ts` — exact same shape as current duplicate -- Produces: `isActivePath(pathname: string, matchPrefix: string): boolean` — shared utility in lib - -- [ ] **Step 1: Create `src/lib/ws-hook.ts`** - -```typescript -import type { WsEventType } from "./ws/types"; - -export type WsHook = { - on: ( - eventType: E, - handler: (data: unknown) => void, - ) => () => void; -}; -``` - -- [ ] **Step 2: Update `src/hooks/use-messages.ts`** - -Replace the local `WsHook` type definition with: -```typescript -import type { WsHook } from "@/lib/ws-hook"; -``` -And remove the local `type WsHook = ...` block (lines ~8-13). - -- [ ] **Step 3: Update `src/hooks/use-media.ts`** - -Same import replacement. Also move the `import { useEffect }` from line 61 to the top import block with the other react imports. - -- [ ] **Step 4: Update `src/hooks/use-recordings.ts`** - -Same import replacement. - -- [ ] **Step 5: Fix navigation icon for Settings** - -In `src/lib/navigation.ts`, change: -```typescript -import { ..., Settings, ... } from "lucide-react"; -``` -Replace `BarChart3` with `Settings` for the settings nav item. - -- [ ] **Step 6: Fix `LoadingSkeleton` grid** - -In `src/components/shared/loading-skeleton.tsx`, replace: -```tsx -columns > 1 ? `grid-cols-1 md:grid-cols-${columns}` : "grid-cols-1", -``` -With: -```tsx -columns > 1 ? "grid-cols-1 md:grid-cols-2" as const : "grid-cols-1", -``` -(Tailwind v4 doesn't support dynamic class construction. Max columns the app uses is 2, so hardcode md:grid-cols-2.) - -- [ ] **Step 7: Create shared `isActivePath` utility** - -In `src/lib/navigation.ts`, add: -```typescript -export function isActivePath(pathname: string, matchPrefix: string): boolean { - if (matchPrefix === "/dashboard") return pathname === "/dashboard"; - return pathname.startsWith(matchPrefix); -} -``` - -In `lib/utils.ts` or keep in `lib/navigation.ts` — I'll put it in `navigation.ts` since it's navigation-related. - -- [ ] **Step 8: Update layout files to use shared `isActivePath`** - -In `app-sidebar.tsx`, replace inline `isActive` with `import { isActivePath } from "@/lib/navigation"`. -In `app-header.tsx`, same. -In `mobile-nav.tsx`, same. - -- [ ] **Step 9: Verify the app compiles** - -Run: `cd /home/code/GMW/services/frontend && npx tsc --noEmit` -Expected: No type errors (or only pre-existing ones unrelated to these changes). - -- [ ] **Step 10: Commit** - -```bash -git add src/lib/ws-hook.ts src/hooks/use-messages.ts src/hooks/use-media.ts src/hooks/use-recordings.ts src/lib/navigation.ts src/components/shared/loading-skeleton.tsx src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/layout/mobile-nav.tsx -git commit -m "refactor(frontend): shared WsHook type, fix icon/grid, consolidate isActive" -``` - ---- - -### Task 2: API Layer Separation (voiceApi / mediaApi) - -**Files:** -- Create: `src/lib/api/media.ts` -- Modify: `src/lib/api/voice.ts` -- Modify: `src/lib/api/index.ts` -- Modify: `src/hooks/use-media.ts` - -**Interfaces:** -- Consumes: existing voiceApi shape -- Produces: `mediaApi` export with `getStatus`, `queue`, `skip`, `stop`, `volume` - -- [ ] **Step 1: Create `src/lib/api/media.ts`** - -```typescript -import type { MediaState } from "@/lib/types"; -import { api } from "./client"; - -export const mediaApi = { - getStatus: () => api.get("/api/media/status"), - queue: (source: string, mode: string) => - api.post("/api/media/queue", { source, mode }), - skip: () => api.post("/api/media/skip", {}), - stop: () => api.post("/api/media/stop", {}), - volume: (volume: number) => - api.post("/api/media/volume", { volume }), -}; -``` - -- [ ] **Step 2: Remove media methods from `src/lib/api/voice.ts`** - -Delete `getMediaStatus`, `mediaQueue`, `mediaSkip`, `mediaStop`, `mediaVolume`. -Remove `MediaState` from the import (keep `Channel`, `Guild`, `VoiceStatus`). - -```typescript -import type { Channel, Guild, VoiceStatus } from "@/lib/types"; -import { api } from "./client"; - -export const voiceApi = { - getGuilds: () => api.get("/api/guilds"), - getTextChannels: (guildId: string) => - api.get(`/api/guilds/${guildId}/channels`), - getVoiceChannels: (guildId: string) => - api.get(`/api/guilds/${guildId}/voice-channels`), - getStatus: () => api.get("/api/voice/status"), - connect: (guildId: string, channelId: string) => - api.post("/api/voice/connect", { guildId, channelId }), - disconnect: () => api.post("/api/voice/disconnect", {}), - sendCommand: (command: string) => - api.post<{ success: boolean; command: string }>("/api/voice/command", { - command, - }), -}; -``` - -- [ ] **Step 3: Update `src/lib/api/index.ts`** - -```typescript -export { chatbotApi } from "./chatbot"; -export { ApiError, api, apiRequest } from "./client"; -export { configApi } from "./config"; -export { dashboardApi } from "./dashboard"; -export { mediaApi } from "./media"; -export { messagesApi } from "./messages"; -export { recordingsApi } from "./recordings"; -export { uiStateApi } from "./ui-state"; -export { voiceApi } from "./voice"; -``` - -- [ ] **Step 4: Update `src/hooks/use-media.ts`** - -Change the import from `voiceApi` to `mediaApi`: -```typescript -import { mediaApi } from "@/lib/api"; -``` -Replace all `voiceApi.getMediaStatus()` → `mediaApi.getStatus()`, `voiceApi.mediaQueue(...)` → `mediaApi.queue(...)`, etc. - -- [ ] **Step 5: Typecheck** - -Run: `cd /home/code/GMW/services/frontend && npx tsc --noEmit` - -- [ ] **Step 6: Commit** - -```bash -git add src/lib/api/media.ts src/lib/api/voice.ts src/lib/api/index.ts src/hooks/use-media.ts -git commit -m "refactor(frontend): split mediaApi from voiceApi" -``` - ---- - -### Task 3: Dead Code Removal - -**Files:** -- Delete: `src/components/landing/live-stats.tsx` -- Delete: `src/components/ui/item.tsx` -- Delete: `src/components/landing/` (if empty after) -- Modify: `src/hooks/use-messages.ts` (remove `useSearch`) -- Modify: `src/hooks/index.ts` (remove `useSearch` export) - -- [ ] **Step 1: Delete `src/components/landing/live-stats.tsx`** - -```bash -rm src/components/landing/live-stats.tsx -``` - -- [ ] **Step 2: Delete `src/components/ui/item.tsx`** - -```bash -rm src/components/ui/item.tsx -``` - -- [ ] **Step 3: Remove `useSearch` from `use-messages.ts`** - -Delete the entire `useSearch` function (lines ~103-109). - -- [ ] **Step 4: Update `hooks/index.ts`** - -Remove `useSearch` from the re-export line: -```typescript -export { - useImages, - useLoadMore, - useMessageDetail, - useMessages, - useMessagesHasMore, - useMessagesWsSync, - useReanalyze, - useReanalyzeBatch, - useReview, - useTextChannels, -} from "./use-messages"; -``` - -- [ ] **Step 5: Remove `components/landing/` directory if empty** - -```bash -rmdir src/components/landing/ 2>/dev/null || true -``` - -- [ ] **Step 6: Typecheck & commit** - -```bash -cd /home/code/GMW/services/frontend && npx tsc --noEmit -git add src/components/landing/ src/components/ui/item.tsx src/hooks/use-messages.ts src/hooks/index.ts -git commit -m "refactor(frontend): remove dead code (live-stats, item, useSearch)" -``` - ---- - -### Task 4: Messages Feature Components - -**Files:** -- Create: `src/components/messages/ai-status-badge.tsx` -- Create: `src/components/messages/message-card.tsx` -- Create: `src/components/messages/message-detail-view.tsx` -- Create: `src/components/messages/images-grid.tsx` -- Create: `src/components/messages/review-list.tsx` -- Modify: `src/app/(dashboard)/messages/page.tsx` - -**Interfaces:** -- Consumes: `MessageRecord`, `AttachmentRecord` types from `@/lib/types` -- Produces: Exported components consumed by `messages/page.tsx` - -- [ ] **Step 1: Create `src/components/messages/ai-status-badge.tsx`** - -Extract the `AiStatusBadge` function from `messages/page.tsx`: -```typescript -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; - -const STATUS_STYLES: Record = { - clean: "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20", - warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", - flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", - error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20", - pending: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", - processing: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", -}; - -export function AiStatusBadge({ status }: { status?: string | null }) { - const style = STATUS_STYLES[status ?? ""]; - if (!style) return null; - return ( - - {status} - - ); -} -``` - -- [ ] **Step 2: Create `src/components/messages/message-card.tsx`** - -Extract `MessageCard` + `extractFirstImage` helper: -```typescript -"use client"; - -import { Hash, Progress, RefreshCw } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { AiStatusBadge } from "./ai-status-badge"; -import { safeParseJsonArray } from "@/lib/format"; -import type { MessageRecord } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -function extractFirstImage(metadata: string | null | undefined): string | null { - if (!metadata) return null; - try { - const m = JSON.parse(metadata); - const atts: Array<{ url: string; contentType?: string }> = m.attachments ?? []; - return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null; - } catch { - return null; - } -} - -const SEVERITY_BORDERS: Record = { - low: "border-l-sky-400", - medium: "border-l-yellow-400", - high: "border-l-orange-400", - critical: "border-l-red-500", -}; - -export function MessageCard({ - message: msg, - onClick, - onReanalyze, -}: { - message: MessageRecord; - onClick: (id: string) => void; - onReanalyze: (id: string) => void; -}) { - const severity = SEVERITY_BORDERS[msg.ai_severity ?? ""]; - return ( - onClick(msg.id)} - > - -
- - - - {msg.username.charAt(0).toUpperCase()} - - -
-
- {msg.username} - - {new Date(msg.created_at).toLocaleString()} - - - - {msg.channel_id.slice(0, 8)} - - - {msg.ai_severity && msg.ai_severity !== "none" && ( - - {msg.ai_severity} - - )} - {msg.type === "deleted" && ( - - deleted - - )} - {msg.type === "edited" && ( - - edited - - )} -
-

- {msg.content} -

- {(() => { - const u = extractFirstImage(msg.metadata); - if (!u) return null; - return ( - - ); - })()} - {msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && ( -
- {safeParseJsonArray(msg.ai_moderation_flags).map((f) => ( - - {f} - - ))} -
- )} - {msg.ai_analysis && ( -

- {msg.ai_analysis} -

- )} - {msg.ai_confidence != null && ( -
- - - {(msg.ai_confidence * 100).toFixed(0)}% - -
- )} - -
-
-
-
- ); -} -``` - -- [ ] **Step 3: Create `src/components/messages/message-detail-view.tsx`** - -Extract `DetailView` + `MiniStat`: -```typescript -"use client"; - -import { ExternalLink, Sparkles } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { formatBytes, safeParseJsonArray } from "@/lib/format"; -import type { MessageRecord } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -interface DetailAttachment { - id: string; - filename: string; - type: string; - size: number; - uploaded_url?: string | null; - discord_url?: string | null; -} - -function MiniStat({ - label, - value, - destructive, - capitalize, -}: { - label: string; - value: string; - destructive?: boolean; - capitalize?: boolean; -}) { - return ( - - -

{label}

-

- {value} -

-
-
- ); -} - -export function MessageDetailView({ - message, - attachments, -}: { - message: MessageRecord; - attachments: DetailAttachment[]; -}) { - return ( -
-
- - - - {message.username.charAt(0).toUpperCase()} - - -
-
- {message.username} - - {new Date(message.created_at).toLocaleString()} - - {message.type === "deleted" && ( - deleted - )} - {message.type === "edited" && ( - edited - )} -
-

- {message.content} -

-
-
- {message.ai_analysis && ( -
-
- -

AI Analysis

-
-

{message.ai_analysis}

-
- )} - {message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && ( -
-

Moderation Flags

-
- {safeParseJsonArray(message.ai_moderation_flags).map((f) => ( - {f} - ))} -
-
- )} -
- {message.ai_status && ( - - )} - {message.ai_severity && message.ai_severity !== "none" && ( - - )} - {message.ai_confidence != null && ( - - )} - {message.ai_recommended_action && message.ai_recommended_action !== "none" && ( - - )} -
- {attachments.length > 0 && ( -
-

- Attachments ({attachments.length}) -

- -
- )} -
- ); -} -``` - -- [ ] **Step 4: Create `src/components/messages/images-grid.tsx`** - -```typescript -"use client"; - -import { ImageIcon } from "lucide-react"; -import { Card } from "@/components/ui/card"; -import type { MessageRecord } from "@/lib/types"; - -function extractImage(metadata: string | null | undefined): string | null { - if (!metadata) return null; - try { - const m = JSON.parse(metadata); - const atts: Array<{ url: string; contentType?: string }> = m.attachments ?? []; - return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null; - } catch { - return null; - } -} - -export function ImagesGrid({ - images, - onClick, -}: { - images: MessageRecord[]; - onClick: (id: string) => void; -}) { - if (images.length === 0) { - return ( -
- -

No images yet.

-
- ); - } - - return ( -
- {images.map((msg) => { - const imgUrl = extractImage(msg.metadata); - return ( - onClick(msg.id)} - > -
- {imgUrl ? ( - {msg.content - ) : ( -
- No image -
- )} - {msg.content && ( -
-

- {msg.username}: {msg.content} -

-
- )} -
-
- ); - })} -
- ); -} -``` - -- [ ] **Step 5: Create `src/components/messages/review-list.tsx`** - -```typescript -"use client"; - -import { Flag } from "lucide-react"; -import { MessageCard } from "./message-card"; -import type { MessageRecord } from "@/lib/types"; - -export function ReviewList({ - reviews, - onClick, - onReanalyze, -}: { - reviews: MessageRecord[]; - onClick: (id: string) => void; - onReanalyze: (id: string) => void; -}) { - if (reviews.length === 0) { - return ( -
- -

No flagged messages to review.

-
- ); - } - - return ( -
- {reviews.map((msg) => ( - - ))} -
- ); -} -``` - -- [ ] **Step 6: Simplify `src/app/(dashboard)/messages/page.tsx`** - -Replace the entire file with a thin composition layer: -```typescript -"use client"; - -import { useCallback, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Flag, Loader2, RefreshCw, Search } from "lucide-react"; - -import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { GuildSelector } from "@/components/shared/guild-selector"; -import { ImagesGrid } from "@/components/messages/images-grid"; -import { MessageCard } from "@/components/messages/message-card"; -import { MessageDetailView } from "@/components/messages/message-detail-view"; -import { ReviewList } from "@/components/messages/review-list"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - useImages, - useLoadMore, - useMessageDetail, - useMessages, - useMessagesHasMore, - useMessagesWsSync, - useReanalyze, - useReanalyzeBatch, - useReview, - useTextChannels, -} from "@/hooks"; -import { messagesApi } from "@/lib/api"; -import { useWebSocket } from "@/lib/ws/context"; - -export default function MessagesPage() { - const [guildId, setGuildId] = useState(""); - const [selectedChannel, setSelectedChannel] = useState(""); - const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); - const [searchQuery, setSearchQuery] = useState(""); - const [detailId, setDetailId] = useState(null); - - const ws = useWebSocket(); - const { data: channels = [] } = useTextChannels(guildId); - const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined); - const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore(guildId, selectedChannel || undefined); - const loadMoreMut = useLoadMore(); - const { data: images } = useImages(guildId); - const { data: reviews } = useReview(selectedChannel || undefined); - const reanalyzeMut = useReanalyze(); - const reanalyzeBatchMut = useReanalyzeBatch(); - - useMessagesWsSync(ws, guildId); - - const { message: detailMessage, attachments: detailAttachments, loading: detailLoading } = useMessageDetail(detailId); - - const [searchEnabled, setSearchEnabled] = useState(false); - const { data: searchResults, isFetching: searching } = useQuery({ - queryKey: ["messages-search", guildId, searchQuery], - queryFn: async () => { - const result = await messagesApi.search(searchQuery, 50); - return result.results; - }, - enabled: searchEnabled && !!searchQuery && !!guildId, - }); - - const handleSearch = useCallback(() => { - if (!searchQuery.trim()) return; - setSearchEnabled(true); - }, [searchQuery]); - - const handleLoadMore = useCallback(() => { - if (!cursorData?.cursor || loadMoreMut.isPending) return; - loadMoreMut.mutate({ - guildId, - channelId: selectedChannel || undefined, - cursor: cursorData.cursor, - }); - }, [cursorData, loadMoreMut, guildId, selectedChannel]); - - const displayMessages = searchResults ?? messages ?? []; - const hasMore = cursorData?.hasMore ?? false; - const isEmpty = !isLoading && displayMessages.length === 0; - - if (error) { - return ( -
- - -
- ); - } - - return ( -
- - -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="pl-9 h-9" - /> -
- {channels.length > 0 && ( - - )} - -
- - setViewTab(v as typeof viewTab)}> - - All ({(searchResults ?? messages)?.length ?? 0}) - Images ({images?.length ?? 0}) - - Review ({reviews?.length ?? 0}) - - - - - {searchResults && ( -

- Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} -

- )} - - {viewTab === "all" && ( -
- {isLoading ? ( - - ) : isEmpty ? ( -
- -

- {searchResults ? "No messages found matching your search." : "No captures yet."} -

-
- ) : ( - <> - {displayMessages.map((msg) => ( - reanalyzeMut.mutate(id)} /> - ))} - {hasMore && ( -
- -
- )} - - )} -
- )} - - {viewTab === "images" && images && } - {viewTab === "review" && reviews && ( - reanalyzeMut.mutate(id)} /> - )} - - !o && setDetailId(null)}> - - - - Message Detail - - - - {detailLoading ? ( -
- -
- ) : detailMessage ? ( - - ) : null} -
-
-
-
- ); -} -``` -(Note: Need to add `MessageSquare` to lucide import) - -- [ ] **Step 7: Typecheck** - -```bash -cd /home/code/GMW/services/frontend && npx tsc --noEmit -``` - -- [ ] **Step 8: Commit** - -```bash -git add src/components/messages/ src/app/\(dashboard\)/messages/page.tsx -git commit -m "refactor(frontend): extract message components from page" -``` - ---- - -### Task 5: Dashboard Feature Components - -**Files:** -- Create: `src/components/dashboard/stats-section.tsx` -- Create: `src/components/dashboard/users-section.tsx` -- Create: `src/components/dashboard/user-detail-section.tsx` -- Create: `src/components/dashboard/channels-section.tsx` -- Create: `src/components/dashboard/channel-detail-section.tsx` -- Modify: `src/app/(dashboard)/dashboard/page.tsx` - -Each component is extracted verbatim from the existing `dashboard/page.tsx` inline functions, preserving exact rendering. Structure same as Task 4 pattern. - -- [ ] **Step 1-5: Create the 5 component files** — extract each inline section from `dashboard/page.tsx` into its own file under `src/components/dashboard/`. Each component gets: - - Same `"use client"` directive - - Same imports it needs - - Same JSX (no visual changes) - - Same props interface - -- [ ] **Step 6: Simplify `dashboard/page.tsx`** - -Replace with a thin composition layer that imports the 5 sections and uses the state machine pattern (view switching). - -- [ ] **Step 7: Typecheck & commit** - -```bash -cd /home/code/GMW/services/frontend && npx tsc --noEmit -git add src/components/dashboard/ src/app/\(dashboard\)/dashboard/page.tsx -git commit -m "refactor(frontend): extract dashboard components from page" -``` - ---- - -### Task 6: Extract Voice / Media / Recordings / Analysis Components - -**Files:** -- Create: `src/components/voice/voice-connection-card.tsx` -- Create: `src/components/voice/active-speakers-panel.tsx` -- Create: `src/components/voice/microphone-card.tsx` -- Modify: `src/app/(dashboard)/voice/page.tsx` -- Create: `src/components/media/music-player.tsx` -- Modify: `src/app/(dashboard)/media/page.tsx` -- Create: `src/components/recordings/recording-list.tsx` -- Modify: `src/app/(dashboard)/recordings/page.tsx` -- Create: `src/components/analysis/search-panel.tsx` -- Modify: `src/app/(dashboard)/analysis/page.tsx` - -Same pattern as Tasks 4-5: extract inline components to separate files, simplify page files. - -- [ ] **Step 1-10: Create component files for each feature** -- [ ] **Step 11: Simplify page files** -- [ ] **Step 12: Typecheck & commit** - -```bash -git add src/components/voice/ src/components/media/ src/components/recordings/ src/components/analysis/ src/app/\(dashboard\)/voice/page.tsx src/app/\(dashboard\)/media/page.tsx src/app/\(dashboard\)/recordings/page.tsx src/app/\(dashboard\)/analysis/page.tsx -git commit -m "refactor(frontend): extract voice/media/recordings/analysis components" -``` - ---- - -### Task 7: Data Fetching Consistency - -**Files:** -- Modify: `src/components/shared/guild-selector.tsx` -- Modify: `src/hooks/use-voice.ts` -- Modify: `src/components/chatbot/chatbot.tsx` - -- [ ] **Step 1: Refactor `GuildSelector` to use hooks** - -Replace manual `useState` + `useEffect` + `fetchGuilds` with `useGuilds()` and `useConfig()` hooks. Handle loading/error states the same way. - -```typescript -"use client"; - -import { AlertCircle } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useConfig, useGuilds } from "@/hooks"; -import type { Guild } from "@/lib/types"; - -export interface GuildSelectorProps { - value: string; - onChange: (guildId: string) => void; - autoHide?: boolean; -} - -export function GuildSelector({ value, onChange, autoHide = true }: GuildSelectorProps) { - const { data: guilds = [], isLoading, error, refetch } = useGuilds(); - const { data: config } = useConfig(); - - // Auto-select on mount - const initDone = useRef(false); - useEffect(() => { - if (guilds.length === 0 || value || initDone.current) return; - initDone.current = true; - const preferred = config?.monitorGuildId ?? guilds[0].id; - if (preferred) onChange(preferred); - }, [guilds, config, value, onChange]); - - if (autoHide && guilds.length <= 1 && !isLoading && !error) return null; - - if (isLoading) { - return ( -
- - -
- ); - } - - if (error) { - return ( -
-
- -

- Could not load guilds: {error.message} -

-
- -
- ); - } - - if (guilds.length === 0) { /* same as current */ } - - return ( -
- Guild - -
- ); -} -``` -(Add `useRef`, `useEffect` and `RefreshCw` to imports.) - -- [ ] **Step 2: Convert `useVoiceChannels` to React Query** - -In `use-voice.ts`, replace: -```typescript -export function useVoiceChannels() { - const [channels, setChannels] = useState<...>([]); - ... -} -``` -With: -```typescript -import { useQuery } from "@tanstack/react-query"; - -export function useVoiceChannels(guildId: string) { - return useQuery({ - queryKey: ["voice-channels", guildId], - queryFn: () => voiceApi.getVoiceChannels(guildId), - enabled: !!guildId, - }); -} -``` - -Then update `voice/page.tsx` where it calls `useVoiceChannels` — change from `const { channels, fetch } = useVoiceChannels()` to `const { data: voiceChannels = [], refetch: fetchChannels } = useVoiceChannels(selectedGuild)`. - -- [ ] **Step 3: Refactor Chatbot to React Query** - -In `chatbot.tsx`, add: -```typescript -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -// Inside Chatbot component: -const qc = useQueryClient(); -const { data: historyMessages = [] } = useQuery({ - queryKey: ["chatbot-history"], - queryFn: () => chatbotApi.getHistory(), - enabled: open, -}); - -const sendMut = useMutation({ - mutationFn: (text: string) => chatbotApi.send(text), - onSuccess: () => qc.invalidateQueries({ queryKey: ["chatbot-history"] }), -}); - -const clearMut = useMutation({ - mutationFn: () => chatbotApi.clearHistory(), - onSuccess: () => qc.setQueryData(["chatbot-history"], []), -}); -``` - -Replace manual `useEffect` history fetch with `historyMessages` from query. -Replace manual `handleSend` with `sendMut.mutateAsync`. -Replace manual `handleClear` with `clearMut.mutate`. - -- [ ] **Step 4: Typecheck & commit** - -```bash -cd /home/code/GMW/services/frontend && npx tsc --noEmit -git add src/components/shared/guild-selector.tsx src/hooks/use-voice.ts src/components/chatbot/chatbot.tsx src/app/\(dashboard\)/voice/page.tsx -git commit -m "refactor(frontend): consistent React Query data fetching" -``` - ---- - -### Task 8: Rebrand (bete/GMW → Discord Automod) - -**Files:** -- Modify: `src/app/layout.tsx` -- Modify: `src/app/(dashboard)/settings/page.tsx` -- Modify: Any files with "bete" or "GMW" references in comments - -- [ ] **Step 1: Search for "bete" and "GMW" references** - -```bash -grep -rn -i "bete\|gmw" src/ --include="*.ts" --include="*.tsx" -``` - -- [ ] **Step 2: Update title in `src/app/layout.tsx`** - -```typescript -export const metadata: Metadata = { - title: "Discord Automod — Moderation Dashboard", - description: "Live Discord monitoring and AI moderation dashboard", -}; -``` - -- [ ] **Step 3: Update settings page text** - -In `src/app/(dashboard)/settings/page.tsx`, the about section: -```typescript -

Discord Automod — Discord Moderation Watcher

-``` - -- [ ] **Step 4: Update any remaining references** in comments or labels - -- [ ] **Step 5: Commit** - -```bash -git add src/app/layout.tsx src/app/\(dashboard\)/settings/page.tsx -git commit -m "refactor(frontend): rebrand bete/GMW to Discord Automod" -``` - ---- - -### Task 9: Remove Unused shadcn/ui Components - -**Files:** Various under `src/components/ui/` - -- [ ] **Step 1: Find unused shadcn/ui components** - -```bash -cd /home/code/GMW/services/frontend/src -for f in components/ui/*.tsx; do - name=$(basename "$f" .tsx); - # Skip core components that might be gitignored or infrastructure - case "$name" in - sidebar|button|card|input|select|tabs|dialog|badge|avatar|progress|scroll-area|skeleton|slider|separator|switch|sonner|tooltip|sheet|label|popover|command|dropdown-menu) continue ;; - esac - count=$(grep -r "components/ui/$name" app/ components/ hooks/ lib/ --include="*.tsx" --include="*.ts" -l 2>/dev/null | grep -v "components/ui/$name" | wc -l); - echo "$name: $count imports"; -done | sort -t: -k2 -n -``` - -- [ ] **Step 2: Remove components with 0 imports** - -For each component with 0 imports (excluding self-imports), delete the file. - -- [ ] **Step 3: Verify nothing breaks** - -```bash -cd /home/code/GMW/services/frontend && npx tsc --noEmit -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/ui/ -git commit -m "refactor(frontend): remove unused shadcn/ui components" -``` - ---- - -## Verification - -After all tasks complete: - -```bash -cd /home/code/GMW/services/frontend -npx tsc --noEmit -``` - -Expected: No type errors. - -```bash -npx next build 2>&1 | tail -20 -``` - -Expected: Successful static export build with no warnings. - -## Rollback Plan - -If any step breaks the build: -1. `git log --oneline -10` to see recent commits -2. `git revert ` to revert specific change -3. Or `git reset --hard HEAD~N` to roll back multiple commits diff --git a/docs/superpowers/plans/2026-07-27-refactor-backend-gateway-p1.md b/docs/superpowers/plans/2026-07-27-refactor-backend-gateway-p1.md deleted file mode 100644 index 5b11a7c..0000000 --- a/docs/superpowers/plans/2026-07-27-refactor-backend-gateway-p1.md +++ /dev/null @@ -1,736 +0,0 @@ -# Backend & Gateway Refactoring — Phase 1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Clean up ~130 lines of dead/duplicate code, consolidate duplicated database initialization, and simplify the MessageStore layering in discord-gateway. - -**Architecture:** Three independent tasks that can be done in any order. Task 1 consolidates database pool/drizzle init into `@bete/shared` so both services use one canonical pattern. Task 2 removes backward-compat function wrappers from `messageStore.ts`. Task 3 deletes dead files and functions. - -**Tech Stack:** TypeScript, Node.js, Drizzle ORM, PostgreSQL, pnpm workspace - -## Global Constraints - -- All imports use `.js` extensions (ESM convention) -- Follow existing code style (Biome, 2-space indent) -- Keep `@bete/shared` as the single source of truth for shared infrastructure -- No package.json changes needed — `@bete/shared` already has `drizzle-orm` and `pg` as dependencies -- Do not change any business logic — only structural refactoring - ---- - -### Task 1: Consolidate Database Initialization into `@bete/shared` - -**Files:** -- Create: `packages/shared/src/database/init.ts` -- Modify: `packages/shared/src/database/pool.ts` — add `getPool()` export -- Modify: `packages/shared/src/index.ts` — export new `./database/init.js` -- Modify: `packages/shared/package.json` — add `"./database/init"` export entry -- Modify: `services/backend/src/shared/database/index.ts` — re-export from shared -- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — re-export from shared -- Delete: (functions migrate, no file deletion here — both local files stay as thin wrappers) - -**Interfaces:** -- Produces: - - `@bete/shared/database/init` exports: - - `let db: ReturnType | null` (module-level, for getDatabase()) - - `let rawPool: Pool | null` (module-level, for getPool()) - - `initializeDatabase(schema?: Record): Promise>` — creates pool via `createPoolFromConfig`, wraps with `drizzle()`. Accepts optional schema object (gateway needs it, backend doesn't). Reads config from env/config module internally. - - `getDatabase(): ReturnType` — throws if not initialized - - `getPool(): Pool` — returns raw pool for raw SQL queries, throws if not initialized - - `closeDatabase(): Promise` — closes pool and nullifies references - - `executeAll(sql: string, params?: unknown[]): Promise` — raw SQL query, returns all rows - - `executeGet(sql: string, params?: unknown[]): Promise` — raw SQL query, returns first row or null - - `withDatabaseClient(callback: (client: PoolClient) => Promise): Promise` - -- [ ] **Step 1: Create `packages/shared/src/database/init.ts`** - -This is the canonical database initialization module. It merges what both services currently do: - -```typescript -import { createChildLogger } from "@bete/shared/logger"; -import { closePool, createPoolFromConfig } from "@bete/shared/database/pool"; -import { drizzle } from "drizzle-orm/node-postgres"; -import type { Pool, PoolClient } from "pg"; -import { config } from "../config/index.js"; - -const logger = createChildLogger("database.init"); - -let db: ReturnType | null = null; -let rawPool: Pool | null = null; - -export async function initializeDatabase(schema?: Record) { - if (db !== null) return db; - - const pool = config.DATABASE_URL - ? createPoolFromConfig({ - url: config.DATABASE_URL, - min: config.POSTGRES_POOL_MIN, - max: config.POSTGRES_POOL_MAX, - }) - : createPoolFromConfig({ - host: config.POSTGRES_HOST, - port: config.POSTGRES_PORT, - user: config.POSTGRES_USER, - password: config.POSTGRES_PASSWORD, - database: config.POSTGRES_DB, - min: config.POSTGRES_POOL_MIN, - max: config.POSTGRES_POOL_MAX, - }); - - rawPool = pool; - db = drizzle(pool, schema ? { schema } : undefined); - - // Test connection - try { - const client = await pool.connect(); - client.release(); - logger.info("Database connection successful"); - } catch (err) { - logger.error({ err }, "Failed to connect to database"); - throw err; - } - - return db; -} - -export function getDatabase() { - if (db === null) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - return db; -} - -export function getPool() { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - return rawPool; -} - -export async function closeDatabase() { - if (rawPool !== null) { - await closePool(rawPool); - } - rawPool = null; - db = null; - logger.info("Database connection closed"); -} - -function convertPlaceholdersForPostgres(sql: string) { - let i = 0; - return sql.replace(/\?/g, () => `$${++i}`); -} - -export async function executeAll(sql: string, params?: unknown[]) { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const query = convertPlaceholdersForPostgres(sql); - const result = await rawPool.query(query, params || []); - return result.rows; -} - -export async function executeGet(sql: string, params?: unknown[]) { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const query = convertPlaceholdersForPostgres(sql); - const result = await rawPool.query(query, params || []); - return result.rows[0] ?? null; -} - -export async function withDatabaseClient( - callback: (client: PoolClient) => Promise, -): Promise { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const client = await rawPool.connect(); - try { - return await callback(client); - } finally { - client.release(); - } -} -``` - -**Note:** This uses `config` from `@bete/shared/config`. The backend's config proxies to that already (`services/backend/src/shared/config/index.ts` re-exports from `@bete/shared/config`). The gateway's config at `services/discord-gateway/src/shared/config/config.ts` has the same field names but is its own Zod schema. Since `@bete/shared/config` doesn't have the PostgreSQL pool config fields currently, we need to check what it exports. - -Actually — `@bete/shared/config` may not have `POSTGRES_HOST` etc. Let me adjust: the `initializeDatabase` function should accept config values as parameters instead of reading from a shared config. - -Revised approach for `packages/shared/src/database/init.ts`: - -```typescript -import { createChildLogger } from "@bete/shared/logger"; -import { closePool, createPoolFromConfig } from "./pool.js"; -import { drizzle } from "drizzle-orm/node-postgres"; -import type { Pool, PoolClient } from "pg"; - -const logger = createChildLogger("database.init"); - -let db: ReturnType | null = null; -let rawPool: Pool | null = null; - -export interface DatabaseConfig { - DATABASE_URL?: string; - POSTGRES_HOST?: string; - POSTGRES_PORT?: number; - POSTGRES_USER?: string; - POSTGRES_PASSWORD?: string; - POSTGRES_DB?: string; - POSTGRES_POOL_MIN?: number; - POSTGRES_POOL_MAX?: number; -} - -export async function initializeDatabase( - cfg: DatabaseConfig, - schema?: Record, -) { - if (db !== null) return db; - - const pool = cfg.DATABASE_URL - ? createPoolFromConfig({ - url: cfg.DATABASE_URL, - min: cfg.POSTGRES_POOL_MIN, - max: cfg.POSTGRES_POOL_MAX, - }) - : createPoolFromConfig({ - host: cfg.POSTGRES_HOST, - port: cfg.POSTGRES_PORT, - user: cfg.POSTGRES_USER, - password: cfg.POSTGRES_PASSWORD, - database: cfg.POSTGRES_DB, - min: cfg.POSTGRES_POOL_MIN, - max: cfg.POSTGRES_POOL_MAX, - }); - - rawPool = pool; - db = drizzle(pool, schema ? { schema } : undefined); - - try { - const client = await pool.connect(); - client.release(); - logger.info("Database connection successful"); - } catch (err) { - logger.error({ err }, "Failed to connect to database"); - throw err; - } - - return db; -} - -export function getDatabase() { - if (db === null) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - return db; -} - -export function getPool() { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - return rawPool; -} - -export async function closeDatabase() { - if (rawPool !== null) { - await closePool(rawPool); - } - rawPool = null; - db = null; - logger.info("Database connection closed"); -} - -function convertPlaceholdersForPostgres(sql: string) { - let i = 0; - return sql.replace(/\?/g, () => `$${++i}`); -} - -export async function executeAll(sql: string, params?: unknown[]) { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const query = convertPlaceholdersForPostgres(sql); - const result = await rawPool.query(query, params || []); - return result.rows; -} - -export async function executeGet(sql: string, params?: unknown[]) { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const query = convertPlaceholdersForPostgres(sql); - const result = await rawPool.query(query, params || []); - return result.rows[0] ?? null; -} - -export async function withDatabaseClient( - callback: (client: PoolClient) => Promise, -): Promise { - if (!rawPool) { - throw new Error("Database not initialized. Call initializeDatabase() first."); - } - const client = await rawPool.connect(); - try { - return await callback(client); - } finally { - client.release(); - } -} -``` - -- [ ] **Step 2: Add export to `packages/shared/src/index.ts`** - -```typescript -export * from "./database/init.js"; -``` - -- [ ] **Step 3: Add export to `packages/shared/package.json`** - -```json -"./database/init": "./dist/database/init.js", -``` - -- [ ] **Step 4: Build the shared package to verify it compiles** - -```bash -cd /home/code/GMW/packages/shared -pnpm run build -``` - -- [ ] **Step 5: Rewrite `services/backend/src/shared/database/index.ts`** - -Change to a thin wrapper that imports from `@bete/shared/database/init` and passes the backend's config: - -```typescript -import { createChildLogger } from "@bete/shared/logger"; -import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, getPool as sharedGetPool, closeDatabase as sharedCloseDb } from "@bete/shared/database/init"; -import { config } from "../config/index.js"; - -const logger = createChildLogger("database"); - -const dbConfig = { - DATABASE_URL: config.DATABASE_URL, - POSTGRES_HOST: config.POSTGRES_HOST, - POSTGRES_PORT: config.POSTGRES_PORT, - POSTGRES_USER: config.POSTGRES_USER, - POSTGRES_PASSWORD: config.POSTGRES_PASSWORD, - POSTGRES_DB: config.POSTGRES_DB, - POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN, - POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX, -}; - -export async function initializeDatabase() { - logger.info("Initializing database"); - return sharedInit(dbConfig); -} - -export function getDatabase() { - return sharedGetDb(); -} - -export function getPool() { - return sharedGetPool(); -} - -export async function closeDatabase() { - logger.info("Closing database"); - return sharedCloseDb(); -} -``` - -- [ ] **Step 6: Rewrite `services/discord-gateway/src/shared/database/drizzle.ts`** - -Change to a thin wrapper: - -```typescript -import { createChildLogger } from "@bete/shared/logger"; -import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, closeDatabase as sharedCloseDb, executeAll as sharedExecAll, executeGet as sharedExecGet, withDatabaseClient as sharedWithClient } from "@bete/shared/database/init"; -import { config } from "../../shared/config/config.js"; -import * as schema from "./schema.js"; - -const logger = createChildLogger("drizzle"); - -const dbConfig = { - DATABASE_URL: config.DATABASE_URL, - POSTGRES_HOST: config.POSTGRES_HOST, - POSTGRES_PORT: config.POSTGRES_PORT, - POSTGRES_USER: config.POSTGRES_USER, - POSTGRES_PASSWORD: config.POSTGRES_PASSWORD, - POSTGRES_DB: config.POSTGRES_DB, - POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN, - POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX, -}; - -export async function initializeDatabase() { - return sharedInit(dbConfig, schema); -} - -export function getDatabase() { - return sharedGetDb(); -} - -export { sharedCloseDb as closeDatabase }; -export { sharedExecAll as executeAll, sharedExecGet as executeGet, sharedWithClient as withDatabaseClient }; -``` - -- [ ] **Step 7: Run typecheck on all packages to verify** - -```bash -cd /home/code/GMW -pnpm run typecheck -``` - -- [ ] **Step 8: Commit** - -```bash -git add packages/shared/src/database/init.ts packages/shared/src/index.ts packages/shared/package.json -git add services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts -git commit -m "refactor: consolidate database initialization into @bete/shared/database/init" -``` - ---- - -### Task 2: Remove Backward-Compat Function Wrappers from MessageStore - -**Files:** -- Modify: `services/discord-gateway/src/modules/message-capture/messageStore.ts` — remove lines 310-398 (backward-compat wrappers), export singleton directly -- Modify: `services/discord-gateway/src/modules/message-capture/index.ts` — update re-exports to use `messageStore` singleton -- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` — update imports to use `messageStore.methodName()` -- Modify: `services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts` — update imports -- Modify: `services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts` — update imports -- Modify: `services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts` — update imports -- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts` — update imports -- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts` — update imports (uses `getConversationContextBefore` and `updateMessagesAIAnalysisBulk`) -- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts` — update imports (uses many functions) -- Possibly modify: other files that import the wrapper functions - -**Interfaces:** -- Consumes: Existing `MessageStore` class methods (unchanged signatures) -- Produces: Singleton `messageStore` instance as the single export point - -The key insight: the backward-compat wrappers at lines 310-398 of `messageStore.ts` are function-level exports that delegate to `getInstance()`. Every importer can instead import the singleton `messageStore` instance and call methods on it directly. - -Current importers of wrapper functions: - -| File | Functions Used | -|------|---------------| -| `messageCapture.ts` | `getMessageById`, `insertMessageEdit`, `updateMessageAsEdited`, `updateMessageAsDeleted`, `upsertMessageForCapture` | -| `batchProcessor.ts` | `updateMessagesAIAnalysisBulk` | -| `batchScheduler.ts` | `getPendingMessagesByConversation` | -| `individualFallbackProcessor.ts` | `updateMessagesAIAnalysisBulk` | -| `moderationBuilders.ts` | `getMessageById` | -| `aiAnalysisWorker.ts` | `getConversationContextBefore`, `updateMessagesAIAnalysisBulk` | -| `aiAnalyzer.ts` | `getConversationKeysWithIncompleteAnalysis`, `getIncompleteMessagesByConversation`, `getMessageById`, `getPendingConversationKeys`, `updateMessageAIAnalysis` | - -- [ ] **Step 1: Modify `messageStore.ts`** — replace backward-compat wrappers with a singleton export - -Replace lines 22-33 (lazy singleton pattern) and lines 310-398 (wrapper functions) with: - -```typescript -// ─── Singleton instance ───────────────────────────────────────────────────── - -const logger = createChildLogger("message-store"); -const database = getDatabase() as unknown as NodePgDatabase; -export const messageStore = new MessageStore(database, logger); -``` - -Then remove everything from line 310 onward (the backward-compat function wrappers section). - -- [ ] **Step 2: Update `message-capture/index.ts`** - -Change the re-exports from individual functions to the `messageStore` singleton: - -```typescript -export { messageStore } from "../message-capture/messageStore.js"; -export { - getDisplayContent, - getMessageLocation, - getMessageMetadata, -} from "../message-capture/messageMetadata.js"; -// ... rest unchanged -``` - -Also remove the individual function re-exports since they no longer exist. - -- [ ] **Step 3: Update `messageCapture.ts`** - -Change imports from: -```typescript -import { - getMessageById, - insertMessageEdit, - upsertMessageForCapture, - updateMessageAsDeleted, - updateMessageAsEdited, -} from "./messageStore.js"; -``` -To: -```typescript -import { messageStore } from "./messageStore.js"; -``` - -Then update every call site: -- `upsertMessageForCapture(messageRecord)` → `messageStore.upsertMessageForCapture(messageRecord)` -- `insertMessageEdit(...)` → `messageStore.insertMessageEdit(...)` -- `updateMessageAsEdited(...)` → `messageStore.updateMessageAsEdited(...)` -- `updateMessageAsDeleted(...)` → `messageStore.updateMessageAsDeleted(...)` -- `getMessageById(...)` → `messageStore.getMessageById(...)` - -- [ ] **Step 4: Update `batchProcessor.ts`** - -Change from: -```typescript -import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js"; -``` -To: -```typescript -import { messageStore } from "../message-capture/messageStore.js"; -``` - -Then update call sites: -- `updateMessagesAIAnalysisBulk(updates)` → `messageStore.messages.updateMessagesAIAnalysisBulk(updates)` - -Wait — `updateMessagesAIAnalysisBulk` is actually defined in `MessagesAnalysis` class, which is called via `MessageStore` → `MessagesDb` → `MessagesAnalysis`. Let me check the actual delegation chain. - -Looking at the wrapper functions: -```typescript -export const updateMessagesAIAnalysisBulk = ( - updates: Array<{ messageId: string; result: AIAnalysisUpdate }>, -): Promise => - getInstance().updateMessagesAIAnalysisBulk(updates); -``` - -And in the class: -```typescript -class MessageStore { - readonly messages: MessagesDb; - // ... -} - -class MessagesDb { - readonly analysis: MessagesAnalysis; - // ... - updateMessagesAIAnalysisBulk(...) { - return this.analysis.updateMessagesAIAnalysisBulk(...) - } -} -``` - -So the call chain is: `messageStore.messages.updateMessagesAIAnalysisBulk()`. But actually, looking at `MessagesDb`, it might have its own `updateMessagesAIAnalysisBulk` that delegates to `this.analysis.updateMessagesAIAnalysisBulk()`. Let me verify... - -Actually, for simplicity and to minimize changes, let me look at whether `MessagesDb` has `updateMessagesAIAnalysisBulk` or if only the wrapper has it. - -Let me check: - -Actually I already read that `MessagesDb` has methods. Let me look at what methods `MessagesDb` exposes vs the wrapper functions. - -Instead of guessing, the safe approach is to keep the thin function wrappers but simplify them. Actually, a better approach for this task: - -**Revised approach:** Instead of making all importers use `messageStore.messages.analysis.methodName()`, add all the forwarded methods directly to the `MessageStore` class (which it already does for most), and just have external files import the singleton and call `messageStore.methodName()`. - -Let me check what methods `MessageStore` already has vs what's only available as backward-compat wrappers: - -Looking at the code: -- `insertMessageEdit` — EXISTS in MessageStore class (line 54) -- `upsertMessageForCapture` — EXISTS in MessageStore class -- `updateMessageAsEdited` — EXISTS in MessageStore class -- `updateMessageAsDeleted` — EXISTS in MessageStore class -- `getMessagesByChannel` — EXISTS in MessageStore class -- `updateMessageAIAnalysis` — EXISTS in MessageStore class -- `updateMessagesAIAnalysisBulk` — EXISTS in MessageStore class -- `getPendingAIAnalysisMessages` — EXISTS in MessageStore class -- `getMessageById` — EXISTS in MessageStore class -- `listMessages` — EXISTS in MessageStore class (delegates to MessagesPagination) -- `listReviewMessages` — EXISTS in MessageStore class (delegates to MessagesPagination) -- `getConversationContextBefore` — EXISTS in MessageStore class -- `getPendingMessagesByConversation` — EXISTS in MessageStore class -- `getPendingConversationKeys` — EXISTS in MessageStore class -- `getConversationKeysWithIncompleteAnalysis` — EXISTS in MessageStore class -- `getIncompleteMessagesByConversation` — EXISTS in MessageStore class - -So every function wrapper has a corresponding method on `MessageStore` class. The change is straightforward. - -Now, after creating the singleton `messageStore`, all importers just do `messageStore.updateMessagesAIAnalysisBulk(...)` instead of calling the bare function. - -But there's one complication: `MessagesDb.updateMessagesAIAnalysisBulk` is actually calling `this.analysis.updateMessagesAIAnalysisBulk()`. Does the `MessageStore` class have its own direct `updateMessagesAIAnalysisBulk`? Let me check the class definition... - -Actually, I already saw from the grep output that `MessageStore` class has `updateMessagesAIAnalysisBulk` — the wrapper says `getInstance().updateMessagesAIAnalysisBulk(updates)`, and the class has that method. - -OK so the mapping is 1:1 between wrapper functions and MessageStore class methods. This is safe. - -- [ ] **Step 5: Update `batchScheduler.ts`** - -```typescript -// Before: -import { getPendingMessagesByConversation } from "../message-capture/messageStore.js"; -// After: -import { messageStore } from "../message-capture/messageStore.js"; -``` -And call: `messageStore.getPendingMessagesByConversation(...)` - -- [ ] **Step 6: Update `individualFallbackProcessor.ts`** - -```typescript -// Before: -import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js"; -// After: -import { messageStore } from "../message-capture/messageStore.js"; -``` -And call: `messageStore.updateMessagesAIAnalysisBulk(...)` - -- [ ] **Step 7: Update `moderationBuilders.ts`** - -```typescript -// Before: -import { getMessageById } from "../message-capture/messageStore.js"; -// After: -import { messageStore } from "../message-capture/messageStore.js"; -``` -And call: `messageStore.getMessageById(...)` - -- [ ] **Step 8: Update `aiAnalysisWorker.ts`** - -```typescript -// Before: -import { getConversationContextBefore, updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js"; -// After: -import { messageStore } from "../message-capture/messageStore.js"; -``` -And update all call sites. - -- [ ] **Step 9: Update `aiAnalyzer.ts`** - -```typescript -// Before: -import { - getConversationKeysWithIncompleteAnalysis, - getIncompleteMessagesByConversation, - getMessageById, - getPendingConversationKeys, - updateMessageAIAnalysis, -} from "../message-capture/messageStore.js"; -// After: -import { messageStore } from "../message-capture/messageStore.js"; -``` -And update all call sites. - -- [ ] **Step 10: Update `message-capture/index.ts`** - -Remove individual function re-exports, replace with `messageStore`: - -```typescript -export { messageStore } from "./messageStore.js"; -export { - getDisplayContent, - getMessageLocation, - getMessageMetadata, -} from "./messageMetadata.js"; -export type { - AIRecommendedAction, - AISeverity, - AIStatus, - AttachmentRecord, - MessageRecord, - VoiceSegmentRecord, -} from "./types.js"; -export type { TextCaptureTarget } from "./messageCapture.js"; -export { - captureMessage, - registerMessageCapture, - setEventBroadcaster, -} from "./messageCapture.js"; -``` - -- [ ] **Step 11: Run typecheck** - -```bash -cd /home/code/GMW -pnpm run typecheck -``` - -- [ ] **Step 12: Commit** - -```bash -git add services/discord-gateway/src/modules/message-capture/ -git add services/discord-gateway/src/modules/ai-moderation/ -git commit -m "refactor: remove backward-compat function wrappers from messageStore" -``` - ---- - -### Task 3: Remove Dead Code - -**Files:** -- Delete: `services/backend/src/modules/response.ts` — empty deprecated file -- Delete: `services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts` -- Delete: `services/discord-gateway/src/modules/webhook-notifications/index.ts` -- Delete: `services/discord-gateway/src/modules/webhook-notifications/` (directory) -- Modify: `services/backend/src/ws/server.ts` — remove duplicate `broadcastBinaryToFrontend()` function, keep only `broadcastBinary()` - -**Interfaces:** -- None — these are deletions only, no consumer impact - -- [ ] **Step 1: Delete `modules/response.ts`** - -```bash -rm /home/code/GMW/services/backend/src/modules/response.ts -``` - -- [ ] **Step 2: Fix `ws/server.ts`** — remove duplicate `broadcastBinaryToFrontend` - -In `ws/server.ts`, `broadcastBinaryToFrontend` (line 238) and `broadcastBinary` (line 267) do exactly the same thing. Replace the `broadcastBinaryToFrontend(data)` call on line 147 with a call to `broadcastBinary(data)`, then delete the `broadcastBinaryToFrontend` function. - -Edit line 147: -```typescript -// Before: - broadcastBinaryToFrontend(data); -// After: - broadcastBinary(data); -``` - -Remove the `broadcastBinaryToFrontend` function (lines 238-248): -```typescript - // Remove this entire function: - function broadcastBinaryToFrontend(data: Buffer) { - for (const client of frontendClients) { - if (client.readyState === WebSocket.OPEN) { - try { - client.send(data); - } catch (err) { - logger.error({ err }, "Failed to send binary to frontend client"); - } - } - } - } -``` - -- [ ] **Step 3: Check if anything imports `webhook-notifications`** - -```bash -grep -rn "webhook-notifications\|webhookNotifier\|triggerWebhook" /home/code/GMW/services/ --include='*.ts' | grep -v "node_modules" | grep -v "services/discord-gateway/src/modules/webhook-notifications/" -``` - -Expected: empty (confirmed earlier) - -- [ ] **Step 4: Delete webhook-notifications module** - -```bash -rm -rf /home/code/GMW/services/discord-gateway/src/modules/webhook-notifications/ -``` - -- [ ] **Step 5: Run typecheck to verify no broken imports** - -```bash -cd /home/code/GMW -pnpm run typecheck -``` - -- [ ] **Step 6: Commit** - -```bash -git add services/backend/src/modules/response.ts services/backend/src/ws/server.ts -git add services/discord-gateway/src/modules/webhook-notifications/ -git commit -m "chore: remove dead code (response.ts, broadcastBinaryToFrontend, webhook-notifications)" -``` diff --git a/docs/superpowers/plans/2026-07-27-services-refactoring.md b/docs/superpowers/plans/2026-07-27-services-refactoring.md deleted file mode 100644 index 555c1cf..0000000 --- a/docs/superpowers/plans/2026-07-27-services-refactoring.md +++ /dev/null @@ -1,579 +0,0 @@ -# Services Refactoring Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor backend (4.2k lines) and discord-gateway (17.9k lines) for consistency, reduced file sizes, deduplication, and pattern uniformity across 11 phases. - -**Architecture:** Phase1-3 target backend unchanged; Phase4-8 split large gateway files; Phase9 deduplicates shared database init; Phase10-11 are minor consolidation. Each phase is independently testable by verifying the service still compiles and runs. - -**Tech Stack:** TypeScript (ESM), Express 5, ws, Discord.js selfbot, Drizzle ORM, Redis (ioredis), pino logger, Biome (formatter) - -## Global Constraints - -- All files use ESM (`.js` extensions in imports) -- Biome formatter handles formatting — run `pnpm run format` after each phase -- TypeScript strict mode — run `pnpm run typecheck` after each phase (for node services) -- Logging uses `createChildLogger(context)` from `@bete/shared/logger` -- Import via barrel files where available -- No logic changes — pure refactoring - ---- - -## Task 1: Fix messages.controller.ts pattern (Phase 1) - -**Files:** -- Modify: `services/backend/src/modules/messages/messages.controller.ts` - -**Interfaces:** -- Consumes: `asyncHandler` from `../../shared/middlewares/index.js` -- Produces: Same exported handler functions, but using decorator pattern - -- [ ] **Step 1: Read current messages.controller.ts** - -The file currently uses the convoluted pattern: -```ts -export function handleListMessages(req, res, next) { - return asyncHandler(async (req, res) => { - // ... - })(req, res, next); -} -``` - -- [ ] **Step 2: Rewrite all handlers to decorator pattern** - -Replace every handler to use the clean decorator pattern: - -```ts -import { createChildLogger } from "@bete/shared/logger"; -import type { Request, Response } from "express"; -import { asyncHandler } from "../../shared/middlewares/index.js"; -import { messageQuerySchema } from "./messages.schema.js"; -import { messagesService } from "./messages.service.js"; - -const logger = createChildLogger("messages.controller"); - -export const handleListMessages = asyncHandler(async (req: Request, res: Response) => { - const query = messageQuerySchema.parse(req.query); - logger.debug({ query }, "Handling list messages request"); - const result = await messagesService.listMessages(query); - res.json(result); -}); - -export const handleGetMessagesByChannel = asyncHandler(async (req: Request, res: Response) => { - const channelId = String(req.params.channelId ?? ""); - if (!channelId) { - res.status(400).json({ error: "MISSING_CHANNEL_ID" }); - return; - } - const query = messageQuerySchema.parse(req.query); - logger.debug({ channelId, query }, "Handling get messages by channel"); - const result = await messagesService.getMessagesByChannel(channelId, query); - res.json(result); -}); - -export const handleGetMessageById = asyncHandler(async (req: Request, res: Response) => { - const id = String(req.params.id ?? ""); - if (!id) { - res.status(400).json({ error: "MISSING_ID" }); - return; - } - logger.debug({ id }, "Handling get message by ID"); - const result = await messagesService.getMessageById(id); - res.json(result); -}); - -export const handleGetImageMessages = asyncHandler(async (req: Request, res: Response) => { - const guildId = String(req.query.guildId ?? ""); - if (!guildId) { - res.status(400).json({ error: "MISSING_GUILD_ID" }); - return; - } - const limit = Number(req.query.limit) || 50; - logger.debug({ guildId, limit }, "Handling get image messages"); - const result = await messagesService.getImageMessages(guildId, limit); - res.json(result); -}); - -export const handleGetAttachmentsByChannel = asyncHandler(async (req: Request, res: Response) => { - const channelId = String(req.params.channelId ?? ""); - if (!channelId) { - res.status(400).json({ error: "MISSING_CHANNEL_ID" }); - return; - } - const query = messageQuerySchema.parse(req.query); - logger.debug({ channelId, query }, "Handling get attachments by channel"); - const result = await messagesService.getAttachmentsByChannel(channelId, query); - res.json(result); -}); -``` - -NOTE: The old pattern used `requireParam` from middlewares to validate params. The new pattern uses simple string checks with early returns. This is equivalent since `requireParam` threw `ValidationError` which the errorHandler middleware catches — but for these handlers the decorator pattern can't throw synchronously in the handler wrapper; the `asyncHandler` catches async rejects. Early return with explicit error response is cleaner. - -- [ ] **Step 3: Verify the module still compiles** - -Run: `cd /home/code/GMW && pnpm run typecheck` -Expected: No TypeScript errors - -- [ ] **Step 4: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 5: Commit** - -```bash -git add services/backend/src/modules/messages/messages.controller.ts -git commit -m "refactor(backend): fix messages.controller.ts to use decorator pattern - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Clean up response.ts usage (Phase 2) - -**Files:** -- Modify: `services/backend/src/modules/health/health.controller.ts` (remove `success()` usage, use plain `res.json()`) -- Modify: `services/backend/src/modules/response.ts` (deprecate/remove) - -**Interfaces:** -- Consumes: all response-producing route files -- Produces: consistent plain `res.json()` pattern everywhere - -- [ ] **Step 1: Check all places that import from response.ts** - -Run: `grep -r 'from.*response\.js' services/backend/src/` - -- [ ] **Step 2: Remove `success()` usage from health.controller.ts** - -Replace: -```ts -import { success } from "../response.js"; -// ... -res.status(status).json(success(result)); -``` -With: -```ts -res.status(status).json({ success: true, data: result }); -``` - -- [ ] **Step 3: Run biome format + typecheck** - -Run: `cd /home/code/GMW && pnpm run format && pnpm run typecheck` - -- [ ] **Step 4: Commit** - -```bash -git add services/backend/src/modules/health/health.controller.ts services/backend/src/modules/response.ts -git commit -m "refactor(backend): remove response.ts helpers, inline health response - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Add ws/ barrel (Phase 3) - -**Files:** -- Create: `services/backend/src/ws/index.ts` - -- [ ] **Step 1: Create barrel file** - -```ts -export { setBroadcastFunctions, clearBroadcastFunctions, broadcastEvent, broadcastBinary } from "./broadcast.js"; -export { startRedisBridge, stopRedisBridge } from "./redis-bridge.js"; -export { createWebSocketServer, closeWebSocketServer } from "./server.js"; -``` - -- [ ] **Step 2: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 3: Commit** - -```bash -git add services/backend/src/ws/index.ts -git commit -m "refactor(backend): add ws barrel index - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Split moderationPrompt.ts (Phase 4) - -**Files:** -- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/text-analysis.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/media-analysis.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/stickers.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/emojis.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/system.ts` -- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts` (become barrel re-export) - -- [ ] **Step 1: Read the full moderationPrompt.ts** - -Read the file to identify all exports and their dependencies. - -- [ ] **Step 2: Create `prompts/system.ts` — system prompt builder + shared helpers** - -Move: `buildSystemPrompt` function, `sanitizeAiContent`, `escapeXml`, `buildCustomEmojiVisionPrompt`, any shared helper functions. - -- [ ] **Step 3: Create `prompts/text-analysis.ts` — text moderation prompts** - -Move: All text-specific prompt strings and builders. - -- [ ] **Step 4: Create `prompts/media-analysis.ts` — image/video prompts** - -Move: `buildGeneralImageVisionPrompt` and related media prompt builders. - -- [ ] **Step 5: Create `prompts/stickers.ts` — sticker prompts** - -Move: `buildStickerVisionPrompt`, `buildStickerTextOnlyWarning`. - -- [ ] **Step 6: Create `prompts/emojis.ts` — emoji prompts** - -Move: `buildCustomEmojiVisionPrompt` if it exists separately. - -- [ ] **Step 7: Replace moderationPrompt.ts with barrel re-exports** - -```ts -export { buildSystemPrompt, sanitizeAiContent } from "./prompts/system.js"; -export { buildGeneralImageVisionPrompt } from "./prompts/media-analysis.js"; -export { buildStickerVisionPrompt, buildStickerTextOnlyWarning } from "./prompts/stickers.js"; -export { buildCustomEmojiVisionPrompt } from "./prompts/emojis.js"; -``` - -- [ ] **Step 8: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` -Expected: No errors. Existing importers continue to work via the barrel. - -- [ ] **Step 9: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 10: Commit** - -```bash -git add services/discord-gateway/src/modules/ai-moderation/prompts/ services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts -git commit -m "refactor(gateway): split moderationPrompt.ts into domain-specific prompt files - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Split moderationOrchestrator.ts (Phase 5) - -**Files:** -- Create: `services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts` -- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts` (extract & re-export) -- Modify: `services/discord-gateway/src/modules/ai-moderation/index.ts` (update exports if needed) - -- [ ] **Step 1: Read full moderationOrchestrator.ts** - -Map all exports and dependencies. - -- [ ] **Step 2: Extract `runTextOnlyBatch` into `textBatchProcessor.ts`** - -Move the function and its helper `buildCorrectedFewShotExamples`. Export it. - -- [ ] **Step 3: Extract `runMediaBatch` into `mediaBatchProcessor.ts`** - -Move the function and all its dependencies. Export it. - -- [ ] **Step 4: Extract `runSimpleTextFallback` into `simpleFallback.ts`** - -Move the function. Export it. - -- [ ] **Step 5: Update moderationOrchestrator.ts** - -Replace extracted functions with imports: -```ts -export { runTextOnlyBatch } from "./textBatchProcessor.js"; -export { runMediaBatch } from "./mediaBatchProcessor.js"; -export { runSimpleTextFallback } from "./simpleFallback.js"; -``` -Keep the `runModerationAnalysis` entry point function which orchestrates text + media + caching. - -- [ ] **Step 6: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 7: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 8: Commit** - -```bash -git add services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts -git commit -m "refactor(gateway): split moderationOrchestrator into dedicated processors - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: Split mediaAnalysisClient.ts (Phase 6) - -**Files:** -- Create: `services/discord-gateway/src/modules/ai-moderation/mediaCache.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts` -- Create: `services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts` -- Modify: `services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts` (become barrel) - -- [ ] **Step 1: Read full mediaAnalysisClient.ts** - -Map all exports and dependencies across the 826 lines. - -- [ ] **Step 2: Extract all cache logic into `mediaCache.ts`** - -Move: LRU cache, phash dedup, `getCachedMediaAnalysis`, `setCachedMediaAnalysis`, `computeImagePhash`, `deleteCachedMediaAnalysis`, `acquireMediaAnalysisLock`. - -- [ ] **Step 3: Extract all download logic into `mediaDownloader.ts`** - -Move: Image download, video download, ffmpeg frame extraction, temporary file handling. - -- [ ] **Step 4: Extract vision LLM logic into `visionAnalyzer.ts`** - -Move: Vision LLM calls, message preparation for vision, `prepareMediaMessage`. - -- [ ] **Step 5: Update mediaAnalysisClient.ts to re-export** - -```ts -export { getCachedMediaAnalysis, setCachedMediaAnalysis, computeImagePhash } from "./mediaCache.js"; -export { downloadAndExtractFrame } from "./mediaDownloader.js"; -export { prepareMediaMessage, hasMediaContent } from "./visionAnalyzer.js"; -``` - -- [ ] **Step 6: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 7: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 8: Commit** - -```bash -git add services/discord-gateway/src/modules/ai-moderation/mediaCache.ts services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts -git commit -m "refactor(gateway): split mediaAnalysisClient into cache, downloader, and vision analyzer - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: Extract retention cleanup from bootstrap.ts (Phase 7) - -**Files:** -- Create: `services/discord-gateway/src/app/retention.ts` -- Modify: `services/discord-gateway/src/app/bootstrap.ts` - -- [ ] **Step 1: Create `app/retention.ts`** - -Move `deleteExpiredRecords` and `startRetentionCleanup` from `bootstrap.ts`: -```ts -import { createChildLogger } from "@bete/shared/logger"; -import { lt, inArray } from "drizzle-orm"; -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { config } from "../shared/config/config.js"; -import { getDatabase } from "../shared/database/drizzle.js"; -import * as schema from "../shared/database/schema.js"; -import { messagesTable, attachmentsTable, voiceRecordingsTable } from "../shared/database/schema.js"; - -const log = createChildLogger("retention"); - -// ... move deleteExpiredRecords here ... - -// ... move startRetentionCleanup here ... - -export { startRetentionCleanup }; -``` - -- [ ] **Step 2: Remove inline retention code from bootstrap.ts** - -- Remove the `deleteExpiredRecords` function -- Remove the `startRetentionCleanup` function -- Add: `import { startRetentionCleanup } from "./retention.js";` -- Replace the call: call `startRetentionCleanup()` directly - -- [ ] **Step 3: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 4: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 5: Commit** - -```bash -git add services/discord-gateway/src/app/retention.ts services/discord-gateway/src/app/bootstrap.ts -git commit -m "refactor(gateway): extract retention cleanup from bootstrap into dedicated module - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 8: Consolidate EventBroadcaster (Phase 8) - -**Files:** -- Modify: `services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts` -- Modify: `services/discord-gateway/src/modules/event-broadcaster/index.ts` - -- [ ] **Step 1: Read current eventBroadcaster.ts** - -Identify `RedisEventPublisher` and `EventBroadcaster` classes. - -- [ ] **Step 2: Merge RedisEventPublisher into EventBroadcaster** - -Inline `RedisEventPublisher` as a private detail inside `EventBroadcaster`. Keep the public API unchanged. - -- [ ] **Step 3: Update index.ts if needed** - -Ensure the barrel still exports `EventBroadcaster`. - -- [ ] **Step 4: Run typecheck** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 5: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 6: Commit** - -```bash -git add services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts services/discord-gateway/src/modules/event-broadcaster/index.ts -git commit -m "refactor(gateway): merge RedisEventPublisher into EventBroadcaster - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 9: Cross-cutting database initialization dedup (Phase 9) - -**Files:** -- Modify: `packages/shared/src/database/schema.ts` — add database lifecycle helpers -- Modify: `services/backend/src/shared/database/index.ts` — use shared helpers -- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — use shared helpers - -- [ ] **Step 1: Check current shared database setup** - -Read `packages/shared/` structure to see if there's already a database module. - -- [ ] **Step 2: Add pool creation helper in @bete/shared** - -In `packages/shared/src/database/schema.ts` or create `packages/shared/src/database/pool.ts`: - -```ts -import { Pool } from "pg"; - -export function createPostgresPool(url: string, opts?: { min?: number; max?: number }): Pool { - return new Pool({ - connectionString: url, - min: opts?.min ?? 2, - max: opts?.max ?? 10, - }); -} - -export interface PoolConfig { - host?: string; - port?: number; - user?: string; - password?: string; - database?: string; - url?: string; - min?: number; - max?: number; -} - -export function createPoolFromConfig(cfg: PoolConfig): Pool { - if (cfg.url) return createPostgresPool(cfg.url, { min: cfg.min, max: cfg.max }); - return new Pool({ - host: cfg.host, - port: cfg.port, - user: cfg.user, - password: cfg.password, - database: cfg.database, - min: cfg.min ?? 2, - max: cfg.max ?? 10, - }); -} -``` - -Export from `packages/shared/src/database/schema.ts` or create a barrel. - -- [ ] **Step 3: Update backend's shared/database/index.ts** - -Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`. - -- [ ] **Step 4: Update gateway's shared/database/drizzle.ts** - -Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`. - -- [ ] **Step 5: Run typecheck across all services** - -Run: `cd /home/code/GMW && pnpm run typecheck` - -- [ ] **Step 6: Run biome format** - -Run: `cd /home/code/GMW && pnpm run format` - -- [ ] **Step 7: Commit** - -```bash -git add packages/shared/src/database/ services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts -git commit -m "refactor: extract shared database pool creation into @bete/shared - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 10: Audit moderationState vs conversationState overlap (Phase 10) - -**Files:** -- Read: `services/discord-gateway/src/modules/ai-moderation/moderationState.ts` -- Read: `services/discord-gateway/src/modules/ai-moderation/conversationState.ts` - -- [ ] **Step 1: Read both files and identify overlap** - -Look for duplicated state management (maps, sets, timers). - -- [ ] **Step 2: If overlap found, merge into one file** - -Otherwise, just add comments documenting the boundary. - -- [ ] **Step 3: Commit** - -```bash -git add services/discord-gateway/src/modules/ai-moderation/ -git commit -m "refactor(gateway): consolidate conversattion/moderation state management - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 11: Redis connection audit (Phase 11) - -**Files:** -- Read: all Redis connection sites in gateway - -- [ ] **Step 1: Identify all Redis connections** - -Search for `new Redis(` patterns in gateway. - -- [ ] **Step 2: Verify each has a valid reason for a separate connection** - -Document with comments if needed. - -- [ ] **Step 3: Commit (if any changes made)** - diff --git a/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md b/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md deleted file mode 100644 index f142165..0000000 --- a/docs/superpowers/plans/2026-07-28-discord-automod-redesign.md +++ /dev/null @@ -1,3581 +0,0 @@ -# Discord Automod — Neo Surveillance Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Full frontend redesign with glassmorphic dark theme, floating top nav, Live2D chatbot, and rich analytics dashboard. - -**Architecture:** No routing or state management changes — same Next.js App Router, TanStack Query, WebSocket context. Only visual layer and component structure rewritten. New glass component system wraps existing logic. - -**Tech Stack:** Next.js 16 (static export), Tailwind v4, shadcn/ui, Recharts 3.8, Live2D Cubism SDK (WebGL), JetBrains Mono + Inter fonts. - -## Global Constraints - -- All files under `services/frontend/src/` — absolute imports via `@/` alias -- All dashboard pages are `"use client"` — preserve this -- API client at `src/lib/api/` — do not modify -- WS context at `src/lib/ws/context.tsx` — do not modify -- WS event types at `src/lib/ws/types.ts` — do not modify -- Hooks at `src/hooks/` — preserve signatures, may add new hooks -- Types at `src/lib/types/` — do not modify -- Format utils at `src/lib/format.ts` — do not modify -- Tailwind v4 — use `@theme inline` tokens, not `tailwind.config` -- All colors in OKLCH — never hex or HSL -- Radius tokens use `var(--radius-*)` scale -- All glass effects: `backdrop-blur-xl` + low-opacity bg + subtle border - ---- - -## File Structure Map - -### Modified files: -| File | Change | -|------|--------| -| `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations | -| `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata | -| `src/app/page.tsx` | Redirect `/dashboard` not `/messages` | -| `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, chatbot context, media context, WS provider | -| `src/lib/navigation.ts` | New nav items (no Search link), mobile items updated | -| `src/app/(dashboard)/dashboard/page.tsx` | Full rewrite — Ops Center | -| `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane | -| `src/app/(dashboard)/voice/page.tsx` | Full rewrite — Connection Center | -| `src/app/(dashboard)/recordings/page.tsx` | Full rewrite — Library | -| `src/app/(dashboard)/settings/page.tsx` | Full rewrite — Glass cards | - -### New component files: -| File | Responsibility | -|------|---------------| -| `src/components/layout/top-nav.tsx` | Floating top nav bar | -| `src/components/layout/sub-nav.tsx` | Per-page sub-navigation tabs | -| `src/components/layout/hidden-sidebar.tsx` | Hover-activated guild sidebar | -| `src/components/layout/mobile-nav.tsx` | Redesigned mobile bottom nav | -| `src/components/glass/card.tsx` | Glass card (base, elevated, interactive, danger) | -| `src/components/glass/panel.tsx` | Glass panel wrapper | -| `src/components/glass/divider.tsx` | Glass-styled separator | -| `src/components/dashboard/stat-card.tsx` | Stat card with micro sparkline | -| `src/components/dashboard/live-stream.tsx` | Auto-scrolling message stream | -| `src/components/dashboard/mod-queue.tsx` | Moderation queue | -| `src/components/dashboard/message-trend-chart.tsx` | 7-day area chart | -| `src/components/dashboard/activity-heatmap.tsx` | Hour × day heatmap | -| `src/components/dashboard/top-channels-chart.tsx` | Top channels bar chart | -| `src/components/messages/message-list.tsx` | Left pane message list | -| `src/components/messages/message-card.tsx` | Redesigned message card | -| `src/components/messages/message-detail.tsx` | Right pane detail view | -| `src/components/messages/attachments-grid.tsx` | Attachments gallery | -| `src/components/messages/ai-analysis-panel.tsx` | AI analysis breakdown | -| `src/components/messages/search-overlay.tsx` | Cmd+K spotlight search | -| `src/components/voice/connection-card.tsx` | Voice connection + status | -| `src/components/voice/speaker-waveform.tsx` | Canvas waveform | -| `src/components/voice/mic-control.tsx` | Mic toggle + volume | -| `src/components/voice/activity-timeline.tsx` | Voice activity chart | -| `src/components/recordings/recording-card.tsx` | Glass card + waveform preview | -| `src/components/recordings/recording-player.tsx` | Inline audio player | -| `src/components/chatbot/chatbot-container.tsx` | Floating L2D container | -| `src/components/chatbot/chatbot-canvas.tsx` | WebGL Live2D renderer | -| `src/components/chatbot/chat-panel.tsx` | Chat input + history | -| `src/components/chatbot/chatbot-context.tsx` | Context provider | -| `src/components/media/mini-player.tsx` | Floating media player | -| `src/components/shared/error-boundary.tsx` | Per-page error boundary | -| `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton | -| `src/components/shared/empty-state.tsx` | Empty state | -| `src/lib/hooks/use-media-player.ts` | Global media player context | -| `src/lib/hooks/use-chatbot.ts` | Chatbot context hook | - -### Deleted files (replaced by new components): -| File | Replaced by | -|------|-------------| -| `src/components/layout/app-sidebar.tsx` | `top-nav.tsx` + `hidden-sidebar.tsx` | -| `src/components/layout/app-header.tsx` | `top-nav.tsx` + `sub-nav.tsx` | -| `src/components/chatbot/chatbot.tsx` | `chatbot/` components | -| `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` | -| `src/components/shared/detail-stat.tsx` | inline in detail views | -| `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` | -| `src/components/messages/review-list.tsx` | part of `message-list.tsx` (filtered) | -| `src/components/messages/message-detail-view.tsx` | `message-detail.tsx` | - ---- - -## Tasks - -### Task 1: Design Tokens & Global CSS Foundation - -**Files:** -- Modify: `src/app/globals.css` — complete rewrite - -**Interfaces:** -- Produces: CSS custom properties consumed by ALL components - -- [ ] **Step 1: Write dark-theme design tokens** - -```css -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - /* Canvas — deep navy */ - --color-canvas: oklch(0.07 0.015 250); - --color-surface: oklch(0.11 0.02 245 / 0.6); - --color-surface-hover: oklch(0.15 0.02 245 / 0.7); - - /* Glass */ - --color-glass-bg: oklch(1 0 0 / 0.04); - --color-glass-border: oklch(1 0 0 / 0.08); - --glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4); - - /* Primary — teal-cyan */ - --color-primary: oklch(0.62 0.17 215); - --color-primary-glow: oklch(0.62 0.17 215 / 0.4); - --color-primary-foreground: oklch(0.98 0 0); - --color-border: oklch(1 0 0 / 0.06); - --color-border-glow: oklch(0.62 0.17 215 / 0.3); - - /* Accents */ - --color-accent-purple: oklch(0.65 0.2 280); - --color-accent-amber: oklch(0.7 0.17 75); - --color-destructive: oklch(0.577 0.245 27.325); - --color-success: oklch(0.6 0.18 160); - - /* Text */ - --color-text-primary: oklch(0.93 0.01 245); - --color-text-secondary: oklch(0.55 0.02 245); - --color-text-mono: oklch(0.62 0.17 215); - - /* Legacy overrides for shadcn compatibility */ - --color-background: var(--color-canvas); - --color-foreground: var(--color-text-primary); - --color-card: var(--color-surface); - --color-card-foreground: var(--color-text-primary); - --color-muted: oklch(0.17 0.015 245); - --color-muted-foreground: var(--color-text-secondary); - --color-accent: var(--color-primary); - --color-accent-foreground: var(--color-primary-foreground); - - /* Radius */ - --radius-card: 16px; - --radius-panel: 12px; - --radius-control: 8px; - --radius-pill: 9999px; - --radius: 0.625rem; /* shadcn compat */ - - /* Fonts */ - --font-sans: "Inter", sans-serif; - --font-mono: "JetBrains Mono", monospace; -} -``` - -- [ ] **Step 2: Add glass utility classes** - -```css -@layer utilities { - .glass { - background: var(--color-glass-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--color-glass-border); - box-shadow: var(--glass-shadow); - } - .glass-elevated { - background: var(--color-glass-bg); - backdrop-filter: blur(16px); - border: 1px solid var(--color-border-glow); - box-shadow: 0 8px 32px oklch(0 0 0 / 0.5), 0 0 20px var(--color-primary-glow); - } - .glass-intense { - background: oklch(1 0 0 / 0.08); - backdrop-filter: blur(20px); - border: 1px solid oklch(1 0 0 / 0.12); - } -} -``` - -- [ ] **Step 3: Add ambient background + animations** - -```css -@layer base { - * { @apply border-border outline-ring/50; } - body { - @apply bg-canvas text-text-primary font-sans antialiased; - background-image: - radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px), - radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.62 0.17 215 / 0.06), transparent), - radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent); - background-size: 24px 24px, 100% 100%, 100% 100%; - } - ::-webkit-scrollbar { width: 6px; height: 6px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; } - ::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.2); } -} - -@keyframes pulse-ring { - 0% { transform: scale(0.8); opacity: 1; } - 100% { transform: scale(2.5); opacity: 0; } -} -@keyframes fade-in-up { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -.animate-fade-in-up { animation: fade-in-up 0.3s ease-out forwards; } -.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; } -.animate-shimmer { background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent); background-size: 200% 100%; animation: shimmer 1.5s infinite; } -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/app/globals.css -git commit -m "feat: add design tokens, glass utilities, and ambient animations" -``` - ---- - -### Task 2: Root Layout & Fonts - -**Files:** -- Modify: `src/app/layout.tsx` - -- [ ] **Step 1: Rewrite root layout with Inter + JetBrains Mono fonts** - -```tsx -import type { Metadata } from "next"; -import { Inter, JetBrains_Mono } from "next/font/google"; -import Script from "next/script"; -import { Toaster } from "@/components/ui/sonner"; -import "./globals.css"; - -const inter = Inter({ - subsets: ["latin"], - variable: "--font-inter", -}); - -const jetbrainsMono = JetBrains_Mono({ - subsets: ["latin"], - variable: "--font-jetbrains-mono", -}); - -export const metadata: Metadata = { - title: "Discord Automod — Moderation Dashboard", - description: "AI-powered Discord moderation and voice monitoring dashboard", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - - - {children} - - - - ); -} -``` - -- [ ] **Step 2: Update redirect** - -In `src/app/page.tsx`, change redirect from `/messages` to `/dashboard`: - -```tsx -import { redirect } from "next/navigation"; -export default function RootPage() { - redirect("/dashboard"); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/app/layout.tsx src/app/page.tsx -git commit -m "feat: update root layout with new fonts and redirect to dashboard" -``` - ---- - -### Task 3: Navigation Config - -**Files:** -- Modify: `src/lib/navigation.ts` - -**Interfaces:** -- Produces: `navItems` array consumed by `top-nav.tsx`, `mobile-nav.tsx` - -- [ ] **Step 1: Rewrite navigation items** - -```tsx -import { - LayoutDashboard, - MessageSquare, - Mic, - Headphones, - Settings, - type LucideIcon, -} from "lucide-react"; - -export interface NavItem { - href: string; - label: string; - icon: LucideIcon; -} - -export interface NavItemWithMatch extends NavItem { - matchPrefix: string; -} - -export const navItems: NavItemWithMatch[] = [ - { - href: "/dashboard", - label: "Dashboard", - icon: LayoutDashboard, - matchPrefix: "/dashboard", - }, - { - href: "/messages", - label: "Messages", - icon: MessageSquare, - matchPrefix: "/messages", - }, - { - href: "/voice", - label: "Voice", - icon: Mic, - matchPrefix: "/voice", - }, - { - href: "/recordings", - label: "Recordings", - icon: Headphones, - matchPrefix: "/recordings", - }, - { - href: "/settings", - label: "Settings", - icon: Settings, - matchPrefix: "/settings", - }, -]; - -export const mobileNavItems: NavItemWithMatch[] = navItems.filter((item) => - ["/dashboard", "/messages", "/voice", "/recordings"].includes(item.href), -); - -export function isActivePath( - pathname: string, - matchPrefix: string, -): boolean { - if (matchPrefix === "/dashboard") return pathname === "/dashboard"; - return pathname.startsWith(matchPrefix); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/lib/navigation.ts -git commit -m "feat: update navigation config — remove search link, add recordings" -``` - ---- - -### Task 4: Glass Component System - -**Files:** -- Create: `src/components/glass/card.tsx` -- Create: `src/components/glass/panel.tsx` -- Create: `src/components/glass/divider.tsx` - -**Interfaces:** -- Produces: ``, ``, `` - -- [ ] **Step 1: Create GlassCard** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; -import type { ComponentPropsWithoutRef } from "react"; - -type GlassVariant = "base" | "elevated" | "interactive" | "danger"; - -interface GlassCardProps extends ComponentPropsWithoutRef<"div"> { - variant?: GlassVariant; -} - -const variantStyles: Record = { - base: "glass rounded-[var(--radius-card)]", - elevated: - "glass-elevated rounded-[var(--radius-card)]", - interactive: - "glass rounded-[var(--radius-card)] transition-all duration-150 hover:scale-[1.01] hover:border-[var(--color-border-glow)] cursor-pointer", - danger: - "glass rounded-[var(--radius-card)] border-red-500/30", -}; - -export function GlassCard({ - variant = "base", - className, - children, - ...props -}: GlassCardProps) { - return ( -
- {children} -
- ); -} -``` - -- [ ] **Step 2: Create GlassPanel** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; -import type { ComponentPropsWithoutRef } from "react"; - -interface GlassPanelProps extends ComponentPropsWithoutRef<"div"> { - dense?: boolean; -} - -export function GlassPanel({ - dense = false, - className, - children, - ...props -}: GlassPanelProps) { - return ( -
- {children} -
- ); -} -``` - -- [ ] **Step 3: Create GlassDivider** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; - -export function GlassDivider({ className }: { className?: string }) { - return ( -
- ); -} -``` - -- [ ] **Step 4: Create barrel export** - -```tsx -// src/components/glass/index.ts -export { GlassCard } from "./card"; -export { GlassPanel } from "./panel"; -export { GlassDivider } from "./divider"; -``` - -- [ ] **Step 5: Commit** - -```bash -git add src/components/glass/ -git commit -m "feat: add glass component system — GlassCard, GlassPanel, GlassDivider" -``` - ---- - -### Task 5: Floating Top Nav - -**Files:** -- Create: `src/components/layout/top-nav.tsx` - -**Interfaces:** -- Consumes: `navItems` from `@/lib/navigation` -- Produces: `` used in dashboard layout - -- [ ] **Step 1: Create TopNav component** - -```tsx -"use client"; - -import { usePathname, useRouter } from "next/navigation"; -import { Moon, Sun } from "lucide-react"; -import { useEffect, useState } from "react"; -import { navItems, isActivePath } from "@/lib/navigation"; - -export function TopNav() { - const pathname = usePathname(); - const router = useRouter(); - const [theme, setTheme] = useState<"light" | "dark">("dark"); - - useEffect(() => { - const stored = localStorage.getItem("theme") as "light" | "dark" | null; - if (stored) setTheme(stored); - }, []); - - const toggleTheme = () => { - const next = theme === "dark" ? "light" : "dark"; - setTheme(next); - localStorage.setItem("theme", next); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(next); - }; - - return ( -
- {/* Brand */} -
-
- D - -
- - Discord Automod - -
- - {/* Nav links */} - - - {/* Right side */} -
- -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/layout/top-nav.tsx -git commit -m "feat: add floating top navigation bar" -``` - ---- - -### Task 6: Sub-navigation & Hidden Sidebar - -**Files:** -- Create: `src/components/layout/sub-nav.tsx` -- Create: `src/components/layout/hidden-sidebar.tsx` - -- [ ] **Step 1: Create SubNav** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; - -interface SubNavTab { - id: string; - label: string; - icon?: React.ReactNode; -} - -interface SubNavProps { - tabs: SubNavTab[]; - activeTab: string; - onTabChange: (tab: string) => void; - className?: string; -} - -export function SubNav({ tabs, activeTab, onTabChange, className }: SubNavProps) { - return ( -
- {tabs.map((tab) => ( - - ))} -
- ); -} -``` - -- [ ] **Step 2: Create HiddenSidebar** - -```tsx -"use client"; - -import { useState } from "react"; -import { GuildSelector } from "@/components/shared/guild-selector"; - -interface HiddenSidebarProps { - guildId: string; - onGuildChange: (guildId: string | null) => void; -} - -export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) { - const [visible, setVisible] = useState(false); - let hideTimer: ReturnType | null = null; - - const handleMouseEnter = () => { - if (hideTimer) clearTimeout(hideTimer); - setVisible(true); - }; - - const handleMouseLeave = () => { - hideTimer = setTimeout(() => setVisible(false), 300); - }; - - return ( - <> - {/* Hotspot trigger */} -
- - {/* Sidebar */} -
-
- - Guilds - -
-
- -
-
- - ); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/sub-nav.tsx src/components/layout/hidden-sidebar.tsx -git commit -m "feat: add sub-navigation tabs and hidden hover sidebar" -``` - ---- - -### Task 7: Dashboard Layout (New) - -**Files:** -- Modify: `src/app/(dashboard)/layout.tsx` -- Delete: `src/components/layout/app-sidebar.tsx`, `src/components/layout/app-header.tsx`, `src/components/chatbot/chatbot.tsx` (replaced) - -**Interfaces:** -- Produces: Wraps all dashboard pages with TopNav + providers - -- [ ] **Step 1: Rewrite dashboard layout** - -```tsx -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Suspense } from "react"; -import { TopNav } from "@/components/layout/top-nav"; -import { MobileNav } from "@/components/layout/mobile-nav"; -import { WsProvider } from "@/lib/ws/context"; -import { ChatbotProvider } from "@/components/chatbot/chatbot-context"; -import { ChatbotContainer } from "@/components/chatbot/chatbot-container"; -import { MiniPlayer } from "@/components/media/mini-player"; -import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; -import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; -import { useState } from "react"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 10_000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - const [guildId, setGuildId] = useState(""); - - return ( - - - - -
- - setGuildId(g ?? "")} /> - - {/* Sub-nav space — filled per-page */} -
-
- -
-
- } - > - {children} -
-
-
- - - - -
-
-
-
-
- ); -} -``` - -- [ ] **Step 2: Delete replaced layout files** - -```bash -rm src/components/layout/app-sidebar.tsx -rm src/components/layout/app-header.tsx -rm src/components/chatbot/chatbot.tsx -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/app/\(dashboard\)/layout.tsx -git rm src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/chatbot/chatbot.tsx -git commit -m "feat: rewrite dashboard layout with top nav, hidden sidebar, chatbot, mini-player" -``` - ---- - -### Task 8: Mobile Nav (Redesigned) - -**Files:** -- Modify: `src/components/layout/mobile-nav.tsx` - -- [ ] **Step 1: Rewrite mobile nav** - -```tsx -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { mobileNavItems, isActivePath } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; - -export function MobileNav() { - const pathname = usePathname(); - - return ( - - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/layout/mobile-nav.tsx -git commit -m "feat: redesign mobile bottom nav with glass styling" -``` - ---- - -### Task 9: Dashboard — Stat Card with Micro Sparkline - -**Files:** -- Create: `src/components/dashboard/stat-card.tsx` - -**Interfaces:** -- Produces: `` used in Dashboard page - -- [ ] **Step 1: Create StatCard component** - -```tsx -"use client"; - -import { type LucideIcon } from "lucide-react"; -import { GlassCard } from "@/components/glass/card"; -import { cn } from "@/lib/utils"; -import { Area, AreaChart, ResponsiveContainer } from "recharts"; - -interface StatCardProps { - label: string; - value: number | string; - icon: LucideIcon; - variant?: "default" | "danger" | "success"; - sparklineData?: { value: number }[]; - formatter?: (v: number) => string; -} - -export function StatCard({ - label, - value, - icon: Icon, - variant = "default", - sparklineData, - formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v), -}: StatCardProps) { - const accentColor = { - default: "var(--color-primary)", - danger: "var(--color-destructive)", - success: "oklch(0.6 0.18 160)", - }[variant]; - - const bgAccent = { - default: "bg-primary/10 text-primary", - danger: "bg-destructive/10 text-destructive", - success: "bg-emerald-500/10 text-emerald-500", - }[variant]; - - const numValue = typeof value === "number" ? value : Number(value); - - return ( - -
-
- -
-
-
- {formatter(numValue)} -
-
- {label} -
- - {/* Sparkline background */} - {sparklineData && sparklineData.length > 0 && ( -
- - - - - - - - - - - -
- )} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/dashboard/stat-card.tsx -git commit -m "feat: add stat card with micro sparkline chart" -``` - ---- - -### Task 10: Dashboard — Live Stream & Mod Queue - -**Files:** -- Create: `src/components/dashboard/live-stream.tsx` -- Create: `src/components/dashboard/mod-queue.tsx` - -- [ ] **Step 1: Create LiveStream component** - -```tsx -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { GlassCard } from "@/components/glass/card"; -import { useWebSocket } from "@/lib/ws/context"; -import { cn } from "@/lib/utils"; - -interface LiveMessage { - id: string; - content: string; - username: string; - channelName?: string; - timestamp: string; - flagged?: boolean; -} - -export function LiveStream() { - const [messages, setMessages] = useState([]); - const scrollRef = useRef(null); - const ws = useWebSocket(); - - useEffect(() => { - const unsub = ws.on("message_created", (data: any) => { - const msg: LiveMessage = { - id: data.id, - content: data.content || "(attachment)", - username: data.username || "unknown", - channelName: data.channelName, - timestamp: new Date().toLocaleTimeString(), - flagged: data.ai_status === "flagged" || data.ai_status === "warn", - }; - setMessages((prev) => [msg, ...prev].slice(0, 50)); - }); - return () => unsub(); - }, [ws]); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [messages]); - - return ( - -
- - - - - - Live Stream - -
-
- {messages.length === 0 ? ( -
- Waiting for messages... -
- ) : ( - messages.map((msg) => ( -
- - {msg.username} - - - {msg.content} - - - {msg.timestamp} - -
- )) - )} -
-
- ); -} -``` - -- [ ] **Step 2: Create ModQueue component** - -```tsx -"use client"; - -import { AlertCircle, Check, Trash2 } from "lucide-react"; -import { GlassCard } from "@/components/glass/card"; -import { cn } from "@/lib/utils"; - -interface ModQueueItem { - id: string; - content: string; - username: string; - severity: "low" | "medium" | "high" | "critical"; - reason: string; -} - -export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) { - const severityColor = { - low: "text-accent-amber border-accent-amber/30", - medium: "text-accent-purple border-accent-purple/30", - high: "text-destructive border-destructive/40", - critical: "text-destructive border-destructive/60 bg-destructive/10", - }; - - return ( - -
- - - Mod Queue - - {items.length > 0 && ( - - {items.length} pending - - )} -
-
- {items.length === 0 ? ( -
- No flagged messages -
- ) : ( - items.map((item) => ( -
-
- {item.username} - {item.severity} -
-

{item.content}

-

{item.reason}

-
- - -
-
- )) - )} -
-
- ); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/components/dashboard/live-stream.tsx src/components/dashboard/mod-queue.tsx -git commit -m "feat: add live stream and mod queue dashboard components" -``` - ---- - -### Task 11: Dashboard Charts - -**Files:** -- Create: `src/components/dashboard/message-trend-chart.tsx` -- Create: `src/components/dashboard/activity-heatmap.tsx` -- Create: `src/components/dashboard/top-channels-chart.tsx` - -- [ ] **Step 1: Create MessageTrendChart** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; - -interface MessageTrendChartProps { - data?: { date: string; messages: number; flagged: number }[]; -} - -export function MessageTrendChart({ data = [] }: MessageTrendChartProps) { - return ( - -
- Message Trend - 7 days -
-
- - - - - - - - - - - - - - - - - - - -
-
- ); -} -``` - -- [ ] **Step 2: Create ActivityHeatmap** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { cn } from "@/lib/utils"; - -const HOURS = Array.from({ length: 24 }, (_, i) => i); -const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - -interface ActivityHeatmapProps { - data?: Record; // key: "day-hour", value: count -} - -export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) { - const maxVal = Math.max(...Object.values(data), 1); - - const getIntensity = (day: string, hour: number) => { - const val = data[`${day}-${hour}`] || 0; - const pct = val / maxVal; - if (pct === 0) return "bg-surface"; - if (pct < 0.25) return "bg-primary/15"; - if (pct < 0.5) return "bg-primary/30"; - if (pct < 0.75) return "bg-primary/50"; - return "bg-primary/70"; - }; - - return ( - -
- Activity - hour × day -
-
-
- {/* Hour labels */} -
-
- {DAYS.map((d) => ( -
{d}
- ))} -
- {/* Grid */} -
- {HOURS.map((hour) => ( -
- {DAYS.map((day) => ( -
- ))} -
- {hour % 4 === 0 ? hour : ""} -
-
- ))} -
-
-
- - ); -} -``` - -- [ ] **Step 3: Create TopChannelsChart** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; - -interface TopChannelsChartProps { - data?: { name: string; count: number }[]; -} - -export function TopChannelsChart({ data = [] }: TopChannelsChartProps) { - return ( - -
- Top Channels -
-
- - - - - - - - -
-
- ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/dashboard/message-trend-chart.tsx src/components/dashboard/activity-heatmap.tsx src/components/dashboard/top-channels-chart.tsx -git commit -m "feat: add dashboard charts — message trend, activity heatmap, top channels" -``` - ---- - -### Task 12: Dashboard Page (Ops Center) - -**Files:** -- Modify: `src/app/(dashboard)/dashboard/page.tsx` - -- [ ] **Step 1: Rewrite dashboard page** - -```tsx -"use client"; - -import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react"; -import { useState } from "react"; -import { useStats } from "@/hooks"; -import { StatCard } from "@/components/dashboard/stat-card"; -import { LiveStream } from "@/components/dashboard/live-stream"; -import { ModQueue } from "@/components/dashboard/mod-queue"; -import { MessageTrendChart } from "@/components/dashboard/message-trend-chart"; -import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap"; -import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; -import { SubNav } from "@/components/layout/sub-nav"; -import { ErrorState, LoadingSkeleton } from "@/components/shared"; - -type DashboardTab = "stats" | "live" | "activity"; - -export default function DashboardPage() { - const [tab, setTab] = useState("stats"); - const { data: stats, isLoading, error, refetch } = useStats(); - - const subNavTabs = [ - { id: "stats", label: "Stats", icon: }, - { id: "live", label: "Live", icon: }, - { id: "activity", label: "Activity", icon: }, - ]; - - return ( -
- setTab(t as DashboardTab)} /> - - {tab === "stats" && ( -
- {error ? ( - - ) : isLoading || !stats ? ( - - ) : ( - <> -
- - - - - - -
- -
- - -
- - )} -
- )} - - {tab === "live" && ( -
- - -
- )} - - {tab === "activity" && ( -
- -
- )} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/\(dashboard\)/dashboard/page.tsx -git commit -m "feat: rewrite dashboard as Ops Center with stats, live, and activity tabs" -``` - ---- - -### Task 13: Messages — Redesigned Components - -**Files:** -- Create: `src/components/messages/message-card.tsx` (new) -- Create: `src/components/messages/message-list.tsx` -- Create: `src/components/messages/message-detail.tsx` -- Create: `src/components/messages/attachments-grid.tsx` -- Create: `src/components/messages/ai-analysis-panel.tsx` - -- [ ] **Step 1: Create redesigned MessageCard** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; -import { formatRelative } from "@/lib/format"; -import type { MessageRecord } from "@/lib/types"; - -interface MessageCardProps { - message: MessageRecord; - selected?: boolean; - onClick?: (id: string) => void; -} - -const severityDot: Record = { - clean: "bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60", - pending: "bg-text-secondary/30", - warn: "bg-accent-amber shadow-[0_0_6px] shadow-accent-amber/60", - flagged: "bg-accent-purple shadow-[0_0_6px] shadow-accent-purple/60", - critical: "bg-destructive shadow-[0_0_6px] shadow-destructive/60", - error: "bg-destructive/60", -}; - -export function MessageCard({ message, selected, onClick }: MessageCardProps) { - const status = message.ai_status || "pending"; - - return ( - - ); -} -``` - -- [ ] **Step 2: Create MessageList** - -```tsx -"use client"; - -import { MessageCard } from "./message-card"; -import type { MessageRecord } from "@/lib/types"; - -interface MessageListProps { - messages: MessageRecord[]; - selectedId?: string | null; - onSelect: (id: string) => void; -} - -export function MessageList({ messages, selectedId, onSelect }: MessageListProps) { - return ( -
- {messages.length === 0 ? ( -
- No messages -
- ) : ( - messages.map((msg) => ( - - )) - )} -
- ); -} -``` - -- [ ] **Step 3: Create MessageDetail** - -```tsx -"use client"; - -import { ArrowLeft, MessageSquare } from "lucide-react"; -import { GlassCard } from "@/components/glass/card"; -import { AttachmentsGrid } from "./attachments-grid"; -import { AiAnalysisPanel } from "./ai-analysis-panel"; -import type { AttachmentRecord, MessageRecord } from "@/lib/types"; - -interface MessageDetailProps { - message: MessageRecord; - attachments?: AttachmentRecord[]; - onBack?: () => void; -} - -export function MessageDetail({ message, attachments, onBack }: MessageDetailProps) { - return ( - - {onBack && ( - - )} - - {/* Message header */} -
- - {message.username} - {message.channel_id?.slice(0, 8)} -
- - {/* Content */} -
- {message.content || "(no text content)"} -
- - {/* Attachments */} - {attachments && attachments.length > 0 && ( -
- -
- )} - - {/* AI Analysis */} - -
- ); -} -``` - -- [ ] **Step 4: Create AttachmentsGrid** - -```tsx -"use client"; - -import type { AttachmentRecord } from "@/lib/types"; - -interface AttachmentsGridProps { - attachments: AttachmentRecord[]; -} - -export function AttachmentsGrid({ attachments }: AttachmentsGridProps) { - if (attachments.length === 0) return null; - - return ( -
- {attachments.map((att) => ( -
- {att.type?.startsWith("image/") ? ( - {att.filename} - ) : ( -
- {att.filename} -
- )} -
- ))} -
- ); -} -``` - -- [ ] **Step 5: Create AiAnalysisPanel** - -```tsx -"use client"; - -import { GlassPanel } from "@/components/glass/panel"; -import { cn } from "@/lib/utils"; - -interface AiAnalysisPanelProps { - status?: string | null; - severity?: string | null; - confidence?: number | null; - flags?: string[] | null; - categories?: string[] | null; - action?: string | null; - score?: number | null; -} - -const severityColor: Record = { - none: "text-emerald-500", - low: "text-text-secondary", - medium: "text-accent-amber", - high: "text-accent-purple", - critical: "text-destructive", -}; - -export function AiAnalysisPanel({ - status, - severity, - confidence, - flags, - categories, - action, - score, -}: AiAnalysisPanelProps) { - if (!status || status === "pending") { - return ( - - AI analysis pending - - ); - } - - return ( - -
- AI Analysis - - {status} - -
- - {severity && ( -
- Severity: - {severity} -
- )} - - {confidence !== null && confidence !== undefined && ( -
- Confidence: - {(confidence * 100).toFixed(0)}% -
- )} - - {score !== null && score !== undefined && ( -
- Score: - {score.toFixed(2)} -
- )} - - {flags && flags.length > 0 && ( -
- {flags.map((f) => ( - {f} - ))} -
- )} - - {categories && categories.length > 0 && ( -
- {categories.map((c) => ( - {c} - ))} -
- )} - - {action && action !== "none" && ( -
- Recommended: - {action} -
- )} -
- ); -} -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/components/messages/ -git commit -m "feat: add redesigned message components — card, list, detail, attachments, AI panel" -``` - ---- - -### Task 14: Search Overlay - -**Files:** -- Create: `src/components/messages/search-overlay.tsx` - -- [ ] **Step 1: Create SearchOverlay** - -```tsx -"use client"; - -import { Search, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { messagesApi } from "@/lib/api"; -import type { MessageRecord } from "@/lib/types"; - -interface SearchOverlayProps { - open: boolean; - onClose: () => void; - onSelect: (id: string) => void; -} - -export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) { - const [query, setQuery] = useState(""); - const inputRef = useRef(null); - - const { data: results } = useQuery({ - queryKey: ["messages-search", query], - queryFn: async () => { - const res = await messagesApi.search(query, 20); - return res.results; - }, - enabled: query.length >= 2, - }); - - useEffect(() => { - if (open) { - setTimeout(() => inputRef.current?.focus(), 100); - } else { - setQuery(""); - } - }, [open]); - - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - onClose(); // this is called when Cmd+K is pressed globally — toggle - } - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handleKey); - return () => document.removeEventListener("keydown", handleKey); - }, [onClose]); - - if (!open) return null; - - return ( -
-
-
- {/* Input */} -
- - setQuery(e.target.value)} - placeholder="Search messages..." - className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary/40 outline-none" - /> - -
- - {/* Results */} -
- {!results || results.length === 0 ? ( -
- {query.length < 2 ? "Type at least 2 characters" : "No results found"} -
- ) : ( - results.map((msg) => ( - - )) - )} -
-
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/messages/search-overlay.tsx -git commit -m "feat: add Cmd+K search overlay" -``` - ---- - -### Task 15: Messages Page (Split Pane) - -**Files:** -- Modify: `src/app/(dashboard)/messages/page.tsx` — full rewrite - -- [ ] **Step 1: Rewrite messages page with split-pane** - -```tsx -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { Search, Flag, Image, Loader2, RefreshCw } from "lucide-react"; -import { MessageList } from "@/components/messages/message-list"; -import { MessageDetail } from "@/components/messages/message-detail"; -import { SearchOverlay } from "@/components/messages/search-overlay"; -import { SubNav } from "@/components/layout/sub-nav"; -import { ErrorState, LoadingSkeleton } from "@/components/shared"; -import { GlassPanel } from "@/components/glass/panel"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - useGuilds, - useImages, - useLoadMore, - useMessageDetail, - useMessages, - useMessagesHasMore, - useMessagesWsSync, - useReanalyze, - useReanalyzeBatch, - useReview, - useTextChannels, -} from "@/hooks"; -import { useWebSocket } from "@/lib/ws/context"; -import { GuildSelector } from "@/components/shared/guild-selector"; - -type MessagesTab = "all" | "images" | "review"; - -export default function MessagesPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [guildId, setGuildId] = useState(searchParams.get("guild") || ""); - const [selectedChannel, setSelectedChannel] = useState(searchParams.get("channel") || ""); - const [detailId, setDetailId] = useState(searchParams.get("selected")); - const [tab, setTab] = useState((searchParams.get("tab") as MessagesTab) || "all"); - const [searchOpen, setSearchOpen] = useState(false); - - const ws = useWebSocket(); - const { data: channels = [] } = useTextChannels(guildId); - const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined); - const { data: cursorData } = useMessagesHasMore(guildId, selectedChannel || undefined); - const loadMoreMut = useLoadMore(); - const { data: images } = useImages(guildId); - const { data: reviews } = useReview(selectedChannel || undefined); - const reanalyzeMut = useReanalyze(); - const reanalyzeBatchMut = useReanalyzeBatch(); - - const { - message: detailMessage, - attachments: detailAttachments, - loading: detailLoading, - } = useMessageDetail(detailId); - - useMessagesWsSync(ws, guildId); - - // Sync to URL - useEffect(() => { - const params = new URLSearchParams(); - if (guildId) params.set("guild", guildId); - if (selectedChannel) params.set("channel", selectedChannel); - if (detailId) params.set("selected", detailId); - if (tab !== "all") params.set("tab", tab); - router.replace(`/messages?${params.toString()}`, { scroll: false }); - }, [guildId, selectedChannel, detailId, tab, router]); - - // Global Cmd+K - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setSearchOpen(true); - } - }; - document.addEventListener("keydown", handleKey); - return () => document.removeEventListener("keydown", handleKey); - }, []); - - const handleLoadMore = useCallback(() => { - if (!cursorData?.cursor || loadMoreMut.isPending) return; - loadMoreMut.mutate({ - guildId, - channelId: selectedChannel || undefined, - cursor: cursorData.cursor, - }); - }, [cursorData, loadMoreMut, guildId, selectedChannel]); - - const subNavTabs = [ - { id: "all", label: "All", icon: null }, - { id: "images", label: "Images", icon: }, - { id: "review", label: "Review", icon: }, - ]; - - const currentMessages = messages ?? []; - - return ( -
- {/* Controls bar */} -
- { setGuildId(g); setSelectedChannel(""); }} /> - {channels.length > 0 && ( - - )} - - -
- - setTab(t as MessagesTab)} /> - - {/* Split pane */} - {error ? ( - - ) : isLoading ? ( - - ) : ( -
- {/* Left pane — message list */} -
- {tab === "all" && ( - <> - - {cursorData?.hasMore && ( -
- -
- )} - - )} - {tab === "images" && ( - - )} - {tab === "review" && ( - reanalyzeMut.mutate(id)} /> - )} -
- - {/* Right pane — detail */} - {detailId && ( -
- {detailLoading ? ( - - - - ) : detailMessage ? ( - setDetailId(null)} - /> - ) : null} -
- )} -
- )} - - {/* Search overlay */} - setSearchOpen(false)} onSelect={setDetailId} /> -
- ); -} - -// Inline ImageGrid (simplified) and ReviewList -function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) { - return ( -
- {items.map((item: any) => ( - - ))} - {items.length === 0 && ( -
No images
- )} -
- ); -} - -function ReviewList({ items, onSelect, onReanalyze }: { items: any[]; onSelect: (id: string) => void; onReanalyze: (id: string) => void }) { - return ( -
- {items.map((item: any) => ( - onSelect(item.message_id)}> -
- -
-

{item.content || item.id}

-
-
-
- ))} - {items.length === 0 && ( -
No flagged messages
- )} -
- ); -} -``` - -Wait — need to import `cn` and `GlassCard` at top. And the detail view should use detailId from URL on mount. Let me write cleaner version: - -- [ ] **Step 1: Rewrite messages page** - -For brevity: the page uses SubNav with tabs (All/Images/Review), split-pane layout, URL-synced state, and Cmd+K search. Full implementation follows the pattern above but with proper imports. - -- [ ] **Step 2: Commit** - -```bash -git add src/app/\(dashboard\)/messages/page.tsx -git commit -m "feat: rewrite messages page with split-pane layout, sub-nav, and search overlay" -``` - ---- - -### Task 16: Voice Page - -**Files:** -- Create: `src/components/voice/connection-card.tsx` -- Create: `src/components/voice/speaker-waveform.tsx` -- Create: `src/components/voice/mic-control.tsx` -- Create: `src/components/voice/activity-timeline.tsx` -- Modify: `src/app/(dashboard)/voice/page.tsx` - -- [ ] **Step 1: Create ConnectionCard** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; - -interface ConnectionCardProps { - connected: boolean; - activeChannelName?: string; - guilds: { id: string; name: string }[]; - voiceChannels: { id: string; name: string }[]; - selectedGuild: string; - selectedChannel: string; - onGuildChange: (guildId: string | null) => void; - onChannelChange: (channelId: string) => void; - onConnect: () => void; - onDisconnect: () => void; - connecting?: boolean; -} - -export function VoiceConnectionCard({ - connected, activeChannelName, guilds, voiceChannels, - selectedGuild, selectedChannel, - onGuildChange, onChannelChange, onConnect, onDisconnect, connecting, -}: ConnectionCardProps) { - return ( - -
- - - - -
- Voice Connection - {activeChannelName && ( - {activeChannelName} - )} -
-
- {connected ? ( - - ) : ( - - )} -
-
- -
- - -
-
- ); -} -``` - -- [ ] **Step 2: Create SpeakerWaveform** - -```tsx -"use client"; - -import { useEffect, useRef } from "react"; -import { GlassPanel } from "@/components/glass/panel"; - -interface Speaker { - id: string; - name: string; - speaking: boolean; -} - -interface SpeakerWaveformProps { - speakers: Speaker[]; -} - -export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) { - const canvasRef = useRef(null); - const animRef = useRef(0); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || speakers.length === 0) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const draw = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - const barCount = 40; - const barWidth = canvas.width / barCount - 1; - - speakers.forEach((speaker, si) => { - const yBase = si * 30 + 10; - for (let i = 0; i < barCount; i++) { - const height = speaker.speaking - ? Math.random() * 20 + 4 - : Math.random() * 4 + 2; - const x = i * (barWidth + 1); - const hue = 185 + si * 30; - ctx.fillStyle = `oklch(0.62 ${0.12 + si * 0.02} ${hue} / ${speaker.speaking ? 0.9 : 0.3})`; - ctx.fillRect(x, yBase + 20 - height, barWidth, height); - } - }); - - animRef.current = requestAnimationFrame(draw); - }; - - draw(); - return () => cancelAnimationFrame(animRef.current); - }, [speakers]); - - if (speakers.length === 0) { - return ( - - No speakers detected - - ); - } - - return ( - -
- {speakers.map((s) => ( -
- {s.name} -
- ))} -
- -
- ); -} -``` - -- [ ] **Step 3: Create MicControl** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { Button } from "@/components/ui/button"; -import { Mic, MicOff } from "lucide-react"; - -interface MicControlProps { - connected: boolean; - active: boolean; - onToggle: (active: boolean) => void; - volume: number; - onVolumeChange: (v: number) => void; -} - -export function MicControl({ connected, active, onToggle, volume, onVolumeChange }: MicControlProps) { - return ( - -
- -
- Vol - onVolumeChange(Number(e.target.value))} - className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60" - /> - {volume}% -
-
-
- ); -} -``` - -- [ ] **Step 4: Create ActivityTimeline** - -```tsx -"use client"; - -import { GlassCard } from "@/components/glass/card"; -import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; - -interface ActivityTimelineProps { - data?: { user: string; duration: number }[]; -} - -export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) { - return ( - -
- Voice Activity -
-
- - - - - [`${(value / 60).toFixed(1)}m`, "Duration"]} - /> - - - -
-
- ); -} -``` - -- [ ] **Step 5: Rewrite voice page** - -```tsx -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { VoiceConnectionCard } from "@/components/voice/connection-card"; -import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; -import { MicControl } from "@/components/voice/mic-control"; -import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; -import { SubNav } from "@/components/layout/sub-nav"; -import { useWebSocket } from "@/lib/ws/context"; -import { useGuilds, useMicTransmit, useSpeakers, useVoiceChannels, useVoiceConnect, useVoiceDisconnect, useVoiceStatus } from "@/hooks"; - -type VoiceTab = "connection" | "activity"; - -export default function VoicePage() { - const ws = useWebSocket(); - const { data: voiceStatus } = useVoiceStatus(); - const { data: guilds = [] } = useGuilds(); - const [selectedGuild, setSelectedGuild] = useState(""); - const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild); - const { speakers, subscribe } = useSpeakers(); - const connectMut = useVoiceConnect(); - const disconnectMut = useVoiceDisconnect(); - const micMut = useMicTransmit(); - const [selectedChannel, setSelectedChannel] = useState(""); - const [micActive, setMicActive] = useState(false); - const [volume, setVolume] = useState(75); - const [tab, setTab] = useState("connection"); - - useEffect(() => { - const unsub = subscribe(ws); - return () => unsub(); - }, [ws, subscribe]); - - const activeSpeakers = speakers.filter((s) => s.speaking); - const connected = voiceStatus?.connected ?? false; - - return ( -
- setTab(t as VoiceTab)} - /> - - { setSelectedGuild(g ?? ""); setSelectedChannel(""); }} - onChannelChange={setSelectedChannel} - onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })} - onDisconnect={() => disconnectMut.mutate(undefined)} - connecting={connectMut.isPending} - /> - - {tab === "connection" && ( -
- - { - setMicActive(checked); - try { await micMut.mutateAsync(checked); } catch { setMicActive(!checked); } - }} - volume={volume} - onVolumeChange={setVolume} - /> -
- )} - - {tab === "activity" && } -
- ); -} -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/components/voice/ src/app/\(dashboard\)/voice/page.tsx -git commit -m "feat: rewrite voice page with connection card, speaker waveform, mic control, activity" -``` - ---- - -### Task 17: Recordings Page - -**Files:** -- Create: `src/components/recordings/recording-card.tsx` -- Create: `src/components/recordings/recording-player.tsx` -- Modify: `src/app/(dashboard)/recordings/page.tsx` - -- [ ] **Step 1: Create RecordingCard** - -```tsx -"use client"; - -import { Download, Link, Play } from "lucide-react"; -import { GlassCard } from "@/components/glass/card"; -import type { RecordingRecord } from "@/lib/types"; - -interface RecordingCardProps { - recording: RecordingRecord; - onPlay: (id: string) => void; -} - -export function RecordingCard({ recording, onPlay }: RecordingCardProps) { - const durationStr = recording.duration - ? `${Math.floor(recording.duration / 60)}:${String(recording.duration % 60).padStart(2, "0")}` - : "--:--"; - - return ( - onPlay(recording.id)}> -
- - -
-
- {recording.username} - {recording.channel_name} -
- - {/* Mini waveform bar */} -
- {Array.from({ length: 40 }, (_, i) => ( -
- ))} -
- -
- {durationStr} - {new Date(recording.created_at).toLocaleString()} -
-
- -
e.stopPropagation()}> - {recording.download_url && ( - - - - )} -
-
- - ); -} -``` - -- [ ] **Step 2: Create RecordingPlayer** - -```tsx -"use client"; - -import { useEffect, useRef } from "react"; -import { GlassPanel } from "@/components/glass/panel"; -import { X } from "lucide-react"; - -interface RecordingPlayerProps { - url?: string; - onClose: () => void; -} - -export function RecordingPlayer({ url, onClose }: RecordingPlayerProps) { - const audioRef = useRef(null); - - useEffect(() => { - if (url && audioRef.current) { - audioRef.current?.play().catch(() => {}); - } - }, [url]); - - if (!url) return null; - - return ( - - - ); -} -``` - -- [ ] **Step 3: Rewrite recordings page** - -```tsx -"use client"; - -import { useState } from "react"; -import { RecordingCard } from "@/components/recordings/recording-card"; -import { RecordingPlayer } from "@/components/recordings/recording-player"; -import { SubNav } from "@/components/layout/sub-nav"; -import { ErrorState, LoadingSkeleton } from "@/components/shared"; -import { Search } from "lucide-react"; -import { useRecordings } from "@/hooks"; -import { useWebSocket } from "@/lib/ws/context"; - -type RecordingsTab = "library" | "stats"; - -export default function RecordingsPage() { - const ws = useWebSocket(); - const { data: recordings, isLoading, error, refetch } = useRecordings(); - const [playingId, setPlayingId] = useState(null); - const [tab, setTab] = useState("library"); - - const currentTrack = playingId && recordings - ? recordings.find((r: any) => r.id === playingId) - : null; - - return ( -
- setTab(t as RecordingsTab)} - /> - - {tab === "library" && ( - <> - {error ? ( - - ) : isLoading ? ( - - ) : ( -
- {(recordings ?? []).map((rec: any) => ( - setPlayingId(id === playingId ? null : id)} - /> - ))} - {(recordings ?? []).length === 0 && ( -
No recordings yet
- )} -
- )} - - )} - - {tab === "stats" && ( -
Recording stats coming soon
- )} - - setPlayingId(null)} /> -
- ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/recordings/ src/app/\(dashboard\)/recordings/page.tsx -git commit -m "feat: rewrite recordings page with glass cards, waveform preview, inline player" -``` - ---- - -### Task 18: Settings Page - -**Files:** -- Modify: `src/app/(dashboard)/settings/page.tsx` — full rewrite - -- [ ] **Step 1: Rewrite settings page** - -```tsx -"use client"; - -import { Moon, Server, Shield, Sun, Wifi } from "lucide-react"; -import { useEffect, useState } from "react"; -import { GlassCard } from "@/components/glass/card"; -import { GlassDivider } from "@/components/glass/divider"; -import { SubNav } from "@/components/layout/sub-nav"; -import { LoadingSkeleton } from "@/components/shared"; -import { useConfig } from "@/hooks"; -import { useWebSocket } from "@/lib/ws/context"; -import { cn } from "@/lib/utils"; - -type SettingsTab = "connection" | "appearance" | "config" | "about"; - -export default function SettingsPage() { - const { status } = useWebSocket(); - const { data: config, isLoading: configLoading } = useConfig(); - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const [tab, setTab] = useState("connection"); - - useEffect(() => { - const stored = localStorage.getItem("theme") as "light" | "dark" | null; - if (stored) setTheme(stored); - }, []); - - const toggleTheme = () => { - const next = theme === "dark" ? "light" : "dark"; - setTheme(next); - localStorage.setItem("theme", next); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(next); - }; - - const statusDot = { - connected: "bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse", - connecting: "bg-accent-amber animate-pulse", - disconnected: "bg-destructive", - error: "bg-destructive", - }[status]; - - const statusLabel = { - connected: "Connected", - connecting: "Connecting", - disconnected: "Disconnected", - error: "Error", - }[status]; - - return ( -
- }, - { id: "appearance", label: "Appearance", icon: }, - { id: "config", label: "Config", icon: }, - { id: "about", label: "About", icon: }, - ]} - activeTab={tab} - onTabChange={(t) => setTab(t as SettingsTab)} - /> - - {tab === "connection" && ( - -
-
- WebSocket -
-
- - {statusLabel} -
-
-
- )} - - {tab === "appearance" && ( - -
-
- {theme === "dark" ? : } - Theme -
- -
-
- )} - - {tab === "config" && ( - -
- {configLoading ? ( - - ) : config ? ( - <> - - - - - - - - - - - ) : ( -

Unable to load config.

- )} -
-
- )} - - {tab === "about" && ( - -
-

Discord Automod

-

- AI-powered message moderation, voice recording, and real-time monitoring for Discord communities. -

-
- v0.1.0 -
-
-
- )} -
- ); -} - -function ConfigRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/\(dashboard\)/settings/page.tsx -git commit -m "feat: rewrite settings page with glass cards and sub-nav tabs" -``` - ---- - -### Task 19: Shared Components - -**Files:** -- Create: `src/components/shared/error-boundary.tsx` -- Modify: `src/components/shared/loading-skeleton.tsx` -- Modify: `src/components/shared/empty-state.tsx` - -- [ ] **Step 1: Create ErrorBoundary** - -```tsx -"use client"; - -import { Component, type ReactNode } from "react"; -import { GlassCard } from "@/components/glass/card"; -import { AlertCircle, RefreshCw } from "lucide-react"; - -interface Props { children: ReactNode; fallback?: ReactNode; } -interface State { hasError: boolean; error?: Error; } - -export class ErrorBoundary extends Component { - state: State = { hasError: false }; - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; - } - - render() { - if (this.state.hasError) { - return this.props.fallback || ( - - -

{this.state.error?.message || "Something went wrong"}

- -
- ); - } - return this.props.children; - } -} -``` - -- [ ] **Step 2: Update LoadingSkeleton with glass shimmer** - -```tsx -"use client"; - -import { cn } from "@/lib/utils"; - -interface LoadingSkeletonProps { - count?: number; - height?: string; - width?: string; - columns?: number; - className?: string; -} - -export function LoadingSkeleton({ - count = 4, - height = "h-24", - width, - columns, - className, -}: LoadingSkeletonProps) { - const items = Array.from({ length: count }, (_, i) => ( -
-
-
- )); - - if (columns) { - return ( -
- {items} -
- ); - } - - return
{items}
; -} -``` - -- [ ] **Step 3: Update EmptyState** - -```tsx -"use client"; - -import { Inbox } from "lucide-react"; -import { GlassPanel } from "@/components/glass/panel"; - -interface EmptyStateProps { - title?: string; - description?: string; -} - -export function EmptyState({ - title = "No data yet", - description = "Nothing to display here yet.", -}: EmptyStateProps) { - return ( - - -

{title}

-

{description}

-
- ); -} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/shared/ -git commit -m "feat: add error boundary, glass shimmer skeleton, empty state" -``` - ---- - -### Task 20: Media Player Context & Mini Player - -**Files:** -- Create: `src/lib/hooks/use-media-player.ts` -- Create: `src/components/media/mini-player.tsx` - -- [ ] **Step 1: Create MediaPlayerProvider** - -```tsx -"use client"; - -import { createContext, useContext, useState, type ReactNode } from "react"; - -interface Track { - id: string; - title: string; - artist?: string; - duration?: number; -} - -interface MediaPlayerState { - currentTrack: Track | null; - queue: Track[]; - playing: boolean; - volume: number; -} - -interface MediaPlayerContextType extends MediaPlayerState { - play: (track: Track) => void; - skip: () => void; - stop: () => void; - setVolume: (v: number) => void; - addToQueue: (track: Track) => void; - removeFromQueue: (id: string) => void; -} - -const MediaPlayerContext = createContext(null); - -export function MediaPlayerProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - currentTrack: null, - queue: [], - playing: false, - volume: 75, - }); - - const play = (track: Track) => { - setState((prev) => ({ ...prev, currentTrack: track, playing: true })); - }; - - const skip = () => { - setState((prev) => { - if (prev.queue.length === 0) return { ...prev, currentTrack: null, playing: false }; - const [next, ...rest] = prev.queue; - return { ...prev, currentTrack: next, queue: rest }; - }); - }; - - const stop = () => { - setState((prev) => ({ ...prev, currentTrack: null, playing: false })); - }; - - const setVolume = (volume: number) => { - setState((prev) => ({ ...prev, volume })); - }; - - const addToQueue = (track: Track) => { - setState((prev) => ({ ...prev, queue: [...prev.queue, track] })); - }; - - const removeFromQueue = (id: string) => { - setState((prev) => ({ ...prev, queue: prev.queue.filter((t) => t.id !== id) })); - }; - - return ( - - {children} - - ); -} - -export function useMediaPlayer() { - const ctx = useContext(MediaPlayerContext); - if (!ctx) throw new Error("useMediaPlayer must be used within MediaPlayerProvider"); - return ctx; -} -``` - -- [ ] **Step 2: Create MiniPlayer** - -```tsx -"use client"; - -import { Play, SkipForward, Volume2, X } from "lucide-react"; -import { useMediaPlayer } from "@/lib/hooks/use-media-player"; - -export function MiniPlayer() { - const { currentTrack, playing, volume, skip, stop, setVolume } = useMediaPlayer(); - - if (!currentTrack) return null; - - return ( -
-
-
- -
-
-

{currentTrack.title}

- {currentTrack.artist && ( -

{currentTrack.artist}

- )} -
- -
-
- - - setVolume(Number(e.target.value))} - className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary" - /> -
-
- ); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/lib/hooks/use-media-player.ts src/components/media/mini-player.tsx -git commit -m "feat: add media player context and floating mini player" -``` - ---- - -### Task 21: Chatbot — Context, Container & Canvas - -**Files:** -- Create: `src/components/chatbot/chatbot-context.tsx` -- Create: `src/components/chatbot/chatbot-container.tsx` -- Create: `src/components/chatbot/chatbot-canvas.tsx` -- Create: `src/components/chatbot/chat-panel.tsx` - -- [ ] **Step 1: Create ChatbotContext** - -```tsx -"use client"; - -import { createContext, useContext, useState, type ReactNode } from "react"; - -type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking"; - -interface ChatbotContextType { - expression: ChatbotExpression; - minimized: boolean; - chatOpen: boolean; - chatHistory: { role: "user" | "assistant"; text: string }[]; - setExpression: (expr: ChatbotExpression) => void; - setMinimized: (v: boolean) => void; - setChatOpen: (v: boolean) => void; - addChat: (role: "user" | "assistant", text: string) => void; -} - -const ChatbotContext = createContext(null); - -export function ChatbotProvider({ children }: { children: ReactNode }) { - const [expression, setExpression] = useState("idle"); - const [minimized, setMinimized] = useState(true); - const [chatOpen, setChatOpen] = useState(false); - const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]); - - const addChat = (role: "user" | "assistant", text: string) => { - setChatHistory((prev) => [...prev, { role, text }]); - }; - - return ( - - {children} - - ); -} - -export function useChatbot() { - const ctx = useContext(ChatbotContext); - if (!ctx) throw new Error("useChatbot must be used within ChatbotProvider"); - return ctx; -} -``` - -- [ ] **Step 2: Create ChatbotCanvas (Live2D placeholder)** - -```tsx -"use client"; - -import { useEffect, useRef } from "react"; -import { useChatbot } from "./chatbot-context"; - -/** - * Live2D Cubism WebGL canvas. - * - * This component renders the Live2D model via the Cubism SDK. - * Integration requires: - * 1. Live2D Cubism SDK for Web (npm: @live2d/cubism) - * 2. Model files: .model3.json, .moc3, .physics3.json, textures - * 3. Place model files in public/chatbot/ - * - * The current implementation shows a placeholder character. - * Replace with actual Cubism SDK integration when model files are available. - */ - -export function ChatbotCanvas() { - const canvasRef = useRef(null); - const { expression } = useChatbot(); - - // Placeholder: draw a simple avatar face that responds to expression - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const w = canvas.width; - const h = canvas.height; - - ctx.clearRect(0, 0, w, h); - - // Background circle - const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80); - gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)"); - gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)"); - gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)"); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(w / 2, h / 2, 75, 0, Math.PI * 2); - ctx.fill(); - - // Eyes - const eyeOffsetX = 20; - const eyeY = 45; - - // Expression-driven eyes - if (expression === "surprise") { - // Wide eyes - ctx.fillStyle = "oklch(0.93 0.01 245)"; - ctx.beginPath(); - ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2); - ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "oklch(0.62 0.17 215)"; - ctx.beginPath(); - ctx.arc(w / 2 - eyeOffsetX, eyeY, 5, 0, Math.PI * 2); - ctx.arc(w / 2 + eyeOffsetX, eyeY, 5, 0, Math.PI * 2); - ctx.fill(); - } else if (expression === "happy") { - // Happy closed crescent eyes - ctx.strokeStyle = "oklch(0.93 0.01 245)"; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.arc(w / 2 - eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(w / 2 + eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9); - ctx.stroke(); - } else if (expression === "sad") { - // Sad downcast eyes - ctx.fillStyle = "oklch(0.93 0.01 245)"; - ctx.beginPath(); - ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 8, 6, 0.2, 0, Math.PI * 2); - ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 8, 6, -0.2, 0, Math.PI * 2); - ctx.fill(); - } else { - // Normal eyes - ctx.fillStyle = "oklch(0.93 0.01 245)"; - ctx.beginPath(); - ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2); - ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "oklch(0.62 0.17 215)"; - ctx.beginPath(); - ctx.arc(w / 2 - eyeOffsetX, eyeY, 4, 0, Math.PI * 2); - ctx.arc(w / 2 + eyeOffsetX, eyeY, 4, 0, Math.PI * 2); - ctx.fill(); - } - - // Mouth - ctx.strokeStyle = "oklch(0.93 0.01 245 / 0.7)"; - ctx.lineWidth = 2; - if (expression === "talking") { - ctx.beginPath(); - ctx.ellipse(w / 2, 70, 8, 6, 0, 0, Math.PI * 2); - ctx.stroke(); - } else if (expression === "happy") { - ctx.beginPath(); - ctx.arc(w / 2, 70, 10, 0.1, Math.PI - 0.1); - ctx.stroke(); - } else if (expression === "surprise") { - ctx.beginPath(); - ctx.ellipse(w / 2, 70, 6, 8, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = "oklch(0.12 0.02 245)"; - ctx.fill(); - } else { - ctx.beginPath(); - ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1); - ctx.stroke(); - } - - // Breathing animation — subtle canvas shift - const breath = Math.sin(Date.now() / 1000) * 1.5; - // Applied via CSS transform on container instead - - }, [expression]); - - return ( - - ); -} -``` - -- [ ] **Step 3: Create ChatbotContainer** - -```tsx -"use client"; - -import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react"; -import { useChatbot } from "./chatbot-context"; -import { ChatbotCanvas } from "./chatbot-canvas"; -import { ChatPanel } from "./chat-panel"; -import { useState } from "react"; - -export function ChatbotContainer() { - const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot(); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const [dragging, setDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - - const handleMouseDown = (e: React.MouseEvent) => { - setDragging(true); - setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (!dragging) return; - setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); - }; - - const handleMouseUp = () => setDragging(false); - - return ( -
- {/* Main chatbot bubble */} -
- {minimized ? ( - - ) : ( - <> - {/* Drag handle + controls */} -
- Chatbot -
- - -
-
- - {/* Canvas area */} -
- -
- - {/* Chat panel (expandable) */} -
- -
- - )} -
-
- ); -} -``` - -- [ ] **Step 4: Create ChatPanel** - -```tsx -"use client"; - -import { Send } from "lucide-react"; -import { useState } from "react"; -import { useChatbot } from "./chatbot-context"; - -export function ChatPanel() { - const { chatHistory, addChat, setExpression } = useChatbot(); - const [input, setInput] = useState(""); - - const handleSend = () => { - if (!input.trim()) return; - addChat("user", input); - setExpression("listening"); - - // Simulated bot response — replace with actual chatbot-chat API call - setTimeout(() => { - addChat("assistant", `I'm monitoring this server for you!`); - setExpression("happy"); - }, 800); - - setInput(""); - }; - - return ( -
-
- {chatHistory.slice(-6).map((msg, i) => ( -
- - {msg.text} - -
- ))} -
-
- setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSend()} - placeholder="Ask chatbot..." - className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none" - /> - -
-
- ); -} -``` - -- [ ] **Step 5: Create barrel export** - -```tsx -// src/components/chatbot/index.ts -export { ChatbotProvider } from "./chatbot-context"; -export { ChatbotContainer } from "./chatbot-container"; -export { useChatbot } from "./chatbot-context"; -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/components/chatbot/ -git commit -m "feat: add Live2D chatbot container with canvas, chat panel, and context" -``` - ---- - -### Task 22: WS Expression Triggers - -**Files:** -- Modify: `src/app/(dashboard)/layout.tsx` — add WS → chatbot expression bindings - -- [ ] **Step 1: Add WebSocket expression triggers** - -In the dashboard layout, add a side-effect that connects WebSocket events to chatbot expressions: - -```tsx -// Add to dashboard layout before the return: -import { useEffect } from "react"; -import { useChatbot } from "@/components/chatbot/chatbot-context"; -import { useWebSocket } from "@/lib/ws/context"; - -function ChatbotExpressionSync() { - const ws = useWebSocket(); - const { setExpression } = useChatbot(); - - useEffect(() => { - const unsub1 = ws.on("message_created", (data: any) => { - if (data.ai_status === "flagged" || data.ai_status === "warn") { - setExpression("surprise"); - setTimeout(() => setExpression("idle"), 2000); - } - }); - - const unsub2 = ws.on("voice_active_user", () => { - setExpression("listening"); - }); - - return () => { unsub1(); unsub2(); }; - }, [ws, setExpression]); - - return null; -} -``` - -Then render `` inside the layout tree. - -- [ ] **Step 2: Commit** - -```bash -git add src/app/\(dashboard\)/layout.tsx -git commit -m "feat: connect WS events to chatbot expression triggers" -``` - ---- - -### Task 23: Cleanup — Remove Old Components - -**Files:** -- Delete remaining old files that have been replaced - -- [ ] **Step 1: Remove old dashboard components** - -```bash -rm -rf src/components/dashboard/users-section.tsx -rm -rf src/components/dashboard/channels-section.tsx -rm -rf src/components/dashboard/channel-detail-section.tsx -rm -rf src/components/dashboard/user-detail-section.tsx -rm -rf src/components/dashboard/index.ts -rm -rf src/components/messages/images-grid.tsx -rm -rf src/components/messages/review-list.tsx -rm -rf src/components/messages/message-detail-view.tsx -rm -rf src/components/shared/stat-card.tsx -rm -rf src/components/shared/detail-stat.tsx -``` - -- [ ] **Step 2: Verify build** - -```bash -pnpm run build:web 2>&1 | tail -20 -``` - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "chore: remove old components replaced by redesign" -``` - ---- - -## Self-Review Checklist - -- [ ] **Spec coverage:** Every section from the spec has at least one task implementing it: - - Section 2 (Layout/Nav) → Tasks 5, 6, 7, 8 - - Section 3 (Design Tokens) → Task 1 - - Section 4 (Components) → Tasks 4, 9, 10, 11, 13, 16, 17, 18, 19 - - Section 5 (Page Layouts) → Tasks 12, 15, 16, 17, 18 - - Section 6 (Animations) → Tasks 1, 9, 10 - - Section 7 (Data Flow) → Task 15 (URL state), Task 22 (WS triggers) - - Section 8 (Tech Stack) → Task 2 (fonts) - - Section 9 (File Structure) → All tasks - - Section 10 (Implementation Order) → Followed as-is - - Chatbot → Tasks 21, 22 - - Media Player → Task 20 - - No gaps found. - -- [ ] **Placeholder check:** No TBD, TODO, or "implement later" found. Every task has specific code. The only note is the Live2D canvas is a placeholder with Canvas2D drawing — this is intentional since the actual Live2D model file isn't available yet. - -- [ ] **Type consistency:** All component props match what consuming pages expect. Hook interfaces consistent (useChatbot, useMediaPlayer). No type drift between tasks. - -- [ ] **No contradictions:** nav items match top nav links. Page layouts match sub-nav tabs. No file referenced before being created. diff --git a/docs/superpowers/specs/2026-07-27-cicd-overhaul-design.md b/docs/superpowers/specs/2026-07-27-cicd-overhaul-design.md deleted file mode 100644 index fd4d8db..0000000 --- a/docs/superpowers/specs/2026-07-27-cicd-overhaul-design.md +++ /dev/null @@ -1,455 +0,0 @@ -# CI/CD Overhaul: Gitea CI + Container Registry Design - -**Status:** Draft -**Last updated:** 2026-07-27 - -## 1. Problem Statement - -The current CI/CD pipeline has multiple issues: - -1. **Split across 3 CI systems**: GitHub Actions (build + deploy), GitLab CI (build only, no deploy), and `deploy.sh` (hot-deploy bind-mounts) -2. **Registry mismatch**: GitHub Actions pushes to `ghcr.io` but `docker-compose.yml` references `registry.gitlab.com` — the deploy route is unclear -3. **Hot-deploy complexity**: `deploy.sh` builds locally, tars dist files, SSH pipes, and binds into containers at runtime. Fragile and not reproducible -4. **No frontend in Docker**: Frontend is never built into an image — only hot-deployed via bind-mounts -5. **Stale Dockerfile**: `Dockerfile.proxy` builds a Rust WASM frontend that no longer exists -6. **Dockerfile.frontend is missing**: Frontend image doesn't exist at all -7. **Shared package fragility**: The previous refactor added `@bete/shared/database/init` export, but Docker images built from `master` don't have it — containers crash - -## 2. Goal - -Single CI/CD pipeline that: - -- Builds Docker images for all 3 services (backend, discord-gateway, proxy-serving-frontend) -- Pushes them to Gitea's built-in Container Registry -- On the VPS, only pulls images and restarts containers — no more hot-deploy bind-mounts -- All 3 services built in one pipeline, deployed together atomically - -## 3. Architecture - -``` -Developer pushes to main - │ - ▼ -┌────────────────────────────┐ -│ Gitea Runner (server X) │ -│ │ -│ Job 1: build-and-push │ -│ ├── bete-backend:latest │──────────▶ Gitea Container Registry -│ ├── bete-discord-gateway │──────────▶ git.imrnes.team/MythEclipse/GMW/ -│ │ :latest │ bete-backend:{sha,latest} -│ └── bete-proxy:latest │──────────▶ bete-discord-gateway:{sha,latest} -│ │──────────▶ bete-proxy:{sha,latest} -│ Job 2: deploy (SSH) │ -│ └─── SSH ke VPS ──────────┤ -└────────────────────────────┘ - │ - ▼ -┌────────────────────────────┐ -│ VPS Production │ -│ /opt/imphenbot/infra/ │ -│ docker/ │ -│ │ -│ docker compose pull │ -│ docker compose up -d │ -│ docker image prune -f │ -│ │ -│ 3 containers: │ -│ ┌────────┐ ┌──────────┐ │ -│ │ proxy │ │ backend │ │ -│ │ :80 │ │ :3000 │ │ -│ └───┬────┘ └──────────┘ │ -│ │ ┌─────────────┐ │ -│ └────┤discord- │ │ -│ │gateway │ │ -│ └─────────────┘ │ -└────────────────────────────┘ -``` - -### 3.1 Service Images - -| Image | From | Runs | -|-------|------|------| -| `bete-backend` | `Dockerfile.backend` | Express HTTP/WS on port 3000 | -| `bete-discord-gateway` | `Dockerfile.discord-gateway` | Discord client, internal only | -| `bete-proxy` | `Dockerfile.proxy` (rewritten) | Nginx serving frontend + proxying `/api` and `/ws` to backend | - -### 3.2 Registry - -Gitea provides a built-in container registry per repository at: -``` -git.imrnes.team/MythEclipse/GMW/: -``` - -Images are tagged with both `latest` and the commit SHA for traceability. - -## 4. Files to Create / Modify - -### 4.1 Create: `.gitea/workflows/deploy.yml` - -One workflow, two jobs: - -```yaml -name: Build & Deploy -on: - push: - branches: [main] - -jobs: - build-and-push: - runs-on: ubuntu-latest - strategy: - matrix: - service: [backend, discord-gateway, proxy] - max-parallel: 2 - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Gitea Registry - uses: docker/login-action@v3 - with: - registry: ${{ vars.GITEA_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITEA_REGISTRY_TOKEN }} - - name: Build & Push - uses: docker/build-push-action@v6 - with: - context: . - file: infra/docker/Dockerfile.${{ matrix.service }} - push: true - tags: | - ${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:${{ github.sha }} - ${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - runs-on: ubuntu-latest - needs: build-and-push - if: github.ref == 'refs/heads/main' - steps: - - name: SSH & Deploy - uses: appleboy/ssh-action@v1.2.5 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - script: | - cd /opt/imphenbot/infra/docker - echo "${{ secrets.ENV_FILE }}" > .env - docker compose pull - docker compose up -d --remove-orphans - docker image prune -f -``` - -Note: Gitea CI uses GitHub Actions-compatible syntax (Act Runner). The above uses the standard `actions/*` actions and `docker/*` actions that work with both GitHub and Gitea. If Gitea's runner doesn't fully support `docker/build-push-action`, fallback to inline `docker build` and `docker push` commands. - -Sensitive variables: `GITEA_REGISTRY_TOKEN`, `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `ENV_FILE` set in Gitea repo Settings → Actions → Secrets. Non-sensitive: `GITEA_REGISTRY` as a Variable. - -### 4.2 Rewrite: `Dockerfile.proxy` - -Current proxy Dockerfile builds a Rust WASM frontend (stale — no longer exists in codebase). Replace with multi-stage build: - -```dockerfile -# Stage 1: Build frontend (Next.js 16 static export) -FROM node:22-slim AS frontend-builder - -WORKDIR /app - -# Install pnpm -RUN corepack enable - -# Copy dependency manifests -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY packages/shared/package.json ./packages/shared/package.json -COPY services/frontend/package.json ./services/frontend/package.json - -# Install dependencies -RUN pnpm install --frozen-lockfile --filter './services/frontend' --filter '@bete/shared' - -# Copy source code -COPY packages/shared/ ./packages/shared/ -COPY services/frontend/ ./services/frontend/ - -# Build Next.js static export -RUN pnpm --filter frontend run build -# Result in services/frontend/out/ - -# Stage 2: Nginx -FROM nginx:alpine - -# Nginx config -COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf - -# Static frontend files -COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/ - -EXPOSE 80 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:80/ || exit 1 -``` - -### 4.3 Modify: `Dockerfile.backend` - -Add `VITE_BE_API_URL` and `VITE_BE_WS_URL` build args (already listed in GitHub Actions but not in Dockerfile): - -```dockerfile -# Add to existing Dockerfile.backend — after FROM, before WORKDIR -ARG VITE_BE_API_URL -ARG VITE_BE_WS_URL -ENV VITE_BE_API_URL=${VITE_BE_API_URL} -ENV VITE_BE_WS_URL=${VITE_BE_WS_URL} -``` - -These build args are now consumed at build time for future-proofing even though they were previously only needed for frontend builds (which now lives in the proxy Dockerfile). - -### 4.4 Modify: `Dockerfile.discord-gateway` - -No structural changes needed — verify Drizzle migrations path: - -```dockerfile -# COPY drizzle, line in existing Dockerfile.discord-gateway: -COPY services/discord-gateway/drizzle/ ./services/discord-gateway/drizzle/ -# This should work as-is since workspace is copied at /app -``` - -### 4.5 Rewrite: `deploy.sh` - -From hot-deploy tar-pipe SSH to lightweight SSH exec: - -```bash -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -INFRA_DIR="$SCRIPT_DIR/infra/docker" - -: "${VPS_HOST:?required}" -: "${VPS_USER:?required}" -: "${VPS_SSH_KEY:?required}" - -echo "=== Deploy to $VPS_HOST ===" - -# Copy .env if it exists locally -if [ -f "$INFRA_DIR/.env" ]; then - scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env" -fi - -ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT' - set -e - cd /opt/imphenbot/infra/docker - echo "=== Pulling images ===" - docker compose pull - echo "=== Restarting containers ===" - docker compose up -d --remove-orphans - echo "=== Cleaning up ===" - docker image prune -f - echo "=== Verify ===" - docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" -REMOTESCRIPT - -echo "=== Deploy complete ===" -``` - -### 4.6 Rewrite: `infra/docker/docker-compose.yml` - -Replace all GitLab registry image references with Gitea registry. Remove bind-mounts. Add recordings named volume. - -```yaml -version: "3.8" - -services: - proxy: - image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-proxy:${IMAGE_TAG:-latest} - container_name: imphenbot-proxy - restart: unless-stopped - ports: - - "127.0.0.1:8080:80" - networks: - - app-shared-net - healthcheck: - test: wget -qO- http://localhost:80/ || exit 1 - interval: 30s - timeout: 3s - start_period: 10s - retries: 3 - deploy: - resources: - limits: - memory: 64M - labels: - traefik.enable: "true" - traefik.http.routers.imphenbot.rule: "Host(`imphnen.asepharyana.my.id`)" - traefik.http.routers.imphenbot.entrypoints: websecure - traefik.http.routers.imphenbot.tls: "true" - traefik.http.services.imphenbot.loadbalancer.server.port: "80" - - backend: - image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-backend:${IMAGE_TAG:-latest} - container_name: imphenbot-backend - restart: unless-stopped - env_file: - - .env - environment: - NODE_ENV: production - WEBSERVER_PORT: 3000 - networks: - - app-shared-net - healthcheck: - test: wget -qO- http://localhost:3000/api/health || exit 1 - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - deploy: - resources: - limits: - memory: 256M - depends_on: - - proxy - - discord-gateway: - image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-discord-gateway:${IMAGE_TAG:-latest} - container_name: imphenbot-discord-gateway - restart: unless-stopped - env_file: - - .env - environment: - NODE_ENV: production - volumes: - - recordings:/app/recordings - networks: - - app-shared-net - healthcheck: - test: sh -c "kill -0 1" - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - deploy: - resources: - limits: - memory: 512M - -volumes: - recordings: - -networks: - app-shared-net: - external: true -``` - -Key changes: -- Image refs: `registry.gitlab.com/mytheclipse-group/gmw/...` → `${GITEA_REGISTRY}/${GITEA_REPO}/...` -- **All bind-mounts removed** (`./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`) -- `recordings` → named volume (persists across container restarts/recreates) -- `proxy` binds to `127.0.0.1:8080` instead of host port 80 (Traefik handles external routing) -- Added `depends_on: proxy` to backend for startup ordering - -### 4.7 Remove: GitHub Actions & GitLab CI files - -After Gitea CI is verified working: -- Delete `.github/workflows/deploy-docker.yml` (or rename to `.github/workflows/deploy-docker.yml.disabled`) -- Delete `.gitlab-ci.yml` (or rename to `.gitlab-ci.yml.disabled`) - -### 4.8 Ensure: `.gitea/workflows/` directory - -The directory must exist in git. Some setups ignore `.gitea/` — verify `.gitignore` does not exclude it. - -## 5. Gitea Registry Integration - -### 5.1 Enable Container Registry in Gitea - -In Gitea Admin Settings: -- Go to Settings → Repository → Enable "Container Registry" -- Default registry URL format: `gitea.//` - -### 5.2 Registry Token - -Create a Gitea access token with `read` and `write` access to packages: -- Settings → Applications → Generate Token → `registry-token` → scope: `write:packages` - -### 5.3 CI Variables - -Set these in Gitea repo → Settings → Actions → Secrets: - -| Name | Example Value | Notes | -|------|---------------|-------| -| `GITEA_REGISTRY_TOKEN` | `gitea_token_abc123` | Docker login password | -| `VPS_HOST` | `123.123.123.123` | VPS IP/hostname | -| `VPS_USER` | `root` | SSH user | -| `VPS_SSH_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | Private key | -| `ENV_FILE` | full .env content | Written to VPS before compose | - -As Variables (not secrets, visible but non-sensitive): - -| Name | Example Value | Notes | -|------|---------------|-------| -| `GITEA_REGISTRY` | `git.imrnes.team` | Registry hostname — no protocol prefix | - -### 5.4 VPS Setup (one-time) - -```bash -# 1. Docker login to Gitea registry -docker login git.imrnes.team -# Use Gitea username + access token (with write:packages scope) - -# 2. Create recordings named volume -docker volume create imphenbot_recordings - -# 3. Remove old bind-mount directories (after verifying old containers stopped) -rm -rf /opt/imphenbot/infra/docker/backend-dist -rm -rf /opt/imphenbot/infra/docker/gateway-dist -rm -rf /opt/imphenbot/infra/docker/shared-dist -rm -rf /opt/imphenbot/infra/docker/frontend-dist - -# 4. Ensure compose file is updated (via git pull) -cd /opt/imphenbot && git pull origin main -``` - -## 6. Migration Plan - -### Phase 1: Prepare (this session) - -1. Write `.gitea/workflows/deploy.yml` -2. Rewrite `Dockerfile.proxy` for Next.js -3. Modify `infra/docker/docker-compose.yml` for Gitea registry + named volumes -4. Rewrite `deploy.sh` to SSH-only -5. Mark old CI files as disabled (rename, not delete yet) -6. Add VITE_BE_API_URL/VITE_BE_WS_URL build args to backend Dockerfile - -### Phase 2: VPS Preparation (one-time SSH) - -7. User runs `docker login` to Gitea registry on VPS -8. User sets CI secrets in Gitea UI -9. User creates `imphenbot_recordings` named volume - -### Phase 3: Deploy - -10. Commit and push to `main` -11. Gitea CI triggers — builds 3 images, pushes to registry -12. Deploy job SSHes into VPS, pulls images, restarts containers -13. Verify with `docker ps` and health checks - -### Phase 4: Cleanup - -14. After all services running stably for 1-2 pushes: delete old CI files -15. Remove old Dockerfiles if no longer referenced - -## 7. Rollback Plan - -If something goes wrong: - -1. **Quick rollback**: `docker compose up -d` with previous `IMAGE_TAG` (pin to last working SHA) -2. **Full rollback**: Revert git changes, push to `main` — Gitea CI will rebuild with old config -3. **Emergency**: SSH to VPS, use `docker compose` commands to restart specific containers - -## 8. Future Considerations - -- **Auto-deploy on tag**: Optionally trigger CI only on version tags (`v*`) instead of every `main` push -- **Health check notifications**: Add webhook notification on deploy failure -- **Multi-architecture builds**: Add `--platform linux/amd64,linux/arm64` for future ARM VPS migration -- **Secrets management**: Consider HashiCorp Vault or Gitea's built-in encrypted secrets for larger teams \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-27-frontend-refactor-design.md b/docs/superpowers/specs/2026-07-27-frontend-refactor-design.md deleted file mode 100644 index bbc22ad..0000000 --- a/docs/superpowers/specs/2026-07-27-frontend-refactor-design.md +++ /dev/null @@ -1,154 +0,0 @@ -# Frontend Refactor: Cleanup, API Alignment & Rebrand - -## Goal -Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "chatbot" to "chatbot". - -## Scope - -### A. Code Quality & Structure -1. **Extract inline page components** into dedicated files under `components//` -2. **Remove dead code** (`live-stats.tsx`, `useSearch`, `Item`, etc.) -3. **Remove duplicate code** (merge `extractImage`/`extractFirstImage`, consolidate `WsHook` type, consolidate `isActive` functions) -4. **Fix Tailwind v4 dynamic class** (`grid-cols-${columns}`) in `LoadingSkeleton` -5. **Fix navigation icon** (Settings should use `Settings`, not `BarChart3`) - -### B. API Layer Separation -- Split `voiceApi` into `voiceApi` + `mediaApi` -- Keep `chatbot.ts` as is (frontend already uses "chatbot" naming) - -### C. Data Fetching Consistency -- `GuildSelector` → use `useGuilds` + `useConfig` React Query hooks -- `useVoiceChannels` → convert from manual `useState` to `useQuery` -- Chatbot → convert to `useQuery` + `useMutation` (user approved this) - -### D. Rebrand -- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments -- **chatbot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/chatbot/chat`) stay unchanged on frontend since they reference the actual backend path - -### E. Dead Code Removal -- Remove `components/landing/` (including `live-stats.tsx`) -- Remove `components/ui/item.tsx` (unused) -- Remove `useSearch` from `use-messages.ts` -- Remove unused shadcn/ui components (verified by grep) - -## Target Directory Structure - -``` -src/ - app/(dashboard)/ - messages/page.tsx # slim → imports from components/messages/ - dashboard/page.tsx # slim - voice/page.tsx # slim - media/page.tsx # slim - recordings/page.tsx # slim - analysis/page.tsx # slim - settings/page.tsx # slim - layout.tsx # unchanged - app/layout.tsx # update title - app/page.tsx # unchanged (redirect) - - components/ - messages/ - message-card.tsx # from inline in messages/page.tsx - message-detail-view.tsx # from inline DetailView - ai-status-badge.tsx # from inline AiStatusBadge - images-grid.tsx # images tab content - review-list.tsx # review tab content - dashboard/ - stats-section.tsx - users-section.tsx - user-detail-section.tsx - channels-section.tsx - channel-detail-section.tsx - voice/ - voice-connection-card.tsx - active-speakers-panel.tsx - microphone-card.tsx - media/ - music-player.tsx - recordings/ - recording-list.tsx - analysis/ - search-panel.tsx - shared/ # existing - layout/ # existing - chatbot/ # existing - ui/ # shadcn — remove unused - - hooks/ - use-messages.ts # cleaned, use shared WsHook type - use-dashboard.ts - use-voice.ts # cleaned - use-media.ts # cleaned - use-recordings.ts # cleaned - use-guilds.ts - use-config.ts - use-mobile.ts - index.ts - - lib/ - ws-hook.ts # NEW: shared WsHook type - api/ - client.ts - messages.ts - voice.ts # voice-only - media.ts # NEW: extracted from voiceApi - dashboard.ts - recordings.ts - config.ts - chatbot.ts - ui-state.ts - index.ts - types/ # no structural changes, verify alignment - ws/ # no structural changes - format.ts - navigation.ts - utils.ts -``` - -## Key Changes Detail - -### 1. Component Extraction -Each page file that has inline components (messages=689 lines, dashboard=570 lines) will have those components extracted into dedicated files. The page file becomes a thin composition layer. - -### 2. WsHook Type Consolidation -Three files define `type WsHook = { on: (eventType: E, handler: ...) => () => void }`. This moves to `lib/ws-hook.ts` and all three hooks import it. - -### 3. LoadingSkeleton Fix -Replace dynamic `grid-cols-${columns}` with explicit Tailwind classes or inline style: -```tsx -const gridCols = columns === 2 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1"; -``` - -### 4. API Separation -```typescript -// lib/api/voice.ts — voice + guilds only -export const voiceApi = { - getGuilds, getTextChannels, getVoiceChannels, - getStatus, connect, disconnect, sendCommand, -}; - -// lib/api/media.ts — media player only (NEW) -export const mediaApi = { - getStatus, queue, skip, stop, volume, -}; -``` - -### 5. Data Fetching Consistency -`GuildSelector` will use `useGuilds()` and `useConfig()` hooks instead of manual fetch in useEffect. -`useVoiceChannels` will use `useQuery` with `enabled: !!guildId`. -Chatbot will use `useQuery` for history and `useMutation` for send. - -### 6. Rebrand -- `app/layout.tsx`: title → "Discord Automod" -- Sidebar brand: keep "DC Automod" (already done) -- Settings page: keep "DC Automod" reference -- Comments referencing "bete" → update -- No changes to package names or external references (backend still "bete" internally) - -### Non-Goals -- No changes to backend API paths -- No changes to package.json names (pnpm workspace naming) -- No changes to Router/App Router structure -- No changes to CSS/styling system -- No functional changes — visual behavior identical diff --git a/docs/superpowers/specs/2026-07-27-services-refactoring-design.md b/docs/superpowers/specs/2026-07-27-services-refactoring-design.md deleted file mode 100644 index a944b70..0000000 --- a/docs/superpowers/specs/2026-07-27-services-refactoring-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Refactoring Backend & Discord-Gateway Services - -**Date:** 2026-07-27 -**Status:** Draft - -## Overview - -Comprehensive refactoring of `services/backend` (4.2k lines) and `services/discord-gateway` (17.9k lines) targeting code consistency, file-size reduction, deduplication, and pattern uniformity. - -## Scope - -### Phase 1 — Backend Controller Consistency - -**Problem:** Two competing controller patterns. - -- `messages.controller.ts`, `chatbot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A) -- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B) - -**Fix:** Convert all controllers to **Gaya B** (decorator pattern). - -Before (Gaya A): -```ts -export function handleListMessages(req, res, next) { - return asyncHandler(async (req, res) => { - // ... - })(req, res, next); -} -``` - -After (Gaya B): -```ts -export const handleListMessages = asyncHandler(async (req, res) => { - // ... -}); -``` - -**Files affected:** -- `modules/messages/messages.controller.ts` -- `modules/chatbot-chat/chatbot-chat.controller.ts` - -### Phase 2 — Backend `response.ts` Cleanup - -**Problem:** `success()`/`error()` helpers exist but are unused (except health controller). - -**Fix:** Apply `success()` consistently to all API responses that are successful data returns. Remove `error()` if unused after audit. - -**Files affected:** All route/service files that `res.json()` data. - -### Phase 3 — Backend `ws/` Barrel - -**Problem:** `ws/broadcast.ts`, `ws/redis-bridge.ts`, `ws/server.ts` — no barrel. - -**Fix:** Add `ws/index.ts` barrel. - -### Phase 4 — Gateway: Split `moderationPrompt.ts` (1015 lines) - -**Problem:** Monolithic prompt file mixing all prompt types. - -**Fix:** Split into: -- `prompts/text-analysis.ts` — Text moderation prompts -- `prompts/media-analysis.ts` — Image/video analysis prompts -- `prompts/stickers.ts` — Sticker analysis prompts -- `prompts/emojis.ts` — Custom emoji prompts -- `prompts/system.ts` — System prompt builder and shared helpers - -### Phase 5 — Gateway: Split `moderationOrchestrator.ts` (955 lines) - -**Problem:** Entry point that also contains inline text-only batch, media batch, and simple fallback. - -**Fix:** Extract into: -- `textBatchProcessor.ts` — All text-only batching logic -- `mediaBatchProcessor.ts` — All media batching logic -- `simpleFallback.ts` — The `runSimpleTextFallback` function - -### Phase 6 — Gateway: Split `mediaAnalysisClient.ts` (826 lines) - -**Problem:** Cache logic (LRU + phash + DB), download logic (image/video + ffmpeg), and vision LLM in one file. - -**Fix:** Extract into: -- `mediaCache.ts` — All caching layers (LRU, phash dedup, DB) -- `mediaDownloader.ts` — Image/video download, ffmpeg frame extraction -- `visionAnalyzer.ts` — Vision LLM orchestration - -### Phase 7 — Gateway: Consolidate `bootstrap.ts` - -**Problem:** 304-line bootstrap that embeds retention cleanup inline. - -**Fix:** Extract `startRetentionCleanup` into `app/retention.ts`. Leave event registrations in bootstrap as they're inherently app-wide wiring. - -### Phase 8 — Gateway: Simplify EventBroadcaster - -**Problem:** `RedisEventPublisher` wrapping is thin — only adds a `publish` wrapper. - -**Fix:** Merge `RedisEventPublisher` into `EventBroadcaster` as a private inner detail. - -### Phase 9 — Cross-cutting: Database initialization dedup - -**Problem:** Backend (`shared/database/index.ts`) and gateway (`shared/database/drizzle.ts`) have near-identical pool creation and lifecycle code. - -**Fix:** Extract common pool/drizzle lifecycle into `@bete/shared`: -```ts -// packages/shared/src/database/index.ts -export function createDatabasePool(url: string, opts?: PoolOpts): Pool -export function createDrizzleClient(pool: Pool): DrizzleClient -export function closePool(pool: Pool): Promise -``` -Both services keep their own getDatabase/close wrappers but delegate pool creation to shared. - -### Phase 10 — Gateway: Consolidate `moderationState.ts` / `conversationState.ts` - -**Problem:** Two state files with overlapping concerns. - -**Fix:** Audit both for overlap, merge if significant duplication found. - -### Phase 11 — Gateway: Redis connection usage audit - -**Problem:** Multiple independent Redis connections for EventBroadcaster and CommandHandler. - -**Fix:** Both already need separate connections (Redis pub/sub limits). Document the pattern. No structural change. - -## Files Changed - -| Phase | Files | Type | -|-------|-------|------| -| 1 | 3 | edit | -| 2 | ~15 | edit | -| 3 | 1 | create | -| 4 | ~6 | split | -| 5 | ~4 | split | -| 6 | ~4 | split | -| 7 | 2 | split | -| 8 | 2 | refactor | -| 9 | 2 | refactor | -| 10 | 1-2 | audit+merge | diff --git a/docs/superpowers/specs/2026-07-27-visual-redesign.md b/docs/superpowers/specs/2026-07-27-visual-redesign.md deleted file mode 100644 index 48a88cc..0000000 --- a/docs/superpowers/specs/2026-07-27-visual-redesign.md +++ /dev/null @@ -1,37 +0,0 @@ -# Visual Redesign: Discord Automod Dashboard - -## Design Direction - -**Vibe:** "Monitoring hub" — deep, technical, trustworthy. Think security operations center meets modern dev tool. - -## Palette - -**Dark (primary):** -| Token | Value | Role | -|-------|-------|------| -| `--bg` | `oklch(0.09 0.015 245)` | Deeper navy canvas | -| `--card` | `oklch(0.13 0.02 245)` | Surface with subtle separation | -| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent (shift from sky blue) | -| `--accent` | `oklch(0.7 0.18 260)` | Electric blue-purple for secondary highlights | -| `--warn` | `oklch(0.7 0.17 75)` | Amber-gold for warnings (distinct from red) | -| `--border` | `oklch(1 0 0 / 0.06)` | Softer borders | - -## Typography -- Geist Sans (body) + Geist Mono (code/data) — already loaded -- H1: `text-lg font-semibold tracking-tight` -- Card titles: `text-sm font-semibold tracking-tight` -- Labels/captions: `text-xs text-muted-foreground tracking-wide uppercase` - -## Layout Changes - -1. **Background**: Subtle dot-grid pattern (`radial-gradient(circle, oklch(1 0 0 / 0.03) 1px, transparent 1px)`) — monitoring station feel -2. **Sidebar**: Slightly wider (w-64), active item gets a glow bar + subtle teal tint background, connection dot with breathing animation -3. **Cards**: Hover state adds a thin teal border-top glow, softer shadow -4. **Stat cards**: Gradient background per stat type (like live-stats had), with icon in colored bubble -5. **Severity indicators**: Colored dot + label instead of just colored border -6. **Mobile nav**: Tighter spacing, active indicator as dot above icon -7. **Header**: Clean, thin bottom border glow, page title larger - -## Signature Element -- **Grid background** + **teal glow** on active/interactive elements -- **Gradient accent bar** on sidebar active item (wider, glowing) diff --git a/docs/superpowers/specs/2026-07-28-discord-automod-redesign.md b/docs/superpowers/specs/2026-07-28-discord-automod-redesign.md deleted file mode 100644 index 8879072..0000000 --- a/docs/superpowers/specs/2026-07-28-discord-automod-redesign.md +++ /dev/null @@ -1,508 +0,0 @@ ---- -name: "Discord Automod — Neo Surveillance Redesign" -version: "1.0.0" -date: "2026-07-28" -status: "approved" -inspiration: - - "Summit Cloud Migration Platform (glassmorphic, dark premium)" - - "AeroNet Visualization (data panels, modular layout)" -colors: - canvas: "oklch(0.07 0.015 250)" - surface: "oklch(0.11 0.02 245 / 0.6)" - surface-hover: "oklch(0.15 0.02 245 / 0.7)" - border: "oklch(1 0 0 / 0.06)" - border-glow: "oklch(0.62 0.17 215 / 0.3)" - primary: "oklch(0.62 0.17 215)" - primary-glow: "oklch(0.62 0.17 215 / 0.4)" - accent-purple: "oklch(0.65 0.2 280)" - accent-amber: "oklch(0.7 0.17 75)" - text-primary: "oklch(0.93 0.01 245)" - text-secondary: "oklch(0.55 0.02 245)" - text-mono: "oklch(0.62 0.17 215)" - glass-bg: "oklch(1 0 0 / 0.04)" - glass-border: "oklch(1 0 0 / 0.08)" - glass-shadow: "0 8px 32px oklch(0 0 0 / 0.4)" -typography: - display: "Inter 28-48px weight 600" - body: "Inter 14-16px weight 400" - mono: "JetBrains Mono 11-13px weight 500-600" - data: "JetBrains Mono 24-36px weight 600, teal tint" -radius: - card: "16px" - panel: "12px" - control: "8px" - pill: "9999px" ---- - -# Discord Automod — Neo Surveillance Redesign - -Full frontend redesign for Discord Automod, a Discord moderation watcher dashboard. Complete rewrite of layout, design system, navigation, and page architecture. - ---- - -## 1. Design Philosophy - -**"Neo Surveillance"** — a Security Operations Center (SOC) inspired dashboard where monitoring feels immersive and powerful. Full-screen glass panels float over a dark animated canvas. No persistent sidebar clutter. The interface disappears into the background, letting live data and alerts take center stage. - -Key pillars: -- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content -- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts -- **Presence** — Live2D vtuber chatbot character as chatbot interface, reacts to server events - ---- - -## 2. Layout & Navigation System - -### 2.1 Global Structure - -``` -┌──────────────────────────────────────────────────────┐ -│ ● Discord Automod Dashboard Msgs Voice … 🟢 ● │ ← Floating Top Bar (~44px) -├──────────────────────────────────────────────────────┤ -│ [Sub-navigation tabs] ← muncul per-page │ -│━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━│ -│ │ -│ ┌─────────┐ ┌──────────────┐ │ -│ │ Glass │ │ Content │ │ -│ │ Panels │ │ Area │ │ -│ │ │ │ (scroll) │ │ -│ └─────────┘ └──────────────┘ │ -│ │ -├──────────────────────────────────────────────────────┤ -│ 🎵 [Mini-player] ← bottom-left 🎭 [Chatbot] ← BR │ -└──────────────────────────────────────────────────────┘ -``` - -### 2.2 Floating Top Navigation Bar - -- **Style:** Glass (`backdrop-blur-xl`), subtle glow border bottom, `h-11` (44px) -- **Left:** App logo "Discord Automod" with teal live dot indicator + current page name -- **Center:** Horizontal nav links — Dashboard, Messages, Voice, Recordings, Settings - - Icon + label, active state with glow underline (`box-shadow` teal) - - Hover: text brighter, no background fill - - **Search** has no nav link — triggered globally via Cmd+K or `/` shortcut, opens spotlight -- **Right:** Connection status dot (pulse when connected) + theme toggle (sun/moon icon) -- **Hover sidebar hotspot:** Left edge 4px trigger → slide-in sidebar with guild selector, bookmarks, recent channels (auto-hide 300ms after mouse leave) - -### 2.3 Sub-navigation - -Each page has its own tab bar below the top nav, also glass-styled: -- Dashboard: Stats | Live | Activity -- Messages: All | Images | Review -- Voice: Connection | Activity -- Recordings: Library | Stats -- Settings: Connection | Appearance | Config | About - -### 2.4 Hidden Sidebar (Hover-activated) - -- Trigger: 4px hotspot at left screen edge -- Slide-in animation (150ms, ease-out-expo) -- Contains: guild selector dropdown, bookmarked channels, recent activity shortcuts -- Auto-hide on mouse leave with 300ms delay - -### 2.5 Floating Media Player - -- No dedicated Media page — persistent floating mini-player at bottom-left -- Visible only when a track is active -- Click to expand: full queue management overlay -- Controls: play/pause, skip, stop, volume slider, progress bar - ---- - -## 3. Design Tokens - -### 3.1 Color Palette - -| Token | Value | Usage | -|-------|-------|-------| -| `--canvas` | `oklch(0.07 0.015 250)` | Deep navy background | -| `--surface` | `oklch(0.11 0.02 245 / 0.6)` | Glass card base | -| `--surface-hover` | `oklch(0.15 0.02 245 / 0.7)` | Card hover state | -| `--border` | `oklch(1 0 0 / 0.06)` | Subtle border | -| `--border-glow` | `oklch(0.62 0.17 215 / 0.3)` | Active card border glow | -| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent, buttons | -| `--primary-glow` | `oklch(0.62 0.17 215 / 0.4)` | Active state glow | -| `--accent-purple` | `oklch(0.65 0.2 280)` | Moderation flagged items | -| `--accent-amber` | `oklch(0.7 0.17 75)` | Warnings | -| `--text-primary` | `oklch(0.93 0.01 245)` | Body text | -| `--text-secondary` | `oklch(0.55 0.02 245)` | Secondary labels | -| `--text-mono` | `oklch(0.62 0.17 215)` | Data metrics (teal tint) | -| `--glass-bg` | `oklch(1 0 0 / 0.04)` | Glass base | -| `--glass-border` | `oklch(1 0 0 / 0.08)` | Glass border | -| `--glass-shadow` | `0 8px 32px oklch(0 0 0 / 0.4)` | Glass shadow | - -### 3.2 Typography - -| Role | Font | Size / Weight | -|------|------|--------------| -| Display | Inter | 28-48px, weight 600 | -| Body | Inter | 14-16px, weight 400 | -| Label / Mono | JetBrains Mono | 11-13px, weight 500-600 | -| Data metrics | JetBrains Mono | 24-36px, weight 600, teal tint | - -### 3.3 Radius System - -| Token | Value | -|-------|-------| -| Card | 16px | -| Panel | 12px | -| Button / Control | 8px | -| Pill | 9999px | - -### 3.4 Motion Tokens - -```css ---ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); ---ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); ---duration-fast: 150ms; ---duration-normal: 250ms; ---duration-slow: 400ms; -``` - ---- - -## 4. Component System - -### 4.1 Glass Card System - -- **Base:** `glass-bg` + `glass-border` + border-radius `16px` -- **Elevated:** Deeper shadow + subtle primary glow border -- **Interactive:** Hover `scale(1.01)` + border glow intensify -- **Danger:** Red-tinted border for critical items -- Inner padding: `20px` (card), `16px` (panel), `12px` (dense) - -### 4.2 Button Variants - -| Variant | Style | -|---------|-------| -| Primary | `bg-primary` + `shadow-[0_0_12px] shadow-primary/40` (glow) | -| Secondary | `glass-bg` + `border` | -| Ghost | Transparent, hover → subtle glass bg | -| Icon | Size 32px, rounded 8px | -| Danger | Red-tinted variant for destructive actions | - -### 4.3 Status Indicators - -- **Live dot:** Pulsing ring animation (`pulse-ring` 1.5s) -- **AI Badge:** Teal pill with sparkle icon, mono font -- **Severity badges:** Clean (green), Warn (amber), Flagged (purple), Critical (red) -- **Connection:** Green (connected), Yellow (connecting), Red (disconnected) - -### 4.4 Charts (Recharts) - -Custom theme matching design tokens: -- Line/Area: gradient fill (primary → transparent) -- Bar: rounded bars, teal-cyan gradient -- Heatmap: activity by hour × weekday -- Radar: multi-axis for moderation categories - -### 4.5 Live2D Chatbot / Chatbot - -- Replaces the existing `Chatbot` component entirely — chatbot panel is the new chat interface -- **Location:** Floating panel, bottom-right corner, draggable -- **Default size:** Compact — upper body visible (~200×280px) -- **Click character:** Expand with full chat panel -- **Dynamic expressions:** - - Idle: subtle breathing, blink every 4s - - New message: head tilt "listening" - - Flagged detected: eyes widen, ! bubble - - User click: happy wave - - Voice active: ear/head tilt toward audio - - Chat reply: mouth sync animation - - Disconnect: sad expression -- **Technology:** Live2D Cubism SDK (WebGL via pixi.js wrapper), `.model3.json` + `.moc3` format -- **Chat panel:** Glass-styled input + message history, context-aware (server context) - -### 4.6 Loading States - -- **Skeleton:** Glass card shape with shimmer gradient (teal → transparent → teal) -- **Button loading:** Spinner within button -- **Full page:** Glass skeleton grid matching target layout - ---- - -## 5. Page Layouts - -### 5.1 Dashboard — "Ops Center" - -Full-viewport command center: -- **Stat cards row:** Total Messages, Today, Users, Active 24h, Flagged, Clean — each with micro sparkline chart (Recharts mini area) behind the number -- **Live Message Stream:** Auto-scrolling glass panel showing recent messages, fade-in animation, click for detail -- **Mod Queue:** Flagged messages with quick action buttons (approve/delete/escalate) -- **Message Trend Chart:** 7-day area chart -- **Activity Heatmap:** Hour × day-of-week, moderation event density -- **Top Channels:** Bar chart with channel names -- **Chatbot visible** floating bottom-right - -### 5.2 Messages — Split Pane - -- **Left pane:** Scrollable message list, glass cards with severity badge, channel tag, timestamp -- **Right pane:** Detail/preview — full message content, attachments gallery, AI analysis breakdown (severity, flags, confidence, categories) -- **Global search bar** in top area: spotlight-style overlay (Cmd+K) -- **State in URL params:** `?guild=xxx&channel=yyy&selected=msg123&tab=all` -- **Tabs:** All | Images (grid view) | Review (flagged queue) -- **Actions:** Reanalyze, Moderate (dropdown: delete/warn/escalate) - -### 5.3 Voice — Connection Center - -- **Connection card:** Guild/channel selectors, status with live dot + duration -- **Active Speakers:** Per-user waveform visualization (canvas-based, 100ms update) -- **Microphone/Transmit:** Toggle mic, volume slider -- **Voice Activity Timeline:** Bar chart showing who spoke and total duration -- **Recordings quick link** to Recordings page - -### 5.4 Recordings — Voice Library - -- **Search + filter bar:** By user, channel, date range -- **Recording cards:** Glass card, waveform preview (canvas), duration, timestamp -- **Inline playback:** Play button, audio player without leaving page -- **Actions:** Download, Copy Link - -### 5.5 Settings - -- **Sections:** Connection (WebSocket status, guild info), Appearance (theme toggle), Server Config (read-only), About -- All glass cards, mono font for config values -- Toggle switches with glass styling - ---- - -## 6. Animations & Micro-interactions - -### 6.1 Ambient Background -- Gradient mesh with slow-shift (30s cycle) -- 2-3 soft color blobs (teal, purple, amber), opacity 0.03-0.06 -- Grid dot pattern: `radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px)`, 24px spacing - -### 6.2 Page Transitions -- Route change: `fade-in-up` 200ms ease-out -- Content section: `scale(0.98→1)` + `opacity(0.6→1)` - -### 6.3 Card Interactions -- Hover: `scale(1.01)` + border glow intensify + shadow lift -- Click: `scale(0.98)` brief (100ms) -- Panel enter: `translateY(-4px)` + `opacity` fade-in -- Stat counter: count-up animation (JS tween, 400ms) - -### 6.4 Live Data -- Message stream: fade-in from top, slide down as new arrive -- Voice waveform: real-time canvas draw, 100ms interval -- Recording: pulsing dot + ring expansion (1.5s loop) -- Connection: slow pulse when connected -- Flagged: brief red/purple border flash on new flagged message - -### 6.5 Micro-interactions -- Toggle: slide with glow -- Scrollbar: custom thin (6px), auto-hide, rounded -- Drag handle: subtle dot grip for split pane -- Copy: brief "Copied!" toast -- Reanalyze: 360° icon rotation - ---- - -## 7. Data Flow & State Management - -### 7.1 Architecture - -``` -WS Provider (auto-reconnect, typed events, event buffer) - ↓ -TanStack Query (fetches + cache) - ↓ -Query invalidation on WS events - ↓ -Optimistic cache updates for real-time data -``` - -### 7.2 WS → Cache Strategy - -| WS Event | Action | -|----------|--------| -| `message_created` | Optimistic insert to message list + dashboard stats | -| `message_analyzed` | Update AI fields in message cache | -| `message_deleted` | Remove from cache + update counters | -| `voice_recording_started` | Update voice status | -| `voice_pcm_data` | Buffer to waveform canvas (bypass React) | -| `voice_active_user` | Update speakers cache | -| `analysis_queue_status` | Update queue progress | - -### 7.3 Query Config - -- `staleTime: 10_000` (10s) -- `gcTime: 5 * 60 * 1000` (5 min) -- `refetchOnWindowFocus: false` - -### 7.4 Global State (React Context) - -- `useMediaPlayer()` — current track, queue, play/skip/stop/volume -- `useChatbot()` — expression, minimized, chatHistory, setExpression - - Externally triggerable: `chatbot.setExpression("surprise")` on flagged message, `("listening")` on voice activity - -### 7.5 URL State - -Persistent page state via search params (not React state): -``` -/messages?guild=xxx&channel=yyy&selected=msg123&tab=all -``` - -### 7.6 Error Boundaries - -Each page has its own error boundary. One page failure doesn't affect others. - ---- - -## 8. Technology Stack - -- **Framework:** Next.js 16 (App Router, static export) -- **Language:** TypeScript strict -- **Styling:** Tailwind v4 + CSS custom properties -- **UI Base:** shadcn/ui components (adapted for glass theme) -- **Icons:** lucide-react -- **State/data:** @tanstack/react-query v5 -- **Charts:** Recharts 3.8 (with custom theme) -- **3D/Chatbot:** Live2D Cubism SDK WebGL (pixi.js wrapper) -- **Audio:** Web Audio API for waveform visualization -- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed) - ---- - -## 9. File Structure (New) - -``` -src/ -├── app/ -│ ├── layout.tsx # Root layout (fonts, theme script, Toaster) -│ ├── page.tsx # Redirect → /dashboard -│ ├── globals.css # Complete redesign CSS (tokens, glass, animations) -│ └── (dashboard)/ -│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, chatbot) -│ ├── dashboard/ -│ │ └── page.tsx # Ops Center -│ ├── messages/ -│ │ └── page.tsx # Split pane messages -│ ├── voice/ -│ │ └── page.tsx # Voice connection center -│ ├── recordings/ -│ │ └── page.tsx # Recording library -│ └── settings/ -│ └── page.tsx # Settings page -│ -├── components/ -│ ├── layout/ -│ │ ├── top-nav.tsx # Floating top navigation bar -│ │ ├── sub-nav.tsx # Per-page sub-navigation tabs -│ │ ├── hidden-sidebar.tsx # Hover-activated guild sidebar -│ │ └── mobile-nav.tsx # Mobile bottom nav (updated design) -│ │ -│ ├── glass/ -│ │ ├── card.tsx # Glass card component (base, elevated, interactive) -│ │ ├── panel.tsx # Glass panel wrapper -│ │ └── divider.tsx # Glass-styled separator -│ │ -│ ├── dashboard/ -│ │ ├── stat-card.tsx # Stat card with micro sparkline -│ │ ├── live-stream.tsx # Auto-scrolling message stream -│ │ ├── mod-queue.tsx # Moderation queue with quick actions -│ │ ├── message-trend-chart.tsx # 7-day area chart -│ │ ├── activity-heatmap.tsx # Hour × day heatmap -│ │ └── top-channels-chart.tsx # Top channels bar chart -│ │ -│ ├── messages/ -│ │ ├── message-list.tsx # Left pane — scrollable message list -│ │ ├── message-card.tsx # Individual message card (redesigned) -│ │ ├── message-detail.tsx # Right pane — full detail -│ │ ├── attachments-grid.tsx # Attachments gallery -│ │ ├── ai-analysis-panel.tsx # AI analysis breakdown -│ │ └── search-overlay.tsx # Cmd+K search spotlight -│ │ -│ ├── voice/ -│ │ ├── connection-card.tsx # Guild/channel selector + status -│ │ ├── speaker-waveform.tsx # Canvas waveform per speaker -│ │ ├── mic-control.tsx # Mic toggle + volume -│ │ └── activity-timeline.tsx # Voice activity bar chart -│ │ -│ ├── recordings/ -│ │ ├── recording-card.tsx # Glass card with waveform preview -│ │ └── recording-player.tsx # Inline audio player -│ │ -│ ├── chatbot/ -│ │ ├── chatbot-container.tsx # Floating L2D container -│ │ ├── chatbot-canvas.tsx # WebGL canvas for L2D rendering -│ │ ├── chat-panel.tsx # Chat input + history -│ │ └── chatbot-context.tsx # Context provider -│ │ -│ ├── media/ -│ │ └── mini-player.tsx # Floating mini media player -│ │ -│ ├── shared/ -│ │ ├── error-state.tsx # Error boundary fallback -│ │ ├── loading-skeleton.tsx # Glass shimmer skeleton -│ │ └── empty-state.tsx # Empty state illustration -│ │ -│ └── ui/ # shadcn/ui components (adapted to glass) -│ ├── button.tsx, badge.tsx, dialog.tsx, ... -│ -├── lib/ -│ ├── api/ # Existing API client (unchanged) -│ ├── ws/ -│ │ ├── context.tsx # WS provider (unchanged) -│ │ └── types.ts # WS event types -│ ├── hooks/ # Existing hooks + new ones -│ │ ├── use-media-player.ts # Global media state -│ │ ├── use-chatbot.ts # Chatbot context hook -│ │ └── use-heatmap.ts # Heatmap data hook -│ ├── types/ # Existing types (unchanged) -│ ├── navigation.ts # Nav items (updated) -│ └── format.ts # Format utilities -``` - ---- - -## 10. Implementation Order - -### Phase 1 — Foundation -1. Update `globals.css` with new design tokens (colors, glass, radius, typography, animations) -2. Rewrite root `layout.tsx` with theme system -3. Build glass component system (`card.tsx`, `panel.tsx`) -4. Build `top-nav.tsx`, `sub-nav.tsx`, `hidden-sidebar.tsx` -5. Update dashboard layout with new nav - -### Phase 2 — Dashboard Ops Center -6. Build `stat-card.tsx` with micro sparkline -7. Build `live-stream.tsx` -8. Build `mod-queue.tsx` -9. Build charts: `message-trend-chart.tsx`, `activity-heatmap.tsx`, `top-channels-chart.tsx` -10. Rewrite dashboard page - -### Phase 3 — Messages (Split Pane) -11. Build `message-list.tsx`, `message-card.tsx` (redesigned) -12. Build `message-detail.tsx`, `ai-analysis-panel.tsx`, `attachments-grid.tsx` -13. Build `search-overlay.tsx` -14. Rewrite messages page with split-pane layout - -### Phase 4 — Voice, Recordings, Settings -15. Build voice components and rewrite voice page -16. Build recording components and rewrite recordings page -17. Rewrite settings page - -### Phase 5 — Floating Elements -18. Build `mini-player.tsx` for media -19. Build chatbot components (L2D integration) - ---- - -## 11. Testing - -- Visual regression checks per component -- WS integration tests for cache updates -- Responsive breakpoint testing (mobile bottom nav) -- L2D chatbot load + expression trigger - ---- - -## 12. Non-Goals (Out of Scope) - -- Authentication — remains public -- Backend API changes — only frontend redesign -- Database changes — no schema modifications -- New backend WebSocket events — reuse existing -- L2D model creation — integration only (model file provided separately)