refactor(frontend): finish shadcn→custom primitive migration (green build)
- Remove tw-animate-css import + dead src/components/ui shadcn tree - Convert 7 orphaned components (moderation, analysis, guild-selector, voice/activity-timeline, shared/empty+error) to new primitives - Add missing moderation/view.tsx; analysis uses SearchPanel directly - globals.css now uses new signal-driven ops-console tokens - tsc --noEmit clean, next build green (11 routes), local smoke 200
This commit is contained in:
@@ -1,177 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
||||
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
||||
import {
|
||||
Activity,
|
||||
Headphones,
|
||||
Mic,
|
||||
MicOff,
|
||||
Pause,
|
||||
Play,
|
||||
Settings,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SessionRibbon } from "@/components/charts/session-ribbon";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { SignalField } from "@/components/three";
|
||||
import { StaticFallback } from "@/components/three/static-fallback";
|
||||
import { WebGLGuard } from "@/components/three/webgl-guard";
|
||||
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
||||
import { ListenControl } from "@/components/voice/listen-control";
|
||||
import { MicControl } from "@/components/voice/mic-control";
|
||||
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||
import {
|
||||
useGuilds,
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceChannels,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type VoiceTab = "connection" | "activity";
|
||||
type VoiceTab = "stage" | "activity";
|
||||
|
||||
/**
|
||||
* Voice view — hydrated on the client. Seeded from server-rendered status +
|
||||
* guild list so every user's first paint reflects the same shared voice
|
||||
* connection state; live updates come over WS.
|
||||
*/
|
||||
export default function VoiceView({
|
||||
initialStatus,
|
||||
initialGuilds = [],
|
||||
}: {
|
||||
initialStatus?: VoiceStatus;
|
||||
initialGuilds?: Guild[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: voiceStatus } = useVoiceStatus(initialStatus);
|
||||
const { data: guilds = [] } = useGuilds(initialGuilds);
|
||||
const [selectedGuild, setSelectedGuild] = useState("");
|
||||
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
|
||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||
const connectMut = useVoiceConnect();
|
||||
const disconnectMut = useVoiceDisconnect();
|
||||
const micMut = useMicTransmit(ws);
|
||||
const [tab, setTab] = useState<VoiceTab>("stage");
|
||||
|
||||
const initialSpeakers = useMemo(
|
||||
() => initialStatus?.activeSpeakers ?? [],
|
||||
[initialStatus],
|
||||
);
|
||||
const { speakers, subscribe } = useSpeakers(initialSpeakers);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const listen = useVoiceListen(ws);
|
||||
const [selectedChannel, setSelectedChannel] = useState("");
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [volume, setVolume] = useState(75);
|
||||
const [listenVolume, setListenVolume] = useState(75);
|
||||
const [tab, setTab] = useState<VoiceTab>("connection");
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(ws);
|
||||
return () => unsub();
|
||||
}, [ws, subscribe]);
|
||||
return unsub;
|
||||
}, [subscribe, ws]);
|
||||
|
||||
const handleMicToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (checked) {
|
||||
try {
|
||||
await micMut.mutateAsync(true);
|
||||
setMicActive(true);
|
||||
} catch {
|
||||
setMicActive(false);
|
||||
}
|
||||
} else {
|
||||
setMicActive(false);
|
||||
try {
|
||||
await micMut.mutateAsync(false);
|
||||
} catch {
|
||||
// Stop already tore down the local transmitter — ignore remote errors
|
||||
}
|
||||
}
|
||||
},
|
||||
[micMut],
|
||||
);
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(v: number) => {
|
||||
setVolume(v);
|
||||
micMut.setVolume(v);
|
||||
},
|
||||
[micMut],
|
||||
);
|
||||
|
||||
const handleGuildChange = useCallback((guildId: string | null) => {
|
||||
if (!guildId) {
|
||||
setSelectedGuild("");
|
||||
setSelectedChannel("");
|
||||
return;
|
||||
}
|
||||
setSelectedGuild(guildId);
|
||||
}, []);
|
||||
|
||||
const activeSpeakers = speakers.filter((s) => s.speaking);
|
||||
const connected = voiceStatus?.connected ?? false;
|
||||
const active = speakers.filter((s) => s.speaking);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={[
|
||||
{ id: "connection", label: "Connection", icon: undefined },
|
||||
{ id: "activity", label: "Activity", icon: undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as VoiceTab)}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Connection bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={initialStatus?.connected ? "signal" : "neutral"} dot>
|
||||
{initialStatus?.connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
{initialStatus?.activeChannelName && (
|
||||
<span className="text-sm text-[var(--color-ink-soft)]">
|
||||
#{initialStatus.activeChannelName}
|
||||
</span>
|
||||
)}
|
||||
{initialStatus?.connected ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
connect.mutate({
|
||||
guildId: initialStatus?.activeGuildId ?? "",
|
||||
channelId: initialStatus?.activeChannelId ?? "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VoiceConnectionCard
|
||||
connected={connected}
|
||||
activeChannelName={voiceStatus?.activeChannelName}
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
onGuildChange={handleGuildChange}
|
||||
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
||||
onConnect={() => {
|
||||
void connectMut
|
||||
.mutateAsync({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Gagal connect ke voice channel";
|
||||
toast.error("Voice connect gagal", {
|
||||
description: msg,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
if (micActive) {
|
||||
setMicActive(false);
|
||||
void micMut.mutateAsync(false).catch(() => {});
|
||||
{/* Stage hero */}
|
||||
<div className="surface relative flex h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
|
||||
<WebGLGuard
|
||||
fallback={
|
||||
<StaticFallback
|
||||
variant="orb"
|
||||
count={Math.max(speakers.length, 3)}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
}
|
||||
if (listen.active) listen.toggle(false);
|
||||
disconnectMut.mutate(undefined);
|
||||
}}
|
||||
connecting={connectMut.isPending}
|
||||
/>
|
||||
>
|
||||
<SignalField
|
||||
activity={speakers.length > 0 ? 0.6 : 0.2}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
</WebGLGuard>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<SpeakerWaveform speakers={active} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "connection" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SpeakerWaveform speakers={activeSpeakers} />
|
||||
<div className="space-y-4">
|
||||
<ListenControl
|
||||
connected={connected}
|
||||
active={listen.active}
|
||||
levels={listen.levels}
|
||||
speakers={speakers}
|
||||
onToggle={(on) => listen.toggle(on)}
|
||||
volume={listenVolume}
|
||||
onVolumeChange={(v) => {
|
||||
setListenVolume(v);
|
||||
listen.setVolume(v);
|
||||
}}
|
||||
/>
|
||||
<MicControl
|
||||
connected={connected}
|
||||
active={micActive}
|
||||
onToggle={handleMicToggle}
|
||||
volume={volume}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<StaggerItem>
|
||||
<MicControl
|
||||
micOn={listen.active}
|
||||
onToggle={(on) => listen.toggle(on)}
|
||||
levels={listen.levels}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<ListenControl
|
||||
listening={listen.active}
|
||||
onToggle={(on) => listen.toggle(on)}
|
||||
volume={75}
|
||||
onVolume={listen.setVolume}
|
||||
/>
|
||||
</StaggerItem>
|
||||
</StaggerGroup>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
|
||||
{(["stage", "activity"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
tab === t
|
||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{t === "stage" ? (
|
||||
<Activity className="size-3.5" />
|
||||
) : (
|
||||
<Headphones className="size-3.5" />
|
||||
)}
|
||||
{t === "stage" ? "Stage" : "Activity"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "stage" && <ActiveSpeakersPanel speakers={speakers} />}
|
||||
{tab === "activity" && (
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Live session timeline</h3>
|
||||
<SessionRibbon
|
||||
segments={speakers.map((s) => ({
|
||||
id: s.userId,
|
||||
label: s.username,
|
||||
value: s.speaking ? 3 : 1,
|
||||
tone: s.speaking ? "signal" : "neutral",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "activity" && <VoiceActivityTimeline data={speakers} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user