feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette
Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan desain sistem Ambient (WebGL haze + drifting motes, signal-driven color) di atas kontrak API/WS/type yang sudah ada. - Design system: globals.css tokens + primitives (glass, button, badge, select, avatar, toast, chart SVG murni). - Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame. - 8 halaman: dashboard, voice (orbital stage), media, messages (live feed + detail AI), moderation, analysis (search), recordings, + chatbot floating. - Command palette (Cmd/Ctrl+K) untuk navigasi cepat. - Server fetch di-page di-try/catch agar render graceful saat backend mati. Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
@@ -1,232 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Headphones, Loader2, Radio, RadioOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SignalField } from "@/components/three";
|
||||
import { WebGLGuard } from "@/components/three/webgl-guard";
|
||||
import { StaticFallback } from "@/components/three/static-fallback";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Select } from "@/components/primitives/select";
|
||||
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||
import { SessionRibbon } from "@/components/charts/session-ribbon";
|
||||
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
||||
import { MicControl } from "@/components/voice/mic-control";
|
||||
import { ListenControl } from "@/components/voice/listen-control";
|
||||
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useGuilds,
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceChannels,
|
||||
useVoiceStatus,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useSpeakers,
|
||||
useMicTransmit,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import type { VoiceStatus } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, Button } from "@/components/primitives";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
|
||||
export default function VoiceView({ initialStatus }: { initialStatus?: VoiceStatus }) {
|
||||
export function VoiceView({
|
||||
initialStatus,
|
||||
initialGuilds,
|
||||
}: {
|
||||
initialStatus?: VoiceStatus;
|
||||
initialGuilds?: Guild[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [selectedGuild, setSelectedGuild] = useState("");
|
||||
const [selectedChannel, setSelectedChannel] = useState("");
|
||||
|
||||
// Live connection status — SWR revalidates on connect/disconnect (the
|
||||
// useVoiceConnect/Disconnect actions invalidate the "voice-status" key),
|
||||
// so this reflects real-time state instead of the static SSR snapshot.
|
||||
const { data: status } = useVoiceStatus(initialStatus);
|
||||
const { speakers, subscribe } = useSpeakers(status?.activeSpeakers ?? []);
|
||||
const { data: guilds = [] } = useGuilds();
|
||||
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
|
||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
||||
const { data: guilds } = useGuilds(initialGuilds);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const listen = useVoiceListen(ws);
|
||||
const mic = useMicTransmit(ws);
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [micVolume, setMicVolume] = useState(75);
|
||||
const listen = useVoiceListen(ws);
|
||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
const [channelId, setChannelId] = useState<string | null>(
|
||||
initialStatus?.activeChannelId ?? null,
|
||||
);
|
||||
const [micOn, setMicOn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(ws);
|
||||
return unsub;
|
||||
}, [subscribe, ws]);
|
||||
|
||||
const active = speakers.filter((s) => s.speaking);
|
||||
const connected = status?.connected ?? false;
|
||||
useEffect(() => {
|
||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||
else ambient.set("vermilion", 0.35, "voice idle");
|
||||
}, [status?.connected, ambient]);
|
||||
|
||||
const handleMicToggle = async (on: boolean) => {
|
||||
if (on) {
|
||||
try {
|
||||
await mic.mutateAsync(true);
|
||||
setMicActive(true);
|
||||
} catch {
|
||||
setMicActive(false);
|
||||
}
|
||||
} else {
|
||||
setMicActive(false);
|
||||
try {
|
||||
await mic.mutateAsync(false);
|
||||
} catch {
|
||||
// Stop already tore down — ignore remote error
|
||||
}
|
||||
if (error && !status) return <ErrorState error={error} />;
|
||||
if (!status && isLoading) return <LoadingState label="Linking voice" />;
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
||||
|
||||
const onConnect = async () => {
|
||||
if (!guildId || !channelId) {
|
||||
toast({ title: "Pick a guild + channel", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await connect.mutateAsync({ guildId, channelId });
|
||||
toast({ title: "Connected to voice", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicVolume = (v: number) => {
|
||||
setMicVolume(v);
|
||||
mic.setVolume(v);
|
||||
};
|
||||
|
||||
const handleListenVolume = (v: number) => {
|
||||
listen.setVolume(v);
|
||||
};
|
||||
|
||||
const handleGuildChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const g = e.target.value;
|
||||
setSelectedGuild(g);
|
||||
setSelectedChannel("");
|
||||
const onMic = async (on: boolean) => {
|
||||
try {
|
||||
await mic.mutateAsync(on);
|
||||
setMicOn(on);
|
||||
} catch (e) {
|
||||
toast({ title: "Mic error", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Connection bar with guild + voice channel pickers */}
|
||||
<div className="surface flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={connected ? "signal" : "neutral"} dot>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
{connected && status?.activeChannelName && (
|
||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||
<Headphones className="size-3.5" />
|
||||
{status.activeChannelName}
|
||||
</span>
|
||||
<div className="space-y-5">
|
||||
<GlassPanel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="voice"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
<PhoneOff className="size-4" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
|
||||
<Radio className="size-4" /> Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={micOn ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onMic(!micOn)}
|
||||
disabled={mic.isPending}
|
||||
aria-pressed={micOn}
|
||||
>
|
||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||
{micOn ? "Mic live" : "Push-to-talk"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={listen.active ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => listen.toggle(!listen.active)}
|
||||
>
|
||||
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
|
||||
{listen.active ? "Listening" : "Listen in"}
|
||||
</Button>
|
||||
{listen.active && (
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||
<Waves className="size-4 text-signal" />
|
||||
<Equalizer bars={listenBars} className="w-40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{!connected ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={handleGuildChange}
|
||||
className="flex-1 min-w-[140px] h-9"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select guild…
|
||||
</option>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => setSelectedChannel(e.target.value)}
|
||||
disabled={!selectedGuild}
|
||||
className="flex-1 min-w-[140px] h-9"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select channel…
|
||||
</option>
|
||||
{(voiceChannels ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void connect.mutate({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel,
|
||||
})
|
||||
}
|
||||
disabled={connect.isPending || !selectedGuild || !selectedChannel}
|
||||
>
|
||||
{connect.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Radio className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => void disconnect.mutate(undefined)}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
{disconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<RadioOff className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage hero */}
|
||||
<div className="relative surface 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"
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="stage"
|
||||
title="Live speakers"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{speakers.length} present
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{speakers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MicOff className="size-7" />}
|
||||
title={connected ? "Silent right now" : "Not connected"}
|
||||
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<VoiceStage speakers={speakers} />
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
{speakers.map((sp) => (
|
||||
<span
|
||||
key={sp.userId}
|
||||
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
|
||||
sp.speaking
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/5 text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} />
|
||||
{sp.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="links" title="Connections" />
|
||||
<div className="space-y-2">
|
||||
{(status?.connections ?? []).map((c) => (
|
||||
<div key={`${c.guildId}-${c.channelId}`} className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm">
|
||||
<span className="size-2 rounded-full bg-signal" />
|
||||
<span className="flex-1 truncate text-ink-soft">{c.channelName}</span>
|
||||
<span className="mono text-[0.6rem] text-ink-faint">{new Date(c.connectedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
))}
|
||||
{(status?.connections ?? []).length === 0 && (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">No active links</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||
<span className="mono text-ink-faint">channel</span>{" "}
|
||||
{status?.activeChannelName ?? "—"}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<StaggerItem>
|
||||
<MicControl
|
||||
micOn={micActive}
|
||||
onToggle={handleMicToggle}
|
||||
levels={listen.levels}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<ListenControl
|
||||
listening={listen.active}
|
||||
onToggle={(on) => listen.toggle(on)}
|
||||
volume={75}
|
||||
onVolume={handleListenVolume}
|
||||
/>
|
||||
</StaggerItem>
|
||||
</StaggerGroup>
|
||||
|
||||
{/* Activity timeline */}
|
||||
<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>
|
||||
|
||||
<ActiveSpeakersPanel speakers={speakers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user