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,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";