refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)

- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 21:44:29 +07:00
co-authored by Claude Opus 4.8
parent bda8304bb9
commit c48a0c5e3b
193 changed files with 16879 additions and 1158 deletions
@@ -0,0 +1,116 @@
import type { HourlyBucket } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
interface ActivityChartProps {
hourly: HourlyBucket[];
loading: boolean;
}
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
if (loading && !hourly?.length) {
return <LoadingBox />;
}
if (!hourly?.length) {
return <EmptyBox text="Belum ada data untuk periode ini." />;
}
const data = hourly.map((b) => {
const utcHour = parseInt(b.hour.slice(11, 13), 10);
const jakartaHour = (utcHour + 7) % 24;
return {
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
clean: b.clean,
warned: b.warned,
flagged: b.flagged,
error: b.error,
total: b.count,
};
});
return (
<Card className="col-span-1 lg:col-span-2 glass border-white/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">
Aktivitas per Jam
</CardTitle>
<CardDescription className="text-xs">
Distribusi pesan per jam berdasarkan status moderasi.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>Clean</span>
<span>Warned</span>
<span>Flagged</span>
<span>Error</span>
</div>
<div className="max-h-55 space-y-2 overflow-auto pr-1">
{data.map((bucket) => {
const total = Math.max(bucket.total, 1);
const clean = bucket.clean / total;
const warned = bucket.warned / total;
const flagged = bucket.flagged / total;
const error = bucket.error / total;
return (
<div
key={bucket.hour}
className="grid gap-1 rounded-xl border border-border bg-background/50 p-3"
>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">
{bucket.hour}
</span>
<span>{bucket.total} pesan</span>
</div>
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
<div
className="bg-emerald-500/80"
style={{ width: `${clean * 100}%` }}
/>
<div
className="bg-amber-500/80"
style={{ width: `${warned * 100}%` }}
/>
<div
className="bg-red-500/80"
style={{ width: `${flagged * 100}%` }}
/>
<div
className="bg-orange-500/80"
style={{ width: `${error * 100}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</Card>
);
}
function EmptyBox({ text }: { text: string }) {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2 glass border-white/5">
{text}
</Card>
);
}
@@ -0,0 +1,119 @@
import { Activity, BarChart3 } from "lucide-react";
import type { Channel, Guild } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Select,
} from "../../../shared/ui";
const TIME_RANGES = [
{ label: "1j", value: 1 },
{ label: "3j", value: 3 },
{ label: "6j", value: 6 },
{ label: "12j", value: 12 },
{ label: "24j", value: 24 },
{ label: "48j", value: 48 },
{ label: "7h", value: 168 },
];
interface ControlBarProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
hours: number;
isFetching: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onHoursChange: (hours: number) => void;
onRefresh: () => void;
}
export function ControlBar({
guilds,
channels,
selectedGuild,
selectedChannel,
hours,
isFetching,
onGuildChange,
onChannelChange,
onHoursChange,
onRefresh,
}: ControlBarProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
Analisis Moderasi
</CardTitle>
<CardDescription>
Pantau statistik, tren topik, dan aktivitas user.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-3">
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Pilih guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
className="min-w-[180px]"
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Semua channel"
options={[
{ value: "", label: "Semua channel" },
...channels.map((c) => ({ value: c.id, label: c.name })),
]}
className="min-w-[160px]"
/>
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
{TIME_RANGES.map((tr) => (
<button
key={tr.value}
type="button"
onClick={() => onHoursChange(tr.value)}
className={cn(
"rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
hours === tr.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tr.label}
</button>
))}
</div>
<Button
onClick={onRefresh}
disabled={isFetching}
variant="outline"
size="sm"
className="ml-auto shrink-0"
>
{isFetching ? (
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
Memuat...
</span>
) : (
<span className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5" />
Refresh
</span>
)}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import type { HeatmapCell } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
interface HeatmapProps {
cells: HeatmapCell[];
loading: boolean;
}
export function Heatmap({ cells, loading }: HeatmapProps) {
const maxCount = useMemo(
() => Math.max(1, ...cells.map((c) => c.count)),
[cells],
);
if (loading && !cells?.length) {
return <LoadingBox />;
}
if (!cells?.length) {
return <EmptyBox />;
}
const cellMap = new Map<string, HeatmapCell>();
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
function getIntensity(day: number, hour: number): number {
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
}
function getHeatClass(intensity: number): string {
if (intensity === 0) return "bg-muted/30";
if (intensity < 0.1) return "bg-blue-500/10";
if (intensity < 0.2) return "bg-blue-500/20";
if (intensity < 0.35) return "bg-blue-500/30";
if (intensity < 0.5) return "bg-blue-500/45";
if (intensity < 0.7) return "bg-blue-500/60";
return "bg-blue-500/80";
}
return (
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">
Heatmap Aktivitas
</CardTitle>
<CardDescription className="text-xs">
Hari × jam area biru = lebih ramai.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[520px]">
{/* Header row */}
<div className="mb-1 ml-8 flex gap-[2px]">
{Array.from({ length: 24 }, (_, h) => (
<div
key={h}
className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums"
>
{h % 3 === 0 ? `${h}` : ""}
</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, d) => (
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
{day}
</div>
{Array.from({ length: 24 }, (_, h) => {
const intensity = getIntensity(d, h);
const cell = cellMap.get(`${d}-${h}`);
return (
<div
key={h}
className={cn(
"flex-1 rounded-sm aspect-square",
getHeatClass(intensity),
)}
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
/>
);
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>Sepi</span>
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
<span>Ramai</span>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
function EmptyBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada data heatmap.
</CardContent>
</Card>
);
}
@@ -0,0 +1,103 @@
import type { ModerationBreakdown } from "../../../shared/api/client";
import { cn } from "../../../shared/lib/utils";
import { Card, CardContent, Skeleton } from "../../../shared/ui";
interface SummaryCardsProps {
messages: ModerationBreakdown | null;
activeUsersCount: number;
totalChannels: number;
loading: boolean;
}
export function SummaryCards({
messages,
activeUsersCount,
totalChannels,
loading,
}: SummaryCardsProps) {
const avgPerHour = messages
? Math.round(messages.total / Math.max(1, 24))
: 0;
const cleanPct =
messages && messages.total > 0
? Math.round((messages.clean / messages.total) * 100)
: 0;
const warnedPct =
messages && messages.total > 0
? Math.round((messages.warned / messages.total) * 100)
: 0;
const flaggedPct =
messages && messages.total > 0
? Math.round((messages.flagged / messages.total) * 100)
: 0;
const cards = [
{
label: "Total Pesan",
value: formatNum(messages?.total),
accent: "text-foreground",
},
{
label: "Rata-rata/jam",
value: formatNum(avgPerHour),
accent: "text-muted-foreground",
},
{
label: "Clean",
value: cleanPct > 0 ? `${cleanPct}%` : "—",
accent: "text-emerald-400",
},
{
label: "Warned",
value: warnedPct > 0 ? `${warnedPct}%` : "—",
accent: "text-amber-400",
},
{
label: "Flagged",
value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
accent: "text-red-400",
},
{
label: "Pending",
value: formatNum(messages?.pending),
accent: "text-slate-400",
},
{
label: "User Aktif",
value: formatNum(activeUsersCount),
accent: "text-violet-400",
},
{
label: "Channel",
value: formatNum(totalChannels),
accent: "text-blue-400",
},
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
{cards.map((card) => (
<Card key={card.label} className="overflow-hidden glass border-white/5">
<CardContent className="p-3">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{card.label}
</div>
<div
className={cn(
"mt-1 font-mono text-lg font-bold tabular-nums",
card.accent,
)}
>
{loading ? <Skeleton className="h-7 w-12 mt-1" /> : card.value}
</div>
</CardContent>
</Card>
))}
</div>
);
}
function formatNum(v: number | undefined | null): string {
if (v == null || v === 0) return "—";
return v.toLocaleString("id-ID");
}
@@ -0,0 +1,88 @@
import { Flame } from "lucide-react";
import type { TopicTrend } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface TopicListProps {
topics: TopicTrend[];
loading: boolean;
}
export function TopicList({ topics, loading }: TopicListProps) {
if (loading && !topics?.length) {
return <LoadingBox />;
}
if (!topics?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Topik akan muncul setelah AI selesai menganalisis.
</CardContent>
</Card>
);
}
const maxCount = Math.max(...topics.map((t) => t.count), 1);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="h-4 w-4 text-orange-400" />
Topik Trending
</CardTitle>
<CardDescription className="text-xs">
Yang paling ramai dibicarakan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<div className="divide-y divide-border/30">
{topics.map((topic, i) => (
<div
key={topic.topic}
className="flex items-center gap-3 px-5 py-2 text-sm"
>
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{i + 1}
</span>
<span className="flex-1 truncate font-medium">
{topic.topic}
</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500/60"
style={{ width: `${(topic.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
{topic.count}
</span>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,244 @@
import type { TrendBucket } from "../../../shared/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../shared/ui";
interface TrendChartProps {
trend: TrendBucket[];
loading: boolean;
}
export function TrendChart({ trend, loading }: TrendChartProps) {
if (loading && !trend?.length) {
return <LoadingBox />;
}
if (!trend?.length) {
return null;
}
const data = trend.map((bucket) => ({
date: bucket.date,
clean: bucket.clean,
warned: bucket.warned,
flagged: bucket.flagged,
error: bucket.error,
total: bucket.count,
}));
const totalMessages = data.reduce((sum, item) => sum + item.total, 0);
return (
<Card className="col-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
<CardDescription className="text-xs">
Volume pesan per hari dengan status moderasi.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
<LegendDot color="bg-blue-500" label="Total" />
<LegendDot color="bg-emerald-500" label="Clean" />
<LegendDot color="bg-amber-500" label="Warned" />
<LegendDot color="bg-red-500" label="Flagged" />
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>Rangkuman 7 hari terakhir</span>
<span>{totalMessages} total pesan</span>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${Math.max((data.length - 1) * 56, 56)} 220`}
className="h-55 min-w-130 w-full overflow-visible"
>
<defs>
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
<stop
offset="100%"
stopColor="#3b82f6"
stopOpacity="0.02"
/>
</linearGradient>
</defs>
<g stroke="#334155" strokeWidth="1" opacity="0.35">
{Array.from({ length: 4 }, (_, index) => {
const y = 40 + index * 45;
return (
<line
key={index}
x1="0"
x2={Math.max((data.length - 1) * 56, 56)}
y1={y}
y2={y}
/>
);
})}
</g>
<TrendArea
data={data}
keyName="total"
fill="url(#trendFill)"
stroke="#3b82f6"
/>
<TrendLine
data={data}
keyName="total"
color="#3b82f6"
strokeWidth={2.5}
/>
<TrendLine
data={data}
keyName="clean"
color="#10b981"
strokeWidth={1.8}
/>
<TrendLine
data={data}
keyName="warned"
color="#f59e0b"
strokeWidth={1.8}
/>
<TrendLine
data={data}
keyName="flagged"
color="#ef4444"
strokeWidth={1.8}
/>
{data.map((item, index) => {
const x =
data.length <= 1
? 0
: (index / (data.length - 1)) *
Math.max((data.length - 1) * 56, 56);
return (
<g key={item.date} transform={`translate(${x}, 188)`}>
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
<text
x="0"
y="18"
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
>
{item.date.slice(5)}
</text>
</g>
);
})}
</svg>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${color}`} /> {label}
</span>
);
}
function TrendLine({
data,
color,
strokeWidth,
keyName,
}: {
data: Array<Record<string, number | string>>;
color: string;
strokeWidth: number;
keyName: string;
}) {
const path = buildPath(data, keyName, 220, false);
return (
<path
d={path}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
/>
);
}
function TrendArea({
data,
keyName,
fill,
stroke,
}: {
data: Array<Record<string, number | string>>;
keyName: string;
fill: string;
stroke: string;
}) {
const path = buildPath(data, keyName, 220, true);
return <path d={path} fill={fill} stroke={stroke} strokeOpacity={0.2} />;
}
function buildPath(
data: Array<Record<string, number | string>>,
keyName: string,
height: number,
closePath: boolean,
): string {
const values = data.map((item) => Number(item[keyName] ?? 0));
const maxValue = Math.max(...values, 1);
const width = Math.max((data.length - 1) * 56, 56);
const points = values.map((value, index) => {
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * width;
const y = height - 35 - (value / maxValue) * 130;
return { x, y };
});
if (points.length === 0) {
return "";
}
const segments: string[] = [`M ${points[0].x} ${points[0].y}`];
for (let index = 1; index < points.length; index++) {
const previous = points[index - 1];
const current = points[index];
const controlX = (previous.x + current.x) / 2;
segments.push(`Q ${controlX} ${previous.y} ${current.x} ${current.y}`);
}
if (closePath) {
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
segments.push(`L ${lastPoint.x} ${height - 24}`);
segments.push(`L ${firstPoint.x} ${height - 24}`);
segments.push("Z");
}
return segments.join(" ");
}
function LoadingBox() {
return (
<Card className="col-span-3">
<CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,142 @@
import { Users } from "lucide-react";
import type { UserStat } from "../../../shared/api/client";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface UserTableProps {
users: UserStat[];
loading: boolean;
}
export function UserTable({ users, loading }: UserTableProps) {
if (loading && !users?.length) {
return <LoadingBox />;
}
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada aktivitas user.
</CardContent>
</Card>
);
}
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
const medals = ["🥇", "🥈", "🥉"];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4 text-violet-400" />
User Paling Aktif
</CardTitle>
<CardDescription className="text-xs">
Leaderboard berdasarkan jumlah pesan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
<th className="py-2 pr-2 font-semibold">User</th>
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
<th className="py-2 pr-2 font-semibold text-right">Hapus</th>
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{users.map((user, i) => (
<tr
key={user.user_id}
className="hover:bg-muted/20 transition-colors"
>
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{medals[i] ?? i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt=""
className="h-6 w-6 rounded-full"
loading="lazy"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">
{user.username}
</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500/60"
style={{
width: `${(user.message_count / maxMsgs) * 100}%`,
}}
/>
</div>
<span className="font-mono text-xs tabular-nums">
{user.message_count}
</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
{user.edited_count > 0 ? user.edited_count : "—"}
</td>
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
{user.deleted_count > 0 ? user.deleted_count : "—"}
</td>
<td className="py-1.5 pr-4 text-right">
{user.flagged_count > 0 ? (
<Badge
variant="destructive"
className="text-[9px] px-1 py-0"
>
{user.flagged_count}
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,155 @@
import { Siren } from "lucide-react";
import type { ViolatorStat } from "../../../shared/api/client";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ScrollArea,
} from "../../../shared/ui";
interface ViolatorTableProps {
users: ViolatorStat[];
loading: boolean;
}
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
if (loading && !users?.length) {
return <LoadingBox />;
}
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Tidak ada pelanggaran terdeteksi.
</CardContent>
</Card>
);
}
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
function dangerLabel(score: number) {
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
if (score >= 5) return { variant: "warning" as const, text: "MED" };
return { variant: "secondary" as const, text: "LOW" };
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Siren className="h-4 w-4 text-red-400" />
Pelanggar Terbanyak
</CardTitle>
<CardDescription className="text-xs">
Skor: flagged × 3 + warned × 1.
</CardDescription>
</div>
<Badge variant="destructive">{users.length} pelanggar</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
<th className="py-2 pr-2 font-semibold">User</th>
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
<th className="py-2 pr-4 font-semibold text-right">Skor</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{users.map((user, i) => {
const danger = dangerLabel(user.violation_score);
return (
<tr
key={user.user_id}
className="hover:bg-red-500/5 transition-colors"
>
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt=""
className="h-6 w-6 rounded-full"
loading="lazy"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">
{user.username}
</span>
<Badge
variant={danger.variant}
className="text-[9px] px-1 py-0"
>
{danger.text}
</Badge>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-amber-400 tabular-nums">
{user.warned_count}
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-red-400 tabular-nums">
{user.flagged_count}
</td>
<td className="py-1.5 pr-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full",
user.violation_score >= 10
? "bg-gradient-to-r from-red-600 to-red-400"
: user.violation_score >= 5
? "bg-gradient-to-r from-amber-500 to-amber-400"
: "bg-gradient-to-r from-yellow-500 to-yellow-400",
)}
style={{
width: `${(user.violation_score / maxScore) * 100}%`,
}}
/>
</div>
<span className="font-mono text-xs font-bold tabular-nums">
{user.violation_score}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
import { cn } from "../../../shared/lib/utils";
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,149 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import type {
AnalyticsOverview,
HeatmapCell,
HourlyBucket,
TopicTrend,
TrendBucket,
UserStat,
ViolatorStat,
} from "../../../shared/api/client";
import {
fetchAnalyticsOverview,
fetchHeatmap,
fetchTrend,
fetchViolators,
} from "../../../shared/api/client";
function analyticsKeys(
guildId: string,
channelId: string | undefined,
hours: number,
) {
return {
overview: [
"analytics",
"overview",
guildId,
channelId ?? "",
hours,
] as const,
violators: [
"analytics",
"violators",
guildId,
channelId ?? "",
hours,
] as const,
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
};
}
interface UseAnalyticsOptions {
guildId: string;
channelId?: string;
hours?: number;
}
export function useAnalytics({
guildId,
channelId,
hours = 24,
}: UseAnalyticsOptions) {
const keys = analyticsKeys(guildId, channelId, hours);
const overviewQuery = useQuery({
queryKey: keys.overview,
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const violatorsQuery = useQuery({
queryKey: keys.violators,
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const trendQuery = useQuery({
queryKey: keys.trend,
queryFn: () => fetchTrend({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
const heatmapQuery = useQuery({
queryKey: keys.heatmap,
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
const refresh = useCallback(() => {
if (!guildId) return;
window.dispatchEvent(new CustomEvent("analytics_refresh"));
}, [guildId]);
useEffect(() => {
const handler = () => {
if (!guildId) return;
// Use queryClient.invalidateQueries from the React Query internals
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
};
window.addEventListener("analytics_refresh", handler);
return () => window.removeEventListener("analytics_refresh", handler);
}, [refresh]);
const overview = overviewQuery.data ?? null;
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
return {
overview,
isLoading,
isFetching,
error:
overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
refresh,
violators: violatorsQuery.data ?? [],
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
refreshViolators: () => {
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
},
trend: trendQuery.data ?? [],
trendLoading: trendQuery.isLoading && !trendQuery.data,
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
heatmap: heatmapQuery.data ?? [],
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
topics: overview?.topics ?? ([] as TopicTrend[]),
topUsers: overview?.top_users ?? ([] as UserStat[]),
messages: overview?.messages ?? null,
period: overview?.period ?? null,
activeUsersCount: overview?.active_users_count ?? 0,
totalChannels: overview?.total_channels ?? 0,
};
}
export type {
AnalyticsOverview,
HeatmapCell,
HourlyBucket,
TopicTrend,
TrendBucket,
UserStat,
ViolatorStat,
};
@@ -0,0 +1,112 @@
import { useState } from "react";
import type { Channel, Guild } from "../../shared/api/client";
import { ActivityChart } from "./components/ActivityChart";
import { ControlBar } from "./components/ControlBar";
import { Heatmap } from "./components/Heatmap";
import { SummaryCards } from "./components/SummaryCards";
import { TopicList } from "./components/TopicList";
import { TrendChart } from "./components/TrendChart";
import { UserTable } from "./components/UserTable";
import { ViolatorTable } from "./components/ViolatorTable";
import { useAnalytics } from "./hooks/useAnalytics";
interface AnalyticsPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
}
export function AnalyticsPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
onGuildChange,
onChannelChange,
}: AnalyticsPanelProps) {
const [hours, setHours] = useState(24);
const analytics = useAnalytics({
guildId: selectedGuild,
channelId: selectedChannel || undefined,
hours,
});
const {
hourly,
topics,
topUsers,
activeUsersCount,
totalChannels,
violators,
trend,
heatmap,
isLoading,
isFetching,
error,
refresh,
refreshViolators,
messages: analyticsMessages,
} = analytics;
const loading = isLoading && !isFetching;
if (error && !analyticsMessages) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">
{error}
</div>
);
}
if (!selectedGuild) {
return (
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
<p className="text-sm text-muted-foreground">
Pilih guild untuk melihat analitik.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<ControlBar
guilds={guilds}
channels={channels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
hours={hours}
isFetching={isFetching}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onHoursChange={setHours}
onRefresh={() => {
refresh();
refreshViolators();
}}
/>
<SummaryCards
messages={analyticsMessages}
activeUsersCount={activeUsersCount}
totalChannels={totalChannels}
loading={loading}
/>
<div className="grid grid-cols-3 gap-4">
<ActivityChart hourly={hourly} loading={loading} />
<div className="col-span-1">
<TopicList topics={topics} loading={loading} />
</div>
</div>
{hours >= 48 && <TrendChart trend={trend} loading={loading} />}
<div className="grid grid-cols-3 gap-4">
<Heatmap cells={heatmap} loading={loading} />
<div className="col-span-1">
<UserTable users={topUsers} loading={loading} />
</div>
</div>
<ViolatorTable users={violators} loading={loading} />
</div>
);
}
@@ -0,0 +1,74 @@
import { Lock } from "lucide-react";
import { useState } from "react";
import { login } from "../../shared/api/client";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
} from "../../shared/ui";
interface AuthOverlayProps {
onAuthenticated: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(password);
localStorage.setItem("admin-password", password);
onAuthenticated();
} catch {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>
Enter the admin password to access Voice and Media controls.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<Button
type="submit"
className="w-full"
disabled={loading || !password}
>
{loading ? "Authenticating..." : "Unlock Controls"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,60 @@
import type { ActiveSpeaker } from "../../../shared/api/client";
import { Skeleton } from "../../../shared/ui";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
if (speakers.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No active speakers.
</div>
);
}
return (
<div className="space-y-2">
{speakers.map((s) => {
// BUG 4 FIX: stable key — no index fallback
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
return (
<div
key={key}
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3"
>
<img
src={s.avatar}
alt=""
className="h-8 w-8 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{s.username}</div>
<div className="text-xs text-emerald-300">Speaking</div>
</div>
</div>
);
})}
</div>
);
}
export function ActiveSpeakersSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3"
>
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,61 @@
import { useEffect, useRef } from "react";
interface AudioVisualizerProps {
levels: number[];
}
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
const barWidth = width / levels.length;
const maxBarHeight = height * 0.85;
for (let i = 0; i < levels.length; i++) {
const level = levels[i];
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
const x = i * barWidth;
const y = height - barHeight;
// Gradient color based on level
const hue = 199 - level * 199;
const saturation = 89;
const lightness = 48 + level * 20;
ctx.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Rounded bar
const radius = barWidth * 0.3;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + barWidth - radius, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
ctx.lineTo(x + barWidth, height);
ctx.lineTo(x, height);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.fill();
}
}, [levels]);
return (
<div className="relative w-full">
<canvas
ref={canvasRef}
width={512}
height={128}
className="w-full rounded-xl bg-muted/30"
style={{ height: "128px" }}
/>
</div>
);
}
@@ -0,0 +1,81 @@
import { Music2, SkipForward, Square, Volume2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button, Input } from "../../../shared/ui";
interface MusicSubPanelProps {
volume: number;
onVolumeChange: (v: number) => void;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
loading: boolean;
}
export function MusicSubPanel({
volume,
onVolumeChange,
onQueue,
onSkip,
onStop,
loading,
}: MusicSubPanelProps) {
const [source, setSource] = useState("");
const safeVolume = Number.isFinite(volume)
? Math.max(0, Math.min(1, volume))
: 1;
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
// Debounced volume — poll every 200ms instead of instant send to avoid flood
useEffect(() => {
const id = setInterval(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) >= 0.001)
onVolumeChange(normalized);
}, 200);
return () => clearInterval(id);
}, [draftVolume, safeVolume, onVolumeChange]);
const submit = () => {
const t = source.trim();
if (!t) return;
onQueue(t);
setSource("");
};
return (
<div className="space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(e) => setDraftVolume(Number(e.target.value))}
className="h-2 w-full cursor-pointer accent-primary"
/>
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
{draftVolume}%
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<Music2 className="mr-1.5 h-4 w-4" /> Queue
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>
<Square className="mr-1.5 h-4 w-4" /> Stop
</Button>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { MonitorUp, Music2 } from "lucide-react";
import type { MediaItem } from "../../../shared/api/client";
import { Badge } from "../../../shared/ui";
interface NowPlayingProps {
current: MediaItem | null;
queue: MediaItem[];
}
export function NowPlaying({ current, queue }: NowPlayingProps) {
if (!current) return null;
return (
<div className="rounded-2xl border border-border bg-card shadow-sm">
<div className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
{current.mode === "screen" ? (
<MonitorUp className="h-5 w-5" />
) : (
<Music2 className="h-5 w-5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{current.title}</div>
<div className="truncate text-xs text-muted-foreground">
{current.source}
</div>
</div>
<Badge variant={current.mode === "screen" ? "warning" : "success"}>
{current.mode ?? "music"}
</Badge>
</div>
</div>
{queue.length > 0 && (
<div className="border-t border-border p-4">
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
<div className="space-y-1.5">
{queue.map((item, i) => (
<div
key={`${item.source}-${i}`}
className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
{i + 1}
</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.title}</div>
<div className="truncate text-xs text-muted-foreground">
{item.source}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,165 @@
// ─── Recordings Sub-Panel — BUG 1 FIX: useEffect instead of useMemo for side effects ──
import { Download, Mic } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge, Button, Skeleton } from "../../../shared/ui";
interface VoiceRecording {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string | null;
upload_status: "pending" | "uploaded" | "failed";
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
}
function formatDate(value: number): string {
return new Date(value).toLocaleString();
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export function RecordingsSubPanel() {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// BUG 1 FIX: proper useEffect for async data fetching
useEffect(() => {
let cancelled = false;
async function loadRecordings() {
try {
setLoading(true);
setError(null);
const response = await fetch("/api/recordings");
if (!response.ok)
throw new Error(`Failed to load recordings: ${response.status}`);
const data = (await response.json()) as VoiceRecording[];
if (!cancelled) setRecordings(data);
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
}
loadRecordings();
const handler = () => loadRecordings();
window.addEventListener("voice_recording_uploaded", handler);
return () => {
cancelled = true;
window.removeEventListener("voice_recording_uploaded", handler);
};
}, []);
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
>
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-64" />
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
{error}
<div className="mt-2">
<Button
size="sm"
variant="outline"
onClick={() => window.location.reload()}
>
Retry
</Button>
</div>
</div>
);
}
if (recordings.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No recordings found.
</div>
);
}
return (
<div className="space-y-3">
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Mic className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{rec.filename}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>{rec.username}</span>
<span>·</span>
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
<span>·</span>
<span>{formatDate(rec.created_at)}</span>
<span>·</span>
<span>{formatBytes(rec.size_bytes)}</span>
</div>
{rec.upload_error && (
<div className="mt-1 text-xs text-destructive">
{rec.upload_error}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant={
rec.upload_status === "uploaded"
? "success"
: rec.upload_status === "failed"
? "destructive"
: "secondary"
}
>
{rec.upload_status}
</Badge>
{rec.download_url && (
<a
href={rec.download_url}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,47 @@
import { MonitorUp, SkipForward, Square } from "lucide-react";
import { useState } from "react";
import { Button, Input } from "../../../shared/ui";
interface ScreenSubPanelProps {
onStart: (source: string) => void;
onSkip: () => void;
onStop: () => void;
loading: boolean;
}
export function ScreenSubPanel({
onStart,
onSkip,
onStop,
loading,
}: ScreenSubPanelProps) {
const [source, setSource] = useState("");
const submit = () => {
const t = source.trim();
if (!t) return;
onStart(t);
setSource("");
};
return (
<div className="space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="Screen share URL or local file path"
/>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>
<Square className="mr-1.5 h-4 w-4" /> Stop
</Button>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import { Headphones, Radio } from "lucide-react";
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
import { Button, Select } from "../../../shared/ui";
interface VoiceConnectionCardProps {
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
voiceLoading: boolean;
isListening: boolean;
isStreaming: boolean;
onGuildChange: (id: string) => void;
onChannelChange: (id: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
}
export function VoiceConnectionCard({
guilds,
voiceChannels,
selectedGuild,
selectedChannel,
status,
voiceLoading,
isListening,
isStreaming,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
}: VoiceConnectionCardProps) {
return (
<div className="rounded-2xl border border-border bg-card shadow-sm">
<div className="p-6">
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Radio className="h-5 w-5" /> Voice Bridge
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Join a Discord voice channel, listen, and transmit audio.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Guild</label>
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Select guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Voice Channel</label>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Select voice channel"
options={voiceChannels.map((c) => ({
value: c.id,
label: c.name,
}))}
/>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
disabled={!selectedGuild || !selectedChannel || voiceLoading}
onClick={onJoin}
>
{status.connected ? "Reconnect" : "Join Voice"}
</Button>
<Button
variant="destructive"
disabled={!status.connected || voiceLoading}
onClick={onDisconnect}
>
Disconnect
</Button>
<Button
variant={isListening ? "secondary" : "outline"}
onClick={onListenToggle}
>
<Headphones className="mr-1.5 h-4 w-4" />{" "}
{isListening ? "Stop Listening" : "Listen"}
</Button>
<Button
variant={isStreaming ? "destructive" : "default"}
onClick={onStreamingToggle}
>
<Radio className="mr-1.5 h-4 w-4" />{" "}
{isStreaming ? "Stop Transmit" : "Transmit"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
// ─── Live feature barrel export ─────────────────────────────────────────────
export { ActiveSpeakers } from "./ActiveSpeakers";
export { AudioVisualizer } from "./AudioVisualizer";
export { MusicSubPanel } from "./MusicSubPanel";
export { NowPlaying } from "./NowPlaying";
export { RecordingsSubPanel } from "./RecordingsSubPanel";
export { ScreenSubPanel } from "./ScreenSubPanel";
export { VoiceConnectionCard } from "./VoiceConnectionCard";
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import type { MediaState } from "../../../shared/api/client";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../../../shared/api/client";
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshMedia = useCallback(async () => {
const state = await getMediaStatus();
setMediaState(state);
return state;
}, []);
const enqueue = useCallback(
async (source: string, mode: "music" | "screen") => {
setLoading(true);
setError(null);
try {
const state = await queueMedia(source, mode);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
},
[],
);
const skip = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await skipMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const stop = useCallback(async () => {
setLoading(true);
setError(null);
try {
const state = await stopMedia();
setMediaState(state);
return state;
} finally {
setLoading(false);
}
}, []);
const setVolume = useCallback(async (volume: number) => {
setError(null);
try {
const state = await setMediaVolume(volume);
setMediaState(state);
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
}
}, []);
useEffect(() => {
refreshMedia().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
}, [refreshMedia]);
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from "react";
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../../../shared/api/client";
export function useVoiceControl() {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
const [textChannels, setTextChannels] = useState<Channel[]>([]);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({
connected: false,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshGuilds = useCallback(async () => {
setError(null);
const nextGuilds = await getGuilds();
setGuilds(nextGuilds);
return nextGuilds;
}, []);
const refreshVoiceStatus = useCallback(async () => {
const status = await getVoiceStatus();
setVoiceStatus(status);
return status;
}, []);
const loadVoiceChannels = useCallback(async (guildId: string) => {
if (!guildId) {
setVoiceChannels([]);
return [];
}
const channels = await getVoiceChannels(guildId);
setVoiceChannels(channels);
return channels;
}, []);
const loadTextTargets = useCallback(async (guildId: string) => {
if (!guildId) {
setTextChannels([]);
return [];
}
const channels = await getTextChannels(guildId);
setTextChannels(channels);
return channels;
}, []);
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
setLoading(true);
setError(null);
try {
const status = await connectVoice(guildId, channelId);
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const leaveVoice = useCallback(async () => {
setLoading(true);
setError(null);
try {
const status = await disconnectVoice();
setVoiceStatus(status);
return status;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshGuilds().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
refreshVoiceStatus().catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
);
}, [refreshGuilds, refreshVoiceStatus]);
return {
guilds,
voiceChannels,
textChannels,
voiceStatus,
loading,
error,
refreshGuilds,
refreshVoiceStatus,
loadVoiceChannels,
loadTextTargets,
joinVoice,
leaveVoice,
};
}
@@ -0,0 +1,156 @@
// ─── Live Panel — thin composition layer ────────────────────────────────────
import { Mic, MonitorUp, Music2 } from "lucide-react";
import type {
ActiveSpeaker,
Channel,
Guild,
MediaState,
VoiceStatus,
} from "../../shared/api/client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ActiveSpeakers } from "./components/ActiveSpeakers";
import { AudioVisualizer } from "./components/AudioVisualizer";
import { MusicSubPanel } from "./components/MusicSubPanel";
import { NowPlaying } from "./components/NowPlaying";
import { RecordingsSubPanel } from "./components/RecordingsSubPanel";
import { ScreenSubPanel } from "./components/ScreenSubPanel";
import { VoiceConnectionCard } from "./components/VoiceConnectionCard";
interface LivePanelProps {
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
voiceLoading: boolean;
activeSpeakers: ActiveSpeaker[];
levels: number[];
isListening: boolean;
isStreaming: boolean;
mediaState: MediaState;
mediaLoading: boolean;
onGuildChange: (id: string) => void;
onChannelChange: (id: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
onQueueMusic: (source: string) => void;
onStartScreen: (source: string) => void;
onSkip: () => void;
onStop: () => void;
onVolumeChange: (v: number) => void;
}
export function LivePanel({
guilds,
voiceChannels,
selectedGuild,
selectedChannel,
status,
voiceLoading,
activeSpeakers,
levels,
isListening,
isStreaming,
mediaState,
mediaLoading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: LivePanelProps) {
return (
<div className="grid gap-6">
<VoiceConnectionCard
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
status={status}
voiceLoading={voiceLoading}
isListening={isListening}
isStreaming={isStreaming}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onJoin={onJoin}
onDisconnect={onDisconnect}
onListenToggle={onListenToggle}
onStreamingToggle={onStreamingToggle}
/>
<div className="grid gap-6 xl:grid-cols-[1fr_320px]">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Live Audio</CardTitle>
</CardHeader>
<CardContent>
<AudioVisualizer levels={levels} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Active Speakers</CardTitle>
</CardHeader>
<CardContent>
<ActiveSpeakers speakers={activeSpeakers} />
</CardContent>
</Card>
</div>
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
<Tabs defaultValue="music">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="music">
<Music2 className="mr-1.5 h-4 w-4" /> Music
</TabsTrigger>
<TabsTrigger value="screen">
<MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share
</TabsTrigger>
<TabsTrigger value="recordings">
<Mic className="mr-1.5 h-4 w-4" /> Recordings
</TabsTrigger>
</TabsList>
<TabsContent value="music">
<MusicSubPanel
volume={mediaState.musicVolume}
onVolumeChange={onVolumeChange}
onQueue={onQueueMusic}
onSkip={onSkip}
onStop={onStop}
loading={mediaLoading}
/>
</TabsContent>
<TabsContent value="screen">
<ScreenSubPanel
onStart={onStartScreen}
onSkip={onSkip}
onStop={onStop}
loading={mediaLoading}
/>
</TabsContent>
<TabsContent value="recordings">
<RecordingsSubPanel />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,136 @@
import type { MessageRecord } from "../../../shared/api/client";
interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
interface ImageItem {
url: string;
title: string;
kind: "attachment" | "embed" | "sticker";
message: MessageRecord;
}
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images: ImageItem[] = [];
for (const message of messages) {
const metadata = parseMetadata(message.metadata);
// Stickers
for (const sticker of metadata.stickers ?? []) {
if (sticker.url) {
images.push({
url: sticker.url,
title: sticker.name || "sticker",
kind: "sticker",
message,
});
}
}
// Attachments
for (const attachment of metadata.attachments ?? []) {
if (
attachment.url &&
(attachment.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name))
) {
images.push({
url: attachment.url,
title: attachment.name,
kind: "attachment",
message,
});
}
}
// Embed images
for (const embed of metadata.embeds ?? []) {
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
images.push({
url: imgUrl as string,
title: embed.title || "embed image",
kind: "embed",
message,
});
}
}
}
if (images.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
No images found.
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image, index) => {
// Stable key using message.id + url
const stableKey = `${image.message.id}-${image.kind}-${index}`;
return (
<a
key={stableKey}
href={image.url}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
>
<div className="relative aspect-video overflow-hidden">
{image.kind === "sticker" ? (
<img
src={image.url}
alt={image.title}
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
loading="lazy"
/>
) : (
<img
src={image.url}
alt={image.title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
)}
<div className="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
{image.kind}
</div>
</div>
<div className="p-3">
<div className="truncate text-sm font-medium">{image.title}</div>
<div className="flex items-center gap-2">
<div className="h-4 w-4 overflow-hidden rounded-full">
<img
src={
image.message.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-full w-full object-cover"
/>
</div>
<span className="truncate text-xs text-muted-foreground">
{image.message.username}
</span>
</div>
</div>
</a>
);
})}
</div>
);
}
@@ -0,0 +1,360 @@
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Image as ImageIcon,
Pencil,
RotateCw,
Smile,
Trash2,
} from "lucide-react";
import { Fragment, useMemo, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { Badge, Button, Skeleton } from "../../../shared/ui";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
/**
* Renders message content with Discord custom emojis displayed as images
* instead of raw text like `<:name:id>`.
*/
function renderContentWithCustomEmojis(content: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g");
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
// Text before the emoji
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
const [, animated, name, id] = match;
const ext = animated ? "gif" : "png";
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`;
parts.push(
<img
key={`${id}-${match.index}`}
src={url}
alt={name}
className="inline-block h-[22px] w-[22px] align-middle object-contain"
loading="lazy"
draggable={false}
title={`:${name}:`}
/>,
);
lastIndex = regex.lastIndex;
}
// Remaining text after last emoji
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
// If no emojis were found, just return the raw content
if (parts.length === 0) {
return content;
}
return <Fragment>{parts}</Fragment>;
}
interface MessageCardProps {
message: MessageRecord;
onReanalyze: (id: string) => Promise<void>;
}
interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function aiVariant(status: string) {
if (status === "clean") return "success";
if (status === "warn") return "warning";
if (status === "flagged" || status === "error") return "destructive";
return "secondary";
}
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "high":
return "bg-orange-500/20 text-orange-300 border-orange-500/30";
case "medium":
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
case "low":
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return new Date(ts).toLocaleDateString();
}
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const metadata = useMemo(
() => parseMetadata(message.metadata),
[message.metadata],
);
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
const categories = useMemo(() => {
const list = parseStringList(
message.ai_categories ?? message.ai_moderation_flags,
);
return list.filter((c) => c !== "analysis_incomplete");
}, [message.ai_categories, message.ai_moderation_flags]);
const confidence =
message.ai_confidence ?? message.ai_moderation_score ?? null;
const [isReanalyzing, setIsReanalyzing] = useState(false);
const stickers = metadata.stickers ?? [];
const attachments = metadata.attachments ?? [];
const imageAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
);
const hasImages = imageAttachments.length > 0;
const handleReanalyze = async () => {
setIsReanalyzing(true);
try {
await onReanalyze(message.id);
} finally {
setIsReanalyzing(false);
}
};
return (
<article
className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
>
<div className="flex gap-3">
<img
src={
message.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
/>
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-foreground">
{message.username || message.user_id}
</span>
<span
className="text-xs text-muted-foreground"
title={new Date(message.created_at).toLocaleString()}
>
{formatTimeAgo(message.created_at)}
</span>
{message.edited_at && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Pencil className="h-3 w-3" /> edited
</span>
)}
{message.deleted_at && (
<span className="flex items-center gap-1 text-xs text-destructive">
<Trash2 className="h-3 w-3" /> deleted
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<Badge
variant={aiVariant(aiStatus)}
className="flex items-center gap-1 text-xs"
>
{aiStatus === "clean" && (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{aiStatus === "warn" && (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{aiStatus === "flagged" && (
<AlertCircle className="h-3.5 w-3.5" />
)}
{aiStatus === "error" && (
<AlertCircle className="h-3.5 w-3.5" />
)}
{aiStatus}
</Badge>
{message.ai_severity && message.ai_severity !== "none" && (
<Badge
className={`text-xs ${severityColor(message.ai_severity)}`}
>
{message.ai_severity}
</Badge>
)}
{confidence != null && (
<Badge variant="outline" className="text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)}
</div>
</div>
{displayContent ? (
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-3">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-2"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
loading="lazy"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
<Smile className="h-8 w-8 text-muted-foreground" />
</div>
)}
<span
className="text-xs text-muted-foreground max-w-[120px] truncate"
title={sticker.name}
>
{sticker.name}
</span>
</div>
))}
</div>
)}
{hasImages && (
<div className="flex gap-2 overflow-x-auto">
{imageAttachments.slice(0, 4).map((img) => (
<a
key={img.url}
href={img.url}
target="_blank"
rel="noreferrer"
className="shrink-0 overflow-hidden rounded-xl border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-20 w-20 object-cover transition-transform hover:scale-105"
loading="lazy"
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
+{imageAttachments.length - 4}{" "}
<ImageIcon className="ml-1 h-3 w-3" />
</div>
)}
</div>
)}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-xs">
{category}
</Badge>
))}
</div>
)}
{message.ai_analysis ? (
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
{message.ai_analysis}
</div>
) : null}
{message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
AI error: {message.ai_error}
</div>
) : null}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-xs"
>
<RotateCw
className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-xs text-destructive/80">
Click to retry analysis
</span>
)}
</div>
</div>
</div>
</article>
);
}
export function MessageCardSkeleton() {
return (
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,90 @@
import { useEffect, useRef } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { ScrollArea } from "../../../shared/ui";
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
export interface MessageFeedProps {
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
emptyText?: string;
loading?: boolean;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
export function MessageFeed({
messages,
onReanalyze,
emptyText = "No messages found.",
loading,
onLoadMore,
hasMore,
loadingMore,
}: MessageFeedProps) {
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!onLoadMore || !hasMore) return;
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" }, // preload before user reaches bottom
);
observer.observe(el);
return () => observer.disconnect();
}, [onLoadMore, hasMore]);
if (loading) {
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<MessageCardSkeleton key={i} />
))}
</div>
</ScrollArea>
);
}
if (messages.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
{emptyText}
</div>
);
}
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<div className="space-y-3">
{messages.map((message) => (
<MessageCard
key={message.id}
message={message}
onReanalyze={onReanalyze}
/>
))}
{/* Infinite-scroll sentinel */}
{hasMore && (
<div
ref={sentinelRef}
className="flex items-center justify-center py-4"
>
{loadingMore ? (
<MessageCardSkeleton />
) : (
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
)}
</div>
)}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,115 @@
import { useCallback, useRef, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
const PAGE_SIZE = 100;
export function mergeMessages(
current: MessageRecord[],
incoming: MessageRecord[],
): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
// Removed .slice(0, 200) cap — let the message list grow unbounded.
// Infinite scroll handles the data volume via cursor pagination.
return Array.from(byId.values()).sort(
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
);
}
export function useMessages() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const currentChannel = useRef<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
setCursor(null);
setHasMore(false);
return [];
}
currentChannel.current = channelId;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
channelId,
});
const result = await listMessages(params);
// Only update state if we're still on the same channel (avoid race conditions)
if (currentChannel.current === channelId) {
setMessages(result.data);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
}
return result.data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const loadMore = useCallback(async () => {
if (!cursor || !currentChannel.current || loadingMore) return;
setLoadingMore(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
channelId: currentChannel.current,
cursor,
});
const result = await listMessages(params);
// Only update if still on the same channel
if (
currentChannel.current === result.data[0]?.channel_id ||
currentChannel.current
) {
setMessages((prev) => [...prev, ...result.data]);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
}
} finally {
setLoadingMore(false);
}
}, [cursor, loadingMore]);
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
const reanalyze = useCallback(async (id: string): Promise<void> => {
setMessages((prev) =>
prev.map((message) =>
message.id === id
? {
...message,
ai_status: "pending" as const,
ai_error: null,
ai_analysis: null,
}
: message,
),
);
await reanalyzeMessage(id);
}, []);
return {
messages,
setMessages,
loading,
loadingMore,
error,
fetchMessages,
reanalyze,
loadMore,
hasMore,
};
}
@@ -0,0 +1,275 @@
import { Filter, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { Channel, Guild, MessageRecord } from "../../shared/api/client";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Select,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ImageGrid } from "./components/ImageGrid";
import { MessageFeed } from "./components/MessageFeed";
interface MessagesPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
messages: MessageRecord[];
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onReanalyze: (id: string) => Promise<void>;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
export function MessagesPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
messages,
onGuildChange,
onChannelChange,
onReanalyze,
onLoadMore,
hasMore,
loadingMore,
}: MessagesPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [aiFilter, setAiFilter] = useState<AiFilter>("all");
const [viewTab, setViewTab] = useState<"all" | "images">("all");
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setShowSearch(false);
return;
}
setIsSearching(true);
try {
const params = new URLSearchParams({
q: searchQuery,
...(selectedChannel && { channelId: selectedChannel }),
limit: "50",
});
const response = await fetch(`/api/analysis/search?${params}`);
if (!response.ok) throw new Error("Search failed");
const data = await response.json();
setSearchResults(data.results || []);
setShowSearch(true);
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const stats = useMemo(() => {
const base = showSearch ? searchResults : messages;
return {
total: base.length,
clean: base.filter((m) => m.ai_status === "clean").length,
warn: base.filter((m) => m.ai_status === "warn").length,
flagged: base.filter((m) => m.ai_status === "flagged").length,
error: base.filter((m) => m.ai_status === "error").length,
pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status)
.length,
deleted: base.filter((m) => m.deleted_at).length,
edited: base.filter((m) => m.edited_at).length,
};
}, [messages, searchResults, showSearch]);
const filteredMessages = useMemo(() => {
const base = showSearch ? searchResults : messages;
if (aiFilter === "all") return base;
return base.filter((m) => {
const status = m.ai_status ?? "pending";
if (aiFilter === "pending")
return status === "pending" || status === null || status === undefined;
return status === aiFilter;
});
}, [messages, searchResults, showSearch, aiFilter]);
return (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Message Source</CardTitle>
<CardDescription>
Pick a guild and channel/thread to inspect captures.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Select text guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Select channel or thread"
options={channels.map((c) => ({ value: c.id, label: c.name }))}
/>
</CardContent>
</Card>
{stats.total > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{stats.total} total{hasMore && !showSearch ? "+" : ""}
</Badge>
<Badge
variant="outline"
className="text-xs text-green-400 border-green-400/30"
>
{stats.clean} clean
</Badge>
<Badge
variant="outline"
className="text-xs text-yellow-400 border-yellow-400/30"
>
{stats.warn} warn
</Badge>
<Badge
variant="outline"
className="text-xs text-red-400 border-red-400/30"
>
{stats.flagged} flagged
</Badge>
<Badge
variant="outline"
className="text-xs text-orange-400 border-orange-400/30"
>
{stats.error} error
</Badge>
<Badge variant="outline" className="text-xs">
{stats.pending} pending
</Badge>
{stats.deleted > 0 && (
<Badge variant="destructive" className="text-xs">
{stats.deleted} deleted
</Badge>
)}
{stats.edited > 0 && (
<Badge variant="outline" className="text-xs">
{stats.edited} edited
</Badge>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search message content..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
disabled={isSearching}
/>
</div>
<Button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
size="sm"
>
{isSearching ? "Searching..." : "Search"}
</Button>
{showSearch && (
<Button
variant="outline"
size="sm"
onClick={() => {
setShowSearch(false);
setSearchResults([]);
setSearchQuery("");
}}
>
<X className="mr-1 h-3 w-3" /> Clear
</Button>
)}
<div className="ml-auto flex items-center gap-1.5">
<Filter className="h-4 w-4 text-muted-foreground" />
{(
[
"all",
"clean",
"warn",
"flagged",
"error",
"pending",
] as AiFilter[]
).map((f) => (
<button
key={f}
onClick={() => setAiFilter(f)}
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${aiFilter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
>
{f}
</button>
))}
</div>
</div>
{showSearch && searchResults.length > 0 && (
<div className="text-sm text-muted-foreground">
Found {searchResults.length} result
{searchResults.length !== 1 ? "s" : ""}
</div>
)}
<Tabs
value={viewTab}
onValueChange={(v) => setViewTab(v as "all" | "images")}
>
<TabsList>
<TabsTrigger value="all">
{showSearch
? `Search (${filteredMessages.length})`
: `All (${filteredMessages.length})`}
</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed
messages={filteredMessages}
onReanalyze={onReanalyze}
emptyText={
showSearch
? "No messages found matching your search."
: selectedChannel
? "No captures yet."
: "Select a channel to view captures."
}
onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore}
loadingMore={loadingMore}
/>
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={filteredMessages} />
</TabsContent>
</Tabs>
</div>
);
}