feat: update dependencies and improve dashboard functionality
Deploy to VPS / deploy (push) Failing after 1m43s

- Added new dependencies for Next.js and lucide-react in pnpm-workspace.yaml.
- Refactored DashboardPage component to improve readability and error handling.
- Enhanced Header component to display error status with an alert icon.
- Updated MobileTabBar and Sidebar components to use a centralized tabs definition.
- Improved ChannelsView in dashboard-panel to handle channel fetching more cleanly.
- Fixed ActiveSpeaker type to use camelCase for userId.
- Updated MessagesPanel to handle guildId checks more gracefully.
- Adjusted API calls in dashboard and messages to align with backend expectations.
- Refined type definitions across various interfaces for consistency and clarity.
This commit is contained in:
asepharyana committed 2026-07-26 14:27:36 +07:00
1 parent 9ecc4a6caa
commit 0a6a9fd982
62 files changed
+2764 -305

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@@ -30,7 +30,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"style": { "style": {
"noNonNullAssertion": "warn", "noNonNullAssertion": "warn",
"useNodejsImportProtocol": "warn" "useNodejsImportProtocol": "warn"
+1 -1
View File
@@ -23,7 +23,7 @@
"install:yt-dlp": "sh scripts/install-yt-dlp.sh" "install:yt-dlp": "sh scripts/install-yt-dlp.sh"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "^2.5.5",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.22.2", "tsx": "^4.22.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+2564 -91
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -19,6 +19,18 @@ minimumReleaseAgeExclude:
- pg-protocol@1.14.0 - pg-protocol@1.14.0
- pg@8.21.0 - pg@8.21.0
- tsx@4.22.2 - tsx@4.22.2
- '@next/env@16.2.12'
- '@next/swc-darwin-arm64@16.2.12'
- '@next/swc-darwin-x64@16.2.12'
- '@next/swc-linux-arm64-gnu@16.2.12'
- '@next/swc-linux-arm64-musl@16.2.12'
- '@next/swc-linux-x64-gnu@16.2.12'
- '@next/swc-linux-x64-musl@16.2.12'
- '@next/swc-win32-arm64-msvc@16.2.12'
- '@next/swc-win32-x64-msvc@16.2.12'
- lucide-react@1.27.0
- next@16.2.12
- shadcn@4.15.0
onlyBuiltDependencies: onlyBuiltDependencies:
- '@discordjs/opus' - '@discordjs/opus'
+1 -1
View File
@@ -28,7 +28,7 @@ async function shutdown(signal: string) {
// 1. Stop accepting new HTTP connections // 1. Stop accepting new HTTP connections
if (httpServer) { if (httpServer) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
httpServer!.close(() => { httpServer?.close(() => {
logger.info("HTTP server closed"); logger.info("HTTP server closed");
resolve(); resolve();
}); });
@@ -2,7 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { getPool } from "../../shared/database/index.js"; import { getPool } from "../../shared/database/index.js";
import type { ListUsersQuery } from "./dashboard.service.js"; import type { ListUsersQuery } from "./dashboard.service.js";
const logger = createChildLogger("dashboard.repository"); const _logger = createChildLogger("dashboard.repository");
export class DashboardRepository { export class DashboardRepository {
async getStats() { async getStats() {
@@ -1,6 +1,6 @@
import type { Router } from "express"; import type { Router } from "express";
import express from "express"; import express from "express";
import { collectDefaultMetrics, register } from "prom-client"; import { collectDefaultMetrics } from "prom-client";
import { handleHealthCheck, handleMetrics } from "./health.controller.js"; import { handleHealthCheck, handleMetrics } from "./health.controller.js";
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.) // Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
@@ -1,7 +1,7 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js"; import { healthRepository } from "./health.repository.js";
const logger = createChildLogger("health.service"); const _logger = createChildLogger("health.service");
export class HealthService { export class HealthService {
async getHealth(verbose = false) { async getHealth(verbose = false) {
@@ -4,7 +4,7 @@
* Prometheus metrics for AI moderation pipeline. * Prometheus metrics for AI moderation pipeline.
* Defined in backend (where prom-client is installed + /api/metrics endpoint). * Defined in backend (where prom-client is installed + /api/metrics endpoint).
*/ */
import { Counter, Histogram, register } from "prom-client"; import { Counter, Histogram } from "prom-client";
// ── LLM Call Metrics ── // ── LLM Call Metrics ──
export const llmCallsTotal = new Counter({ export const llmCallsTotal = new Counter({
@@ -56,7 +56,7 @@ export const handleMascotChat = asyncHandler(
export const getMascotChatHistory = asyncHandler( export const getMascotChatHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = (req as AuthenticatedRequest).userId || "anonymous";
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await mascotChatService.getChatHistory(userId, limit); const history = await mascotChatService.getChatHistory(userId, limit);
@@ -66,7 +66,7 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
type MediaReplyData = Record<string, unknown> | MediaState; type MediaReplyData = Record<string, unknown> | MediaState;
function fromReply(data: MediaReplyData): MediaState { function _fromReply(data: MediaReplyData): MediaState {
return normalizeMediaState(data as Record<string, unknown>); return normalizeMediaState(data as Record<string, unknown>);
} }
@@ -68,7 +68,7 @@ export class RecordingsService {
const items = rows.slice(0, limit) as unknown as RecordingRow[]; const items = rows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = rows.length > limit; const hasMore = rows.length > limit;
const nextCursor = hasMore const nextCursor = hasMore
? String(items[items.length - 1]!.created_at) ? String(items[items.length - 1]?.created_at)
: null; : null;
return { items, nextCursor, hasMore }; return { items, nextCursor, hasMore };
@@ -1,6 +1,5 @@
import type { CommandReply } from "@bete/shared"; import type { CommandReply } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { publishCommand, readRedisStatus } from "./redis/index.js";
export { createChildLogger }; export { createChildLogger };
+1 -1
View File
@@ -15,7 +15,7 @@ import {
pageResult, pageResult,
retryWithBackoff, retryWithBackoff,
} from "@bete/shared/utils"; } from "@bete/shared/utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
// ─── Backend middleware ────────────────────────────────────────────────────── // ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js"; import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
@@ -24,10 +24,7 @@ import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
import { registerThreadCapture } from "../modules/thread-tracking/index.js"; import { registerThreadCapture } from "../modules/thread-tracking/index.js";
import { registerPresenceCapture } from "../modules/user-presence/index.js"; import { registerPresenceCapture } from "../modules/user-presence/index.js";
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js"; import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
startMuxerWorker,
stopMuxerWorker,
} from "../modules/voice-recording/muxer.js";
import { import {
setPcmWsClient, setPcmWsClient,
setEventBroadcaster as setRecorderEventBroadcaster, setEventBroadcaster as setRecorderEventBroadcaster,
@@ -19,7 +19,6 @@ import {
} from "./batchProcessor.js"; } from "./batchProcessor.js";
import { scheduleConversationAnalysis } from "./batchScheduler.js"; import { scheduleConversationAnalysis } from "./batchScheduler.js";
import { import {
_redisEventBroadcaster,
broadcastAnalysisCompleted, broadcastAnalysisCompleted,
conversationConsecutiveErrors, conversationConsecutiveErrors,
conversationDebounceTimers, conversationDebounceTimers,
@@ -16,13 +16,13 @@ let activeCount = 0;
let pendingCount = 0; let pendingCount = 0;
// Track queue state changes for logging // Track queue state changes for logging
function updateCounts(): void { function _updateCounts(): void {
// p-limit exposes queueSize and activeCount via constructor internals, // p-limit exposes queueSize and activeCount via constructor internals,
// but we track via our wrapper to avoid depending on internals. // but we track via our wrapper to avoid depending on internals.
} }
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> { export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
const queuedAt = activeCount + pendingCount; const _queuedAt = activeCount + pendingCount;
pendingCount++; pendingCount++;
logger.debug( logger.debug(
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT }, { activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import { import { messagesTable } from "../../shared/database/schema.js";
channelCulturesTable,
messagesTable,
} from "../../shared/database/schema.js";
import { updateChannelCulture } from "./channelCultureStore.js"; import { updateChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
@@ -550,7 +550,7 @@ async function downloadMediaCandidate(
imageMap: Map<string, MessageImagePart[]>, imageMap: Map<string, MessageImagePart[]>,
mediaAnalysisMap: Map<string, string[]>, mediaAnalysisMap: Map<string, string[]>,
): Promise<void> { ): Promise<void> {
const log = createChildLogger("mediaAnalysis"); const _log = createChildLogger("mediaAnalysis");
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return; if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
if (candidate.customEmojiId || candidate.stickerName) { if (candidate.customEmojiId || candidate.stickerName) {
@@ -646,7 +646,7 @@ export async function prepareMediaMessage(
target: MessageRecord, target: MessageRecord,
allAttachments: AttachmentRecord[] | undefined, allAttachments: AttachmentRecord[] | undefined,
): Promise<PreparedMediaMessage> { ): Promise<PreparedMediaMessage> {
const log = createChildLogger("mediaAnalysis"); const _log = createChildLogger("mediaAnalysis");
const targetId = target.id; const targetId = target.id;
const imageMap = new Map<string, MessageImagePart[]>(); const imageMap = new Map<string, MessageImagePart[]>();
const webTextMap = new Map<string, string[]>(); const webTextMap = new Map<string, string[]>();
@@ -10,7 +10,6 @@ import { delay, retryWithBackoff } from "@bete/shared/utils";
import type { ChatCompletion } from "openai/resources/chat/completions"; import type { ChatCompletion } from "openai/resources/chat/completions";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js"; import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
import { getMessageById } from "../message-capture/messageStore.js";
import type { import type {
AnalysisResult, AnalysisResult,
AttachmentRecord, AttachmentRecord,
@@ -18,15 +17,7 @@ import type {
} from "../message-capture/types.js"; } from "../message-capture/types.js";
import { getChannelCulture } from "./channelCultureStore.js"; import { getChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
import type { import { hasMediaContent, prepareMediaMessage } from "./mediaAnalysisClient.js";
MessageImagePart,
PreparedMediaMessage,
} from "./mediaAnalysisClient.js";
import {
analyzeSingleMediaImage,
hasMediaContent,
prepareMediaMessage,
} from "./mediaAnalysisClient.js";
import { import {
buildReferenceXml, buildReferenceXml,
escapeXml, escapeXml,
@@ -816,7 +807,7 @@ export async function runSimpleTextFallback(
const MAX_CONTENT_CHARS = 500; const MAX_CONTENT_CHARS = 500;
const truncatedContent = const truncatedContent =
content.length > MAX_CONTENT_CHARS content.length > MAX_CONTENT_CHARS
? content.slice(0, MAX_CONTENT_CHARS) + "..." ? `${content.slice(0, MAX_CONTENT_CHARS)}...`
: content; : content;
let userProfileCtx = ""; let userProfileCtx = "";
@@ -876,7 +867,7 @@ Jawab HANYA dengan satu kata: clean, warn, atau flagged`;
let category = ""; let category = "";
if (status === "clean") { if (status === "clean") {
analysis = `${message.username ?? "user"}: ${content.length > 200 ? content.slice(0, 200) + "..." : content}. Percakapan normal, tidak ada pelanggaran.`; analysis = `${message.username ?? "user"}: ${content.length > 200 ? `${content.slice(0, 200)}...` : content}. Percakapan normal, tidak ada pelanggaran.`;
} else { } else {
category = status === "flagged" ? "harassment" : "spam"; category = status === "flagged" ? "harassment" : "spam";
const categoryOptions = const categoryOptions =
@@ -958,7 +949,7 @@ Kategori: spam`;
policyVersion: "default-simple-2026-06", policyVersion: "default-simple-2026-06",
evidence: evidence:
status !== "clean" status !== "clean"
? [content.length > 120 ? content.slice(0, 120) + "..." : content] ? [content.length > 120 ? `${content.slice(0, 120)}...` : content]
: [], : [],
}; };
} }
@@ -795,7 +795,7 @@ export function sanitizeAiContent(
// 3. Cap length // 3. Cap length
const capped = const capped =
escaped.length > maxLen escaped.length > maxLen
? escaped.slice(0, maxLen) + "…[truncated]" ? `${escaped.slice(0, maxLen)}…[truncated]`
: escaped; : escaped;
// 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts) // 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts)
@@ -4,8 +4,6 @@ import { extractJson } from "./jsonExtractor.js";
import { ModerationResponseSchema } from "./moderationSchemas.js"; import { ModerationResponseSchema } from "./moderationSchemas.js";
import { import {
clampScore, clampScore,
DEFERRAL_ANALYSIS_PATTERN,
DEFERRAL_EXCEPTION_PATTERN,
deriveRecommendedAction, deriveRecommendedAction,
deriveSeverity, deriveSeverity,
hasDeferralAnalysis, hasDeferralAnalysis,
@@ -46,7 +44,7 @@ export function parseModerationResponse(
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(content); parsed = JSON.parse(content);
} catch (e) { } catch (_e) {
parsed = extractJson(content); parsed = extractJson(content);
} }
@@ -90,7 +90,7 @@ export function logModerationAnalysis(
}, },
parseErrors: string[] = [], parseErrors: string[] = [],
): void { ): void {
const response: ModerationAnalysisResponse = { const _response: ModerationAnalysisResponse = {
messageIds, messageIds,
batchSize: messageIds.length, batchSize: messageIds.length,
model, model,
@@ -158,7 +158,7 @@ export function logCacheEvent(
cacheKey: string, cacheKey: string,
source: "text" | "media" | "sticker", source: "text" | "media" | "sticker",
): void { ): void {
const event: CacheHitEvent = { const _event: CacheHitEvent = {
type, type,
cacheKey, cacheKey,
source, source,
@@ -260,7 +260,7 @@ export function logAnalysisSummary(
duration_ms: durationMs, duration_ms: durationMs,
per_message_avg_ms: Math.round(durationMs / totalMessages), per_message_avg_ms: Math.round(durationMs / totalMessages),
summary, summary,
success_rate: ((successCount / totalMessages) * 100).toFixed(1) + "%", success_rate: `${((successCount / totalMessages) * 100).toFixed(1)}%`,
}, },
`Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`, `Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`,
); );
@@ -86,7 +86,7 @@ export async function incrementTextCacheHit(text: string): Promise<void> {
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`, `UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
[text], [text],
); );
} catch (error) { } catch (_error) {
// Silent fail — this is just a counter, not critical // Silent fail — this is just a counter, not critical
} }
} }
@@ -3,7 +3,7 @@ import { isIP } from "node:net";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { createAbortControllerWithTimeout } from "@bete/shared/utils"; import { createAbortControllerWithTimeout } from "@bete/shared/utils";
const log = createChildLogger("urlFetcher"); const _log = createChildLogger("urlFetcher");
export interface FetchedUrlContext { export interface FetchedUrlContext {
url: string; url: string;
@@ -53,14 +53,14 @@ async function isSafeUrl(urlStr: string): Promise<boolean> {
return false; return false;
} }
} }
} catch (err) { } catch (_err) {
// If DNS fails, we can't fetch it anyway // If DNS fails, we can't fetch it anyway
return false; return false;
} }
} }
return true; return true;
} catch (err) { } catch (_err) {
return false; return false;
} }
} }
@@ -70,7 +70,7 @@ function extractOgImage(html: string): string | null {
const ogRegex = const ogRegex =
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; /<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
const match = html.match(ogRegex); const match = html.match(ogRegex);
if (match && match[1]) { if (match?.[1]) {
// Unescape basic HTML entities // Unescape basic HTML entities
return match[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'); return match[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
} }
@@ -79,7 +79,7 @@ function extractOgImage(html: string): string | null {
const ogRegexRev = const ogRegexRev =
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; /<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
const matchRev = html.match(ogRegexRev); const matchRev = html.match(ogRegexRev);
if (matchRev && matchRev[1]) { if (matchRev?.[1]) {
return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'); return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
} }
@@ -164,7 +164,7 @@ export async function fetchUrlSafely(
// If it's HTML, try to find an og:image first (for Tenor/Giphy etc) // If it's HTML, try to find an og:image first (for Tenor/Giphy etc)
if (contentType.startsWith("text/html")) { if (contentType.startsWith("text/html")) {
const ogImage = extractOgImage(text); const ogImage = extractOgImage(text);
if (ogImage && ogImage.startsWith("http")) { if (ogImage?.startsWith("http")) {
// Fetch the og:image instead // Fetch the og:image instead
return fetchUrlSafely(ogImage, depth + 1); return fetchUrlSafely(ogImage, depth + 1);
} }
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import { import { messagesTable } from "../../shared/database/schema.js";
messagesTable,
userProfilesTable,
} from "../../shared/database/schema.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
import { updateUserProfile } from "./userProfileStore.js"; import { updateUserProfile } from "./userProfileStore.js";
@@ -48,7 +45,7 @@ async function learnUserProfile(
for (const msg of recentMessages) { for (const msg of recentMessages) {
const ch = msg.channelId ?? "unknown"; const ch = msg.channelId ?? "unknown";
if (!channelGroups.has(ch)) channelGroups.set(ch, []); if (!channelGroups.has(ch)) channelGroups.set(ch, []);
channelGroups.get(ch)!.push(msg); channelGroups.get(ch)?.push(msg);
} }
// Build messages text with channel context // Build messages text with channel context
@@ -9,10 +9,7 @@ import { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis"; import Redis from "ioredis";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import type { import type { VoiceController } from "../voice-recording/voiceController.js";
VoiceController,
VoiceStatus,
} from "../voice-recording/voiceController.js";
import { GuildHandler } from "./guild.handler.js"; import { GuildHandler } from "./guild.handler.js";
import { import {
type CommandHandlerFn, type CommandHandlerFn,
@@ -1,8 +1,4 @@
import { import type { CommandMessage, CommandReply } from "@bete/shared";
COMMAND_VOICE_DISCONNECT_GUILD,
type CommandMessage,
type CommandReply,
} from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
@@ -1,6 +1,5 @@
import http from "node:http"; import http from "node:http";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
const logger = createChildLogger("gateway-metrics"); const logger = createChildLogger("gateway-metrics");
@@ -82,7 +81,7 @@ function formatMetrics(): string {
lines.push(`${fullName} ${metric.value}`); lines.push(`${fullName} ${metric.value}`);
} }
return lines.join("\n") + "\n"; return `${lines.join("\n")}\n`;
} }
export function startMetricsServer(): void { export function startMetricsServer(): void {
@@ -1,4 +1,3 @@
import { decodeCursor, encodeCursor } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
@@ -1,15 +1,5 @@
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
and,
asc,
desc,
eq,
inArray,
isNull,
or,
type SQL,
sql,
} from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type * as schema from "../../shared/database/schema.js"; import type * as schema from "../../shared/database/schema.js";
import { messagesTable } from "../../shared/database/schema.js"; import { messagesTable } from "../../shared/database/schema.js";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,4 +1,4 @@
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared"; import { decodeCursor, pageResult } from "@bete/shared";
import { createChildLogger, type Logger } from "@bete/shared/logger"; import { createChildLogger, type Logger } from "@bete/shared/logger";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -1,14 +1,8 @@
import type fs from "node:fs"; import type fs from "node:fs";
import type { import type {
AIRecommendedAction,
AISeverity,
AIStatus,
AnalysisQueueStatus, AnalysisQueueStatus,
AttachmentRecord, AttachmentRecord,
BroadcasterClient,
MessageRecord, MessageRecord,
ModerationBroadcaster,
RoleMetadata,
UserMetadata, UserMetadata,
VoiceRecordingUploadData, VoiceRecordingUploadData,
} from "@bete/shared"; } from "@bete/shared";
@@ -225,7 +225,7 @@ export function resolveMediaUrl(
// -- stderr (capture for diagnostics, capped at 4KB) ---------------------------------- // -- stderr (capture for diagnostics, capped at 4KB) ----------------------------------
const MAX_STDERR = 4096; const _MAX_STDERR = 4096;
if (proc.stderr) { if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => { proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8"); stderrBuf += chunk.toString("utf8");
@@ -1,7 +1,6 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js"; import { getDatabase } from "../../shared/database/drizzle.js";
import type * as schema from "../../shared/database/schema.js"; import type * as schema from "../../shared/database/schema.js";
import { muxerJobsTable } from "../../shared/database/schema.js"; import { muxerJobsTable } from "../../shared/database/schema.js";
@@ -1,7 +1,7 @@
import { Transform, type TransformCallback } from "node:stream"; import { Transform, type TransformCallback } from "node:stream";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("packet-filter"); const _logger = createChildLogger("packet-filter");
/** /**
* Transform stream to filter out audio packets that are too small. * Transform stream to filter out audio packets that are too small.
@@ -19,7 +19,7 @@ export class PacketFilter extends Transform {
_transform( _transform(
chunk: Buffer, chunk: Buffer,
encoding: string, _encoding: string,
callback: TransformCallback, callback: TransformCallback,
): void { ): void {
this.totalCount++; this.totalCount++;
@@ -16,7 +16,6 @@ import type { VoicePcmWsClient } from "../voice-pcm-ws/index.js";
import { import {
createRecordingSession, createRecordingSession,
type RecordingSession, type RecordingSession,
type SessionRecordingMetadata,
} from "./recorder/sessionRecording.js"; } from "./recorder/sessionRecording.js";
import { createSpeakingHandler } from "./recorder/speakingHandler.js"; import { createSpeakingHandler } from "./recorder/speakingHandler.js";
@@ -141,7 +140,7 @@ export async function startRecording(
activeSessions, activeSessions,
recordingsDir, recordingsDir,
pcmSender: _pcmWsClient pcmSender: _pcmWsClient
? (pcm, userId) => _pcmWsClient!.sendPcm(userId, pcm) ? (pcm, userId) => _pcmWsClient?.sendPcm(userId, pcm)
: undefined, : undefined,
}); });
@@ -23,7 +23,6 @@ export class VoiceTransmitter {
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT; private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
/** Queue for PCM chunks when backpressure is active */ /** Queue for PCM chunks when backpressure is active */
private backpressureQueue: Buffer[] = []; private backpressureQueue: Buffer[] = [];
private draining = false;
/** Serialise start/stop to prevent races between rapid toggle commands */ /** Serialise start/stop to prevent races between rapid toggle commands */
private gate = Promise.resolve(); private gate = Promise.resolve();
/** Set true before sending SIGTERM so exit handler knows it's intentional */ /** Set true before sending SIGTERM so exit handler knows it's intentional */
@@ -176,7 +175,7 @@ export class VoiceTransmitter {
logger.info("Voice transmitter started"); logger.info("Voice transmitter started");
} finally { } finally {
release!(); release?.();
} }
} }
@@ -227,7 +226,7 @@ export class VoiceTransmitter {
discordPlayer.stop("browser-bridge"); discordPlayer.stop("browser-bridge");
logger.info("Voice transmitter stopped"); logger.info("Voice transmitter stopped");
} finally { } finally {
release!(); release?.();
} }
} }
@@ -1,6 +1,6 @@
import { AppError } from "@bete/shared/errors"; import { AppError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; import type { VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { discordPlayer } from "./player.js"; import { discordPlayer } from "./player.js";
import { startRecording, stopRecording } from "./recorder.js"; import { startRecording, stopRecording } from "./recorder.js";
@@ -1,5 +1,4 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
const logger = createChildLogger("webhook-notifier"); const logger = createChildLogger("webhook-notifier");
@@ -2,7 +2,6 @@ import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
import { import {
bigint as pgBigint, bigint as pgBigint,
boolean as pgBoolean, boolean as pgBoolean,
foreignKey as pgForeignKey,
index as pgIndex, index as pgIndex,
integer as pgInteger, integer as pgInteger,
jsonb as pgJsonb, jsonb as pgJsonb,
+26 -16
View File
@@ -1,14 +1,14 @@
"use client"; "use client";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { voiceApi } from "@/lib/api"; import { useCallback, useEffect, useState } from "react";
import { useAppConfig } from "@/lib/hooks/use-config";
import type { Guild } from "@/lib/types";
import { DashboardPanel } from "@/features/dashboard/dashboard-panel"; import { DashboardPanel } from "@/features/dashboard/dashboard-panel";
import { LivePanel } from "@/features/live/live-panel"; import { LivePanel } from "@/features/live/live-panel";
import { MessagesPanel } from "@/features/messages/messages-panel"; import { MessagesPanel } from "@/features/messages/messages-panel";
import { voiceApi } from "@/lib/api";
import { useAppConfig } from "@/lib/hooks/use-config";
import type { Guild } from "@/lib/types";
export default function DashboardPage() { export default function DashboardPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -45,12 +45,17 @@ export default function DashboardPage() {
if (!cancelled) setGuilds(g); if (!cancelled) setGuilds(g);
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"); if (!cancelled)
setGuildsError(
err instanceof Error ? err.message : "Failed to load guilds",
);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setGuildsLoading(false); if (!cancelled) setGuildsLoading(false);
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
// Resolve guild ID once config and guilds are loaded // Resolve guild ID once config and guilds are loaded
@@ -80,9 +85,15 @@ export default function DashboardPage() {
onRetry={() => { onRetry={() => {
setGuildsLoading(true); setGuildsLoading(true);
setGuildsError(null); setGuildsError(null);
voiceApi.getGuilds().then(setGuilds).catch( voiceApi
(err) => setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"), .getGuilds()
).finally(() => setGuildsLoading(false)); .then(setGuilds)
.catch((err) =>
setGuildsError(
err instanceof Error ? err.message : "Failed to load guilds",
),
)
.finally(() => setGuildsLoading(false));
}} }}
/> />
@@ -90,12 +101,8 @@ export default function DashboardPage() {
{isReady ? ( {isReady ? (
<> <>
{tab === "live" && <LivePanel />} {tab === "live" && <LivePanel />}
{tab === "dashboard" && ( {tab === "dashboard" && <DashboardPanel guildId={selectedGuildId} />}
<DashboardPanel guildId={selectedGuildId} /> {tab === "messages" && <MessagesPanel guildId={selectedGuildId} />}
)}
{tab === "messages" && (
<MessagesPanel guildId={selectedGuildId} />
)}
</> </>
) : ( ) : (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@@ -165,7 +172,10 @@ function GuildBar({
return ( return (
<div className="flex items-center gap-2 rounded-lg border p-3"> <div className="flex items-center gap-2 rounded-lg border p-3">
<label htmlFor="guild-select" className="text-sm font-medium text-muted-foreground whitespace-nowrap"> <label
htmlFor="guild-select"
className="text-sm font-medium text-muted-foreground whitespace-nowrap"
>
Guild: Guild:
</label> </label>
<select <select
+4 -10
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -29,16 +30,9 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>
<script <Script id="theme-script" strategy="beforeInteractive">
dangerouslySetInnerHTML={{ {`try{const t=localStorage.getItem('theme')||'dark';document.documentElement.classList.add(t)}catch(e){}`}
__html: ` </Script>
try {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.classList.add(theme);
} catch(e) {}
`,
}}
/>
</head> </head>
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react"; import { AlertCircle, Moon, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
@@ -37,6 +37,11 @@ export function Header() {
<Wifi className="size-3 text-yellow-500" /> <Wifi className="size-3 text-yellow-500" />
<span className="hidden sm:inline">Connecting</span> <span className="hidden sm:inline">Connecting</span>
</> </>
) : status === "error" ? (
<>
<AlertCircle className="size-3 text-destructive" />
<span className="hidden sm:inline">Error</span>
</>
) : ( ) : (
<> <>
<WifiOff className="size-3 text-destructive" /> <WifiOff className="size-3 text-destructive" />
@@ -1,18 +1,11 @@
"use client"; "use client";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation"; import { type TabId, tabs } from "@/lib/tabs";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function MobileTabBar({ activeTab }: { activeTab: TabId }) { export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
return ( return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background"> <nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background">
@@ -21,7 +14,11 @@ export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => router.push(`/dashboard?tab=${id}`)} onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", id);
router.push(`/dashboard?${params}`);
}}
data-active={activeTab === id ? "" : undefined} data-active={activeTab === id ? "" : undefined}
className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors" className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors"
> >
@@ -1,21 +1,17 @@
"use client"; "use client";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { Radio } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { type TabId, tabs } from "@/lib/tabs";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function Sidebar({ activeTab }: { activeTab: TabId }) { export function Sidebar({ activeTab }: { activeTab: TabId }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const handleTabClick = (tabId: TabId) => { const handleTabClick = (tabId: TabId) => {
router.push(`/dashboard?tab=${tabId}`); const params = new URLSearchParams(searchParams.toString());
params.set("tab", tabId);
router.push(`/dashboard?${params}`);
}; };
return ( return (
@@ -11,6 +11,7 @@ import {
Shield, Shield,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { dashboardApi } from "@/lib/api"; import { dashboardApi } from "@/lib/api";
import type { import type {
@@ -123,7 +124,7 @@ export function DashboardPanel({ guildId }: { guildId: string }) {
// ── Stats View ──────────────────────────────────────────── // ── Stats View ────────────────────────────────────────────
function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) { function StatsView(_props: { onNavigate: (view: View) => void }) {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -166,8 +167,8 @@ function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) {
{/* Metric cards */} {/* Metric cards */}
{loading ? ( {loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }, (_, i) => `stat-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="h-3 w-16 bg-muted rounded animate-pulse" /> <div className="h-3 w-16 bg-muted rounded animate-pulse" />
<div className="h-8 w-20 bg-muted rounded animate-pulse" /> <div className="h-8 w-20 bg-muted rounded animate-pulse" />
</div> </div>
@@ -339,8 +340,8 @@ function UsersView({
{loading ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }, (_, i) => `user-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-muted animate-pulse" /> <div className="size-10 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
@@ -362,9 +363,11 @@ function UsersView({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden"> <div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
{user.avatar_url ? ( {user.avatar_url ? (
<img <Image
src={user.avatar_url} src={user.avatar_url}
alt="" alt=""
width={40}
height={40}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -407,21 +410,24 @@ function ChannelsView({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const fetchChannels = useCallback(async (searchQuery?: string) => { const fetchChannels = useCallback(
setLoading(true); async (searchQuery?: string) => {
try { setLoading(true);
const result = await dashboardApi.listChannels( try {
20, const result = await dashboardApi.listChannels(
searchQuery, 20,
guildId || undefined, searchQuery,
); guildId || undefined,
setChannels(result.data); );
} catch { setChannels(result.data);
// ignore } catch {
} finally { // ignore
setLoading(false); } finally {
} setLoading(false);
}, [guildId]); }
},
[guildId],
);
useEffect(() => { useEffect(() => {
fetchChannels(); fetchChannels();
@@ -450,8 +456,8 @@ function ChannelsView({
{loading ? ( {loading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }, (_, i) => `ch-sk-${i}`).map((key) => (
<div key={i} className="rounded-lg border p-4 space-y-2"> <div key={key} className="rounded-lg border p-4 space-y-2">
<div className="h-4 w-32 bg-muted rounded animate-pulse" /> <div className="h-4 w-32 bg-muted rounded animate-pulse" />
<div className="h-3 w-24 bg-muted rounded animate-pulse" /> <div className="h-3 w-24 bg-muted rounded animate-pulse" />
</div> </div>
@@ -517,9 +523,11 @@ function UserDetailView({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden"> <div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden">
{user.avatar_url ? ( {user.avatar_url ? (
<img <Image
src={user.avatar_url} src={user.avatar_url}
alt="" alt=""
width={64}
height={64}
className="size-full object-cover" className="size-full object-cover"
/> />
) : ( ) : (
@@ -541,7 +549,10 @@ function UserDetailView({
value={user.flagged_count} value={user.flagged_count}
variant="destructive" variant="destructive"
/> />
<DetailStat label="Clean Streak" value={user.clean_message_streak} /> <DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat <DetailStat
label="Trust Score" label="Trust Score"
value={user.trust_score ?? 0} value={user.trust_score ?? 0}
@@ -14,6 +14,7 @@ import {
Trash2, Trash2,
Volume2, Volume2,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { recordingsApi, voiceApi } from "@/lib/api"; import { recordingsApi, voiceApi } from "@/lib/api";
import type { import type {
@@ -104,7 +105,7 @@ export function LivePanel() {
const unsubSpeaker = ws.on("voice_active_user", (user) => { const unsubSpeaker = ws.on("voice_active_user", (user) => {
const speaker = user as ActiveSpeaker; const speaker = user as ActiveSpeaker;
setSpeakers((prev) => { setSpeakers((prev) => {
const existing = prev.findIndex((s) => s.user_id === speaker.user_id); const existing = prev.findIndex((s) => s.userId === speaker.userId);
if (existing >= 0) { if (existing >= 0) {
const next = [...prev]; const next = [...prev];
next[existing] = speaker; next[existing] = speaker;
@@ -307,7 +308,7 @@ export function LivePanel() {
.filter((s) => s.speaking) .filter((s) => s.speaking)
.map((s) => ( .map((s) => (
<div <div
key={s.user_id} key={s.userId}
className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5" className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5"
> >
<span className="relative flex size-2"> <span className="relative flex size-2">
@@ -354,9 +355,11 @@ export function LivePanel() {
<p className="text-xs text-muted-foreground">Now Playing</p> <p className="text-xs text-muted-foreground">Now Playing</p>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{mediaState.current.thumbnailUrl && ( {mediaState.current.thumbnailUrl && (
<img <Image
src={mediaState.current.thumbnailUrl} src={mediaState.current.thumbnailUrl}
alt="" alt=""
width={48}
height={48}
className="size-12 rounded object-cover" className="size-12 rounded object-cover"
/> />
)} )}
Loaded 50 of 62 files, more files were not shown because too many files have changed in this diff. Show more