diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index 8401822..aa1dc32 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -1,32 +1,57 @@ "use client"; -import { AlertCircle, Clock, Hash, Shield, Sparkles, Users } from "lucide-react"; +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 { ChannelsSection } from "@/components/dashboard/channels-section"; import { LiveStream } from "@/components/dashboard/live-stream"; +import type { ModQueueItem } from "@/components/dashboard/mod-queue"; import { ModQueue } from "@/components/dashboard/mod-queue"; -import { MessageTrendChart } from "@/components/dashboard/message-trend-chart"; -import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap"; +import { StatCard } from "@/components/dashboard/stat-card"; import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; +import { UsersSection } from "@/components/dashboard/users-section"; import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { useReview, useStats } from "@/hooks"; -type DashboardTab = "stats" | "live" | "activity"; +type DashboardTab = "stats" | "live" | "users" | "channels"; export default function DashboardPage() { const [tab, setTab] = useState("stats"); const { data: stats, isLoading, error, refetch } = useStats(); + const { data: review = [] } = useReview(); + + const modQueueItems: ModQueueItem[] = review.slice(0, 10).map((msg) => ({ + id: msg.id, + content: msg.content || msg.id, + username: msg.username, + severity: + msg.ai_severity && msg.ai_severity !== "none" + ? (msg.ai_severity as ModQueueItem["severity"]) + : "medium", + reason: msg.ai_analysis ?? "AI moderation flag", + })); const subNavTabs = [ { id: "stats", label: "Stats", icon: }, { id: "live", label: "Live", icon: }, - { id: "activity", label: "Activity", icon: }, + { id: "users", label: "Users", icon: }, + { id: "channels", label: "Channels", icon: }, ]; return (
- setTab(t as DashboardTab)} /> + setTab(t as DashboardTab)} + /> {tab === "stats" && (
@@ -37,18 +62,46 @@ export default function DashboardPage() { ) : ( <>
- - - - - - + + + + + +
-
- - -
+ ({ + name: c.channel_name ?? c.channel_id, + count: c.message_count, + }))} + /> )}
@@ -57,15 +110,13 @@ export default function DashboardPage() { {tab === "live" && (
- +
)} - {tab === "activity" && ( -
- -
- )} + {tab === "users" && } + + {tab === "channels" && }
); } diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 874c0b6..e6c148b 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,12 +1,13 @@ "use client"; import { useState } from "react"; +import { SubNav } from "@/components/layout/sub-nav"; 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 { useRecordings } from "@/hooks"; +import { useRecordings, useRecordingsWsSync } from "@/hooks"; import type { VoiceRecording } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; type RecordingsTab = "library" | "stats"; @@ -14,10 +15,15 @@ export default function RecordingsPage() { const { data: recordings, isLoading, error, refetch } = useRecordings(); const [playingId, setPlayingId] = useState(null); const [tab, setTab] = useState("library"); + const ws = useWebSocket(); - const currentTrack = playingId && recordings - ? recordings.find((r: VoiceRecording) => r.id === playingId) - : null; + // Live-update the library when the gateway publishes voice_recording_uploaded + useRecordingsWsSync(ws); + + const currentTrack = + playingId && recordings + ? recordings.find((r: VoiceRecording) => r.id === playingId) + : null; return (
@@ -30,34 +36,38 @@ export default function RecordingsPage() { onTabChange={(t) => setTab(t as RecordingsTab)} /> - {tab === "library" && ( - <> - {error ? ( - - ) : isLoading ? ( - - ) : ( -
- {(recordings ?? []).map((rec: VoiceRecording) => ( - setPlayingId(id === playingId ? null : id)} - /> - ))} - {(recordings ?? []).length === 0 && ( -
No recordings yet
- )} -
- )} - - )} + {tab === "library" && + (error ? ( + + ) : isLoading ? ( + + ) : ( +
+ {(recordings ?? []).map((rec: VoiceRecording) => ( + setPlayingId(id === playingId ? null : id)} + /> + ))} + {(recordings ?? []).length === 0 && ( +
+ No recordings yet +
+ )} +
+ ))} {tab === "stats" && ( -
Recording stats coming soon
+
+ Recording stats coming soon +
)} - setPlayingId(null)} /> + setPlayingId(null)} + />
); } diff --git a/services/frontend/src/components/chatbot/chatbot-context.tsx b/services/frontend/src/components/chatbot/chatbot-context.tsx index fe395f8..8de51b5 100644 --- a/services/frontend/src/components/chatbot/chatbot-context.tsx +++ b/services/frontend/src/components/chatbot/chatbot-context.tsx @@ -10,9 +10,15 @@ import { useState, } from "react"; import { chatbotApi } from "@/lib/api"; -import type { ChatHistoryMessage } from "@/lib/types"; +import type { ChatbotHistoryRow } from "@/lib/types"; -export type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking"; +export type ChatbotExpression = + | "idle" + | "listening" + | "surprise" + | "happy" + | "sad" + | "talking"; interface ChatbotMessage { role: "user" | "assistant"; @@ -74,16 +80,29 @@ export function ChatbotProvider({ children }: { children: ReactNode }) { if (historyFetched.current) return; historyFetched.current = true; - chatbotApi.getHistory().then((history) => { - const mapped = (history ?? []).map((msg: ChatHistoryMessage) => ({ - role: msg.role as "user" | "assistant", - content: msg.content, - timestamp: msg.timestamp, - })); - setMessages(mapped); - }).catch(() => { - // API may not be available yet — silently ignore - }); + chatbotApi + .getHistory() + .then((res) => { + // Backend returns rows {user_message, bot_response, created_at} — + // interleave each user message with its bot reply. + const withReplies: ChatbotMessage[] = []; + for (const row of res.history ?? []) { + withReplies.push({ + role: "user", + content: row.user_message, + timestamp: row.created_at, + }); + withReplies.push({ + role: "assistant", + content: row.bot_response, + timestamp: row.created_at, + }); + } + setMessages(withReplies); + }) + .catch(() => { + // API may not be available yet — silently ignore + }); }, []); const sendMessage = useCallback(async (content: string) => { diff --git a/services/frontend/src/components/dashboard/activity-heatmap.tsx b/services/frontend/src/components/dashboard/activity-heatmap.tsx deleted file mode 100644 index bb25768..0000000 --- a/services/frontend/src/components/dashboard/activity-heatmap.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"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 : ""} -
-
- ))} -
-
-
- - ); -} diff --git a/services/frontend/src/components/dashboard/channels-section.tsx b/services/frontend/src/components/dashboard/channels-section.tsx new file mode 100644 index 0000000..b8c4a73 --- /dev/null +++ b/services/frontend/src/components/dashboard/channels-section.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { Hash, Search } from "lucide-react"; +import { useCallback, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useChannelDetail, useChannels } from "@/hooks"; +import type { DashboardChannel } from "@/lib/types"; + +export function ChannelsSection({ guildId }: { guildId?: string }) { + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + + const { + data: channels = [], + isLoading, + error, + refetch, + } = useChannels(guildId ?? "", search); + const { data: detail } = useChannelDetail(selectedId); + + const handleSearch = useCallback((v: string) => { + setSearch(v); + setSelectedId(null); + }, []); + + if (error) { + return ( + + Failed to load channels: {error.message} + + + ); + } + + return ( +
+
+
+ + handleSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ + {isLoading ? ( + + ) : channels.length === 0 ? ( + + ) : ( +
+ {channels.map((channel) => ( + + ))} +
+ )} +
+ + + {detail ? ( +
+
+ +

+ {detail.channel_name ?? detail.channel_id} +

+

+ {detail.channel_id} +

+
+ +
+ Messages: {detail.total_messages} + + Flagged: {detail.flagged_count} + + + Clean: {detail.clean_count} + +
+ + {detail.culture_summary && ( +
+

+ Culture summary +

+

+ {detail.culture_summary} +

+
+ )} + + {detail.recent_messages.length > 0 && ( +
+

+ Recent messages +

+ {detail.recent_messages.slice(0, 5).map((msg) => ( +
+

+ {msg.username}: {msg.content || "(no text content)"} +

+

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

+
+ ))} +
+ )} +
+ ) : ( +
+ +

+ Select a channel to see its culture summary and recent messages. +

+
+ )} +
+
+ ); +} + +function ChannelRow({ + channel, + active, + onSelect, +}: { + channel: DashboardChannel; + active: boolean; + onSelect: (id: string) => void; +}) { + return ( + onSelect(channel.channel_id)} + > + + +
+

+ {channel.channel_name ?? channel.channel_id} +

+

+ {channel.channel_id} +

+
+
+ {channel.total_messages} + {channel.flagged_count > 0 && ( + {channel.flagged_count} + )} +
+
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/live-stream.tsx b/services/frontend/src/components/dashboard/live-stream.tsx index 3502cd8..788605f 100644 --- a/services/frontend/src/components/dashboard/live-stream.tsx +++ b/services/frontend/src/components/dashboard/live-stream.tsx @@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from "react"; import { GlassCard } from "@/components/glass/card"; -import { useWebSocket } from "@/lib/ws/context"; +import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useWebSocket } from "@/lib/ws/context"; interface LiveMessage { id: string; @@ -20,12 +21,23 @@ export function LiveStream() { const ws = useWebSocket(); useEffect(() => { - const unsub = ws.on("message_created", (data: any) => { + const unsub = ws.on("message_created", (data: MessageRecord) => { + // channel name lives inside the metadata JSON (channel.channelName) + let channelName: string | undefined; + try { + const meta = + typeof data.metadata === "string" + ? JSON.parse(data.metadata) + : data.metadata; + channelName = meta?.channel?.channelName; + } catch { + channelName = undefined; + } const msg: LiveMessage = { id: data.id, content: data.content || "(attachment)", username: data.username || "unknown", - channelName: data.channelName, + channelName, timestamp: new Date().toLocaleTimeString(), flagged: data.ai_status === "flagged" || data.ai_status === "warn", }; @@ -34,12 +46,6 @@ export function LiveStream() { return () => unsub(); }, [ws]); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [messages]); - return (
@@ -51,7 +57,10 @@ export function LiveStream() { Live Stream
-
+
{messages.length === 0 ? (
Waiting for messages... diff --git a/services/frontend/src/components/dashboard/message-trend-chart.tsx b/services/frontend/src/components/dashboard/message-trend-chart.tsx deleted file mode 100644 index 8574ed6..0000000 --- a/services/frontend/src/components/dashboard/message-trend-chart.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"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 -
-
- - - - - - - - - - - - - - - - - - - -
-
- ); -} diff --git a/services/frontend/src/components/dashboard/mod-queue.tsx b/services/frontend/src/components/dashboard/mod-queue.tsx index ddd031e..674afe9 100644 --- a/services/frontend/src/components/dashboard/mod-queue.tsx +++ b/services/frontend/src/components/dashboard/mod-queue.tsx @@ -4,7 +4,7 @@ import { AlertCircle, Check, Trash2 } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; import { cn } from "@/lib/utils"; -interface ModQueueItem { +export interface ModQueueItem { id: string; content: string; username: string; @@ -48,16 +48,30 @@ export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) { )} >
- {item.username} - {item.severity} + + {item.username} + + + {item.severity} +
-

{item.content}

-

{item.reason}

+

+ {item.content} +

+

+ {item.reason} +

- -
diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx new file mode 100644 index 0000000..4bba380 --- /dev/null +++ b/services/frontend/src/components/dashboard/users-section.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Search, Users, UserX } from "lucide-react"; +import { useCallback, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +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 { Input } from "@/components/ui/input"; +import { useUserDetail, useUsers } from "@/hooks"; +import type { DashboardUser } from "@/lib/types"; + +export function UsersSection() { + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + + const { data: users = [], isLoading, error, refetch } = useUsers(search); + const { data: detail } = useUserDetail(selectedId); + + const handleSearch = useCallback((v: string) => { + setSearch(v); + setSelectedId(null); + }, []); + + if (error) { + return ( + + Failed to load users: {error.message} + + + ); + } + + return ( +
+
+
+ + handleSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ + {isLoading ? ( + + ) : users.length === 0 ? ( + + ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ + + {detail ? ( +
+
+ + + + {detail.username?.charAt(0).toUpperCase() ?? "?"} + + +
+

+ {detail.username ?? "Unknown user"} +

+

+ {detail.user_id} +

+
+
+ +
+ Messages: {detail.total_messages} + + Flagged: {detail.flagged_count} + + + Clean: {detail.clean_count} + + {detail.trust_score != null && ( + Trust: {detail.trust_score} + )} + {detail.clean_message_streak != null && ( + + Streak: {detail.clean_message_streak} + + )} + {detail.total_infractions != null && ( + + Infractions: {detail.total_infractions} + + )} +
+ + {detail.profile_summary && ( +

+ {detail.profile_summary} +

+ )} + + {detail.recent_messages.length > 0 && ( +
+

+ Recent messages +

+ {detail.recent_messages.slice(0, 5).map((msg) => ( +
+

+ {msg.content || "(no text content)"} +

+

+ {msg.channel_id?.slice(0, 8)} ·{" "} + {new Date(msg.created_at).toLocaleString()} +

+
+ ))} +
+ )} +
+ ) : ( +
+ +

+ Select a user to see their profile, trust score and recent + messages. +

+
+ )} +
+
+ ); +} + +function UserRow({ + user, + active, + onSelect, +}: { + user: DashboardUser; + active: boolean; + onSelect: (id: string) => void; +}) { + return ( + onSelect(user.user_id)} + > + + + + + {user.username?.charAt(0).toUpperCase() ?? "?"} + + +
+

+ {user.username ?? "Unknown user"} +

+

+ {user.user_id} +

+
+
+ {user.total_messages} + {user.flagged_count > 0 && ( + {user.flagged_count} + )} +
+
+
+ ); +} diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index 483cf86..56858f4 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -22,12 +22,11 @@ export function useUsers(search?: string) { }); } -export function useChannels(guildId: string, search?: string) { +export function useChannels(guildId?: string, search?: string) { return useQuery({ - queryKey: ["dashboard-channels", guildId, search ?? ""], + queryKey: ["dashboard-channels", guildId ?? "__all__", search ?? ""], queryFn: () => dashboardApi.listChannels(20, search, guildId || undefined), select: (data) => data.data, - enabled: !!guildId, }); } diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index b8150b5..eff1fd9 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -182,7 +182,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { }); const unsub3 = ws.on("message_deleted", (data) => { qc.setQueryData(key, (old) => - old ? old.filter((m) => m.id !== (data as unknown as string)) : old, + old ? old.filter((m) => m.id !== (data as { id: string }).id) : old, ); }); const unsub4 = ws.on("message_analyzed", (data) => { diff --git a/services/frontend/src/lib/api/chatbot.ts b/services/frontend/src/lib/api/chatbot.ts index 6a69d80..83ef8bd 100644 --- a/services/frontend/src/lib/api/chatbot.ts +++ b/services/frontend/src/lib/api/chatbot.ts @@ -1,11 +1,14 @@ -import type { ChatbotResponse, ChatHistoryMessage } from "@/lib/types"; +import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types"; import { api } from "./client"; export const chatbotApi = { send: (message: string) => api.post("/api/chat", { message }), - getHistory: () => api.get("/api/chat/history"), + getHistory: () => + api.get<{ history: ChatbotHistoryRow[]; total: number }>( + "/api/chat/history", + ), clearHistory: () => api.delete<{ ok: boolean }>("/api/chat/history"), }; diff --git a/services/frontend/src/lib/api/client.ts b/services/frontend/src/lib/api/client.ts index 4de5792..7474698 100644 --- a/services/frontend/src/lib/api/client.ts +++ b/services/frontend/src/lib/api/client.ts @@ -8,21 +8,23 @@ export class ApiError extends Error { } } -const REMOTE_API = "https://imphnen.asepharyana.my.id"; - +/** + * API base URL resolution. + * + * Default: same-origin — the production nginx (gmw-proxy) proxies /api/* to + * the backend, so no cross-origin config is needed. For local dev against a + * remote deployment, set NEXT_PUBLIC_API_URL (e.g. https://imphnen.asepharyana.my.id). + */ function getBaseUrl(): string { - if (typeof window === "undefined") return REMOTE_API; - const hostname = window.location.hostname; + const override = + typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL : ""; + if (override) return override.replace(/\/+$/, ""); - // In local dev, route API calls to the remote server - if (hostname === "localhost" || hostname === "127.0.0.1") { - return REMOTE_API; - } + if (typeof window === "undefined") return ""; - // Production: nginx proxies /api/* to backend on the same host const protocol = window.location.protocol.replace(":", ""); const port = window.location.port; - return `${protocol}://${hostname}${port ? `:${port}` : ""}`; + return `${protocol}://${window.location.hostname}${port ? `:${port}` : ""}`; } export async function apiRequest( diff --git a/services/frontend/src/lib/types/recording.ts b/services/frontend/src/lib/types/recording.ts index f850513..a4a2b15 100644 --- a/services/frontend/src/lib/types/recording.ts +++ b/services/frontend/src/lib/types/recording.ts @@ -8,7 +8,8 @@ export interface VoiceRecording { channel_name?: string | null; filename: string; size_bytes: number; - duration_bytes: number; + /** Present on REST rows; absent on WS voice_recording_uploaded events */ + duration_bytes?: number | null; download_url?: string | null; upload_status: string; upload_error?: string | null; diff --git a/services/frontend/src/lib/types/ui.ts b/services/frontend/src/lib/types/ui.ts index 87fdde6..0bbd8ad 100644 --- a/services/frontend/src/lib/types/ui.ts +++ b/services/frontend/src/lib/types/ui.ts @@ -16,8 +16,15 @@ export interface ChatbotResponse { timestamp: string; } -export interface ChatHistoryMessage { - role: string; - content: string; - timestamp: string; +/** + * Chat history row as returned by the backend (GET /api/chat/history → + * { history: ChatbotHistoryRow[], total }). + */ +export interface ChatbotHistoryRow { + id: string; + user_id: string; + user_message: string; + bot_response: string; + context: Record | null; + created_at: string; } diff --git a/services/frontend/src/lib/ws/connection.ts b/services/frontend/src/lib/ws/connection.ts index d783074..aab3c97 100644 --- a/services/frontend/src/lib/ws/connection.ts +++ b/services/frontend/src/lib/ws/connection.ts @@ -2,21 +2,20 @@ import type { WsEvent, WsStatus } from "./types"; type WsEventCallback = (event: WsEvent) => void; -const REMOTE_WS = "wss://imphnen.asepharyana.my.id/ws"; - +/** + * WebSocket URL resolution — same-origin by default (gmw-proxy nginx + * proxies /ws to the backend). Override for local dev with NEXT_PUBLIC_WS_URL. + */ function getWsUrl(): string { - if (typeof window === "undefined") return REMOTE_WS; - const hostname = window.location.hostname; + const override = + typeof process !== "undefined" ? process.env.NEXT_PUBLIC_WS_URL : ""; + if (override) return override.replace(/\/+$/, ""); - // Always route WS through the remote server (even from local dev) - if (hostname === "localhost" || hostname === "127.0.0.1") { - return REMOTE_WS; - } + if (typeof window === "undefined") return "wss://localhost/ws"; - // Production: nginx proxies /ws/* to backend on the same host const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const port = window.location.port; - return `${protocol}://${hostname}${port ? `:${port}` : ""}/ws`; + return `${protocol}://${window.location.hostname}${port ? `:${port}` : ""}/ws`; } export class WsConnection { diff --git a/services/frontend/src/lib/ws/types.ts b/services/frontend/src/lib/ws/types.ts index b28c0ba..a35684b 100644 --- a/services/frontend/src/lib/ws/types.ts +++ b/services/frontend/src/lib/ws/types.ts @@ -28,7 +28,8 @@ export interface WsBinaryEvent { export interface WsEventMap { message_created: MessageRecord; message_updated: MessageRecord; - message_deleted: string; // message ID + /** Gateway emits { id, deleted_at } — NOT a bare string */ + message_deleted: { id: string; deleted_at?: number }; message_analyzed: MessageRecord; attachment_created: unknown; attachment_uploaded: unknown;