chore: remove React frontend, rename frontend-leptos to frontend

This commit is contained in:
asepharyana
2026-07-03 23:11:00 +07:00
parent 4390a3da5e
commit 8166023f91
175 changed files with 22 additions and 9817 deletions
-300
View File
@@ -1,300 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ActiveSpeaker } from "./entities/voice/types.js";
import { AuthOverlay } from "./features/auth";
import { DashboardPanel } from "./features/dashboard";
import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl";
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
import { MessagesPanel } from "./features/messages";
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
import {
mergeMessages,
useMessages,
} from "./features/messages/hooks/useMessages";
import { getAppConfig } from "./shared/api/client";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useTheme } from "./shared/hooks/useTheme";
import { useUIState } from "./shared/hooks/useUIState";
import { MobileTabBar } from "./shared/ui/MobileTabBar";
import { useDashboardSocket } from "./shared/ws/socket";
import { DashboardLayout } from "./widgets/DashboardLayout";
export default function App() {
const { uiState, patchUIState } = useUIState();
const [authenticated, setAuthenticated] = useState(() => {
return sessionStorage.getItem("admin-password") !== null;
});
useTheme();
const handleAuthenticated = useCallback(() => {
setAuthenticated(true);
}, []);
const voice = useVoiceControl();
const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<
(ActiveSpeaker & { heardAt?: number })[]
>([]);
const [monitorGuildId, setMonitorGuildId] = useState("");
const audio = useAudioPlayback();
const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true";
const activeTab = uiState.activeTab || "messages";
// Reset persisted tab to "messages" once on mount if not authenticated
// Prevents localStorage carryover from a prior session on the Live tab
useEffect(() => {
if (!authenticated && !isPublicDashboard && uiState.activeTab === "live") {
patchUIState({ activeTab: "messages" });
}
// Run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selectedVoiceGuild =
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
// Resolve monitor guild name from the full guild list (has real names now)
const monitorGuildName = useMemo(
() =>
monitorGuildId
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
: null,
[monitorGuildId, voice.guilds],
);
// Update speaker list from incremental voice_active_user events
const updateSpeakerList = (
prev: (ActiveSpeaker & { heardAt?: number })[],
data: Partial<ActiveSpeaker> & {
userId?: string;
id?: string;
speaking: boolean;
},
): (ActiveSpeaker & { heardAt?: number })[] => {
const key = data.userId ?? data.id;
if (!key) return prev;
const now = Date.now();
const idx = prev.findIndex((s) => (s.userId ?? s.id) === key);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], ...data, heardAt: now };
return next;
}
return [
...prev,
{ ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number },
];
};
const socket = useDashboardSocket({
onBinary: (d) => audio.handleIncomingBinary(d),
onUserState: (users) =>
setActiveSpeakers(
users.map((u) => ({
...u,
heardAt: Date.now(),
})),
),
onVoiceActiveUser: (data) => {
if (data.userId) audio.registerUserId(data.userId);
setActiveSpeakers((prev) =>
updateSpeakerList(prev, {
userId: data.userId,
username: data.username,
avatar: data.avatar,
speaking: data.speaking,
}),
);
},
onVoiceRecordingStarted: () =>
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
onVoiceRecordingStopped: () =>
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
onMessageCreated: (m) =>
messages.setMessages((prev) => mergeMessages(prev, [m])),
onMessageUpdated: (m) =>
messages.setMessages((prev) =>
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
),
onMessageDeleted: (m) =>
messages.setMessages((prev) =>
prev.map((i) =>
i.id === m.id ? { ...i, type: "deleted" as const } : i,
),
),
onMessageAnalyzed: (msg) => {
messages.setMessages((prev) => mergeMessages(prev, [msg]));
const status = msg.ai_status;
if (status === "flagged") {
const username = msg.username || msg.user_id || "unknown";
const severity = msg.ai_severity || "";
const categories = msg.ai_categories || "";
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
window.dispatchEvent(
new CustomEvent("moderation_alert", {
detail: { type: status, username, severity, categories, brief },
}),
);
}
},
onAttachmentUploaded: () =>
messages
.fetchMessages(monitorGuildId || undefined)
.catch(() => undefined),
onAttachmentCreated: () =>
messages
.fetchMessages(monitorGuildId || undefined)
.catch(() => undefined),
onMediaState: (state) => media.setMediaState(state),
onVoiceRecordingUploaded: (d) =>
window.dispatchEvent(
new CustomEvent("voice_recording_uploaded", { detail: d }),
),
});
const transmit = useAudioTransmit(socket.socketRef);
// Load app config on mount
useEffect(() => {
getAppConfig()
.then((c) => {
if (c.monitorGuildId) {
setMonitorGuildId(c.monitorGuildId);
}
})
.catch(() => undefined);
}, []);
// Load voice channels when guild changes (Live tab)
useEffect(() => {
if (selectedVoiceGuild)
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
// Auto-fetch messages for the monitor guild
useEffect(() => {
if (monitorGuildId)
messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, [monitorGuildId, messages.fetchMessages]);
// Periodic refetch — keeps dashboard in sync even if WS events missed
useEffect(() => {
if (!monitorGuildId) return;
const interval = setInterval(() => {
messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, 15_000);
return () => clearInterval(interval);
}, [monitorGuildId, messages.fetchMessages]);
// Stale speaker pruning — remove speakers not heard from in 30s
useEffect(() => {
const interval = setInterval(() => {
setActiveSpeakers((prev) => {
const now = Date.now();
const pruned = prev.filter(
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
);
return pruned.length < prev.length ? pruned : prev;
});
}, 30_000);
return () => clearInterval(interval);
}, []);
// Push-to-Talk — hold Space to transmit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
)
return;
if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) {
e.preventDefault();
transmit.startTransmit().catch(() => undefined);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space" && transmit.isStreaming) {
transmit.stopTransmit();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [transmit]);
return (
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
recentMessages={messages.messages}
guildId={monitorGuildId}
channelId={
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
}
>
{activeTab === "live" && !authenticated && !isPublicDashboard ? (
<AuthOverlay onAuthenticated={handleAuthenticated} />
) : activeTab === "live" ? (
<LivePanel
guilds={voice.guilds}
voiceChannels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={uiState.selectedVoiceChannel || ""}
micLevel={0}
status={voice.voiceStatus}
voiceLoading={voice.loading}
activeSpeakers={activeSpeakers}
levels={audio.levels}
isListening={audio.isListening}
isStreaming={transmit.isStreaming}
mediaState={media.mediaState}
mediaLoading={media.loading}
onGuildChange={(id) =>
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
}
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
onJoin={() =>
voice.joinVoice(
selectedVoiceGuild,
uiState.selectedVoiceChannel || "",
)
}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={audio.toggleListening}
onStreamingToggle={transmit.toggle}
onQueueMusic={(s) => media.enqueue(s, "music")}
onStartScreen={(s) => media.enqueue(s, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
) : activeTab === "dashboard" ? (
<DashboardPanel />
) : (
<MessagesPanel
guildName={monitorGuildName}
messages={messages.messages}
onReanalyze={messages.reanalyze}
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
onLoadMore={messages.loadMore}
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
)}
<MobileTabBar
activeTab={activeTab}
onTabChange={(tab) => patchUIState({ activeTab: tab })}
/>
<ModerationAlertListener />
</DashboardLayout>
);
}
@@ -1,75 +0,0 @@
export interface DashboardStats {
total_messages: number;
total_users: number;
total_flagged: number;
total_clean: number;
total_warned: number;
total_error: number;
total_voice_recordings: number;
total_profiles: number;
today_messages: number;
today_flagged: number;
active_users_24h: number;
top_channels: Array<{
channel_id: string;
channel_name: string | null;
message_count: number;
}>;
moderation_overview: {
pending: number;
processing: number;
error: number;
};
}
export interface DashboardUser {
user_id: string;
username: string | null;
avatar_url: string | null;
profile_summary: string | null;
total_messages: number;
flagged_count: number;
last_message_at: number | null;
trust_score: number | null;
}
export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at: number | null;
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
}>;
}
export interface DashboardChannel {
channel_id: string;
channel_name: string | null;
guild_id: string | null;
total_messages: number;
flagged_count: number;
last_message_at: number | null;
culture_summary: string | null;
last_analyzed_at: number | null;
}
export interface DashboardChannelDetail extends DashboardChannel {
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
username: string | null;
}>;
}
export interface ChatResponse {
response?: string;
}
@@ -1,19 +0,0 @@
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
@@ -1,17 +0,0 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
@@ -1,19 +0,0 @@
export type {
AIRecommendedAction,
AISeverity,
AIStatus,
MessageRecord,
PageResult,
} from "@bete/shared";
export interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
channel?: {
channelId: string;
channelName?: string;
threadId?: string;
threadName?: string;
};
}
@@ -1,23 +0,0 @@
export 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;
transcription?: string | null;
created_at: number;
uploaded_at: number | null;
}
export interface VoiceRecordingListResponse {
items: VoiceRecording[];
nextCursor: string | null;
hasMore: boolean;
}
@@ -1,18 +0,0 @@
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: "live" | "messages" | "dashboard";
isListening?: boolean;
isStreaming?: boolean;
}
export type DashboardTab = "live" | "messages" | "dashboard";
export interface AppConfig {
monitorGuildId: string | null;
}
@@ -1,17 +0,0 @@
import type { GuildVoiceEntry } from "../guild/types";
export interface VoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}
@@ -1,82 +0,0 @@
import { motion } from "framer-motion";
import { Lock } from "lucide-react";
import { useState } from "react";
import { login } from "../../shared/api/client.js";
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);
sessionStorage.setItem("admin-password", password);
onAuthenticated();
} catch {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex items-center justify-center p-4"
>
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex items-center justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
</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>
</motion.div>
);
}
@@ -1,56 +0,0 @@
import { Hash } from "lucide-react";
import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui";
interface ChannelProfileDetailProps {
detail: DashboardChannelDetail | null;
loading: boolean;
error: string | null;
onBack: () => void;
onRefetch: () => void;
}
export function ChannelProfileDetail({
detail,
loading,
error,
onBack,
onRefetch,
}: ChannelProfileDetailProps) {
if (!detail && !loading && !error) return null;
return (
<ProfileDetail
loading={loading}
error={error}
onRetry={onRefetch}
onBack={onBack}
icon={<Hash className="h-8 w-8" />}
title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""}
subtitle={detail?.channel_id}
summaryLabel="AI Channel Summary"
summaryText={detail?.culture_summary ?? undefined}
lastAnalyzedLabel={
detail?.last_analyzed_at
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
: undefined
}
stats={{
totalLabel: "Total Messages",
totalValue: detail?.total_messages ?? 0,
cleanLabel: "Clean",
cleanValue: detail?.clean_count ?? 0,
flaggedLabel: "Flagged",
flaggedValue: detail?.flagged_count ?? 0,
}}
messages={
detail?.recent_messages.map((msg) => ({
id: msg.id,
content: msg.content,
created_at: new Date(msg.created_at).toISOString(),
ai_status: msg.ai_status,
})) ?? []
}
/>
);
}
@@ -1,56 +0,0 @@
import { Hash } from "lucide-react";
import type { DashboardChannel } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui";
interface ChannelSummaryListProps {
channels: DashboardChannel[];
loading: boolean;
error: string | null;
search: string;
onSearchChange: (value: string) => void;
onLoadMore: () => void;
hasMore: boolean;
onRefetch: () => void;
onSelectChannel: (channelId: string) => void;
}
export function ChannelSummaryList({
channels,
loading,
error,
search,
onSearchChange,
onLoadMore,
hasMore,
onRefetch,
onSelectChannel,
}: ChannelSummaryListProps) {
const items: SummaryItem[] = channels.map((ch) => ({
id: ch.channel_id,
label: `#${ch.channel_name ?? ch.channel_id}`,
subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined,
summaryText: ch.culture_summary ?? `${ch.total_messages} messages`,
onClick: () => onSelectChannel(ch.channel_id),
}));
return (
<SummaryList
items={items}
loading={loading}
error={error}
searchValue={search}
onSearchChange={onSearchChange}
onRetry={onRefetch}
hasMore={hasMore}
onLoadMore={onLoadMore}
loadingMore={loading}
renderIcon={() => (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Hash className="h-5 w-5 text-muted-foreground" />
</div>
)}
emptyMessage="No channels found."
/>
);
}
@@ -1,246 +0,0 @@
import { motion } from "framer-motion";
import {
AlertCircle,
BarChart3,
MessageSquare,
Mic,
RefreshCw,
ShieldAlert,
UserCheck,
Users,
} from "lucide-react";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { useUIState } from "../../../shared/hooks/useUIState.js";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
StatusBadge,
} from "../../../shared/ui";
import { useDashboardStats } from "../hooks/useDashboard";
export function DashboardStatsContent() {
const { stats, loading, error, refetch } = useDashboardStats();
const { patchUIState } = useUIState();
if (loading) {
return <StatsSkeleton />;
}
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-sm">{error}</p>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<RefreshCw className="h-4 w-4" /> Retry
</button>
</div>
);
}
if (!stats) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<BarChart3 className="h-10 w-10" />
<p className="text-sm">No data available yet.</p>
</div>
);
}
const cards = [
{
title: "Total Messages",
value: stats.total_messages.toLocaleString(),
icon: MessageSquare,
color: "text-primary",
bg: "bg-primary/10",
},
{
title: "Today's Messages",
value: stats.today_messages.toLocaleString(),
icon: MessageSquare,
color: "text-success",
bg: "bg-success-soft",
},
{
title: "Total Users",
value: stats.total_users.toLocaleString(),
icon: Users,
color: "text-primary",
bg: "bg-primary-soft",
},
{
title: "Active Users (24h)",
value: stats.active_users_24h.toLocaleString(),
icon: UserCheck,
color: "text-tertiary",
bg: "bg-tertiary-soft",
},
{
title: "Flagged",
value: stats.total_flagged.toLocaleString(),
icon: ShieldAlert,
color: "text-destructive",
bg: "bg-destructive/10",
},
{
title: "Clean",
value: stats.total_clean.toLocaleString(),
icon: ShieldAlert,
color: "text-success",
bg: "bg-success-soft",
},
{
title: "Voice Recordings",
value: stats.total_voice_recordings.toLocaleString(),
icon: Mic,
color: "text-info",
bg: "bg-info-soft",
onClick: () => patchUIState({ activeTab: "live" }),
},
{
title: "AI Profiles",
value: stats.total_profiles.toLocaleString(),
icon: Users,
color: "text-warning",
bg: "bg-warning-soft",
},
];
return (
<motion.div
className="grid gap-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
{/* Summary cards grid */}
<motion.div
variants={cardItem}
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{cards.map((card) => (
<Card
key={card.title}
className={cn(
"overflow-hidden",
card.onClick &&
"cursor-pointer transition-colors hover:bg-accent/50",
)}
onClick={card.onClick}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{card.title}
</p>
<p className="text-2xl font-bold tracking-tight">
{card.value}
</p>
</div>
<div className={cn("rounded-xl p-2.5", card.bg)}>
<card.icon className={cn("h-5 w-5", card.color)} />
</div>
</div>
</CardContent>
</Card>
))}
</motion.div>
{/* Top channels */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-primary">Top Channels</CardTitle>
</CardHeader>
<CardContent>
{stats.top_channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channel data yet.
</p>
) : (
<div className="space-y-2">
{stats.top_channels.map((ch) => (
<div
key={ch.channel_id}
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
>
<span className="truncate text-xs text-muted-foreground">
#{ch.channel_name ?? ch.channel_id}
</span>
<span className="ml-2 shrink-0 font-medium">
{ch.message_count.toLocaleString()}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Moderation overview */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-primary">Moderation Queue</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<StatusBadge status="pending" />
<StatusBadge status="processing" />
<StatusBadge status="error" />
</div>
<div className="grid gap-4 sm:grid-cols-3 mt-4">
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-muted-foreground">
{stats.moderation_overview.pending}
</p>
<p className="text-xs text-muted-foreground mt-1">Pending</p>
</div>
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-warning">
{stats.moderation_overview.processing}
</p>
<p className="text-xs text-muted-foreground mt-1">Processing</p>
</div>
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-destructive">
{stats.moderation_overview.error}
</p>
<p className="text-xs text-muted-foreground mt-1">Errors</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
function StatsSkeleton() {
return (
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -1,66 +0,0 @@
import { User } from "lucide-react";
import type { DashboardUserDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui";
interface UserProfileDetailProps {
detail: DashboardUserDetail | null;
loading: boolean;
error: string | null;
onBack: () => void;
onRefetch: () => void;
}
export function UserProfileDetail({
detail,
loading,
error,
onBack,
onRefetch,
}: UserProfileDetailProps) {
if (!detail && !loading && !error) return null;
const icon = detail?.avatar_url ? (
<img
src={detail.avatar_url}
alt=""
className="h-16 w-16 rounded-full object-cover ring-2 ring-border"
/>
) : (
<User className="h-8 w-8" />
);
return (
<ProfileDetail
loading={loading}
error={error}
onRetry={onRefetch}
onBack={onBack}
icon={icon}
title={detail?.username ?? detail?.user_id ?? ""}
subtitle={detail?.user_id}
summaryLabel="AI Profile Summary"
summaryText={detail?.profile_summary ?? undefined}
lastAnalyzedLabel={
detail?.last_analyzed_at
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
: undefined
}
stats={{
totalLabel: "Total Messages",
totalValue: detail?.total_messages ?? 0,
cleanLabel: "Clean",
cleanValue: detail?.clean_count ?? 0,
flaggedLabel: "Flagged",
flaggedValue: detail?.flagged_count ?? 0,
}}
messages={
detail?.recent_messages.map((msg) => ({
id: msg.id,
content: msg.content,
created_at: new Date(msg.created_at).toISOString(),
ai_status: msg.ai_status,
})) ?? []
}
/>
);
}
@@ -1,67 +0,0 @@
import { User } from "lucide-react";
import type { DashboardUser } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui";
interface UserSummaryListProps {
users: DashboardUser[];
loading: boolean;
error: string | null;
search: string;
onSearchChange: (value: string) => void;
onLoadMore: () => void;
hasMore: boolean;
onRefetch: () => void;
onSelectUser: (userId: string) => void;
}
export function UserSummaryList({
users,
loading,
error,
search,
onSearchChange,
onLoadMore,
hasMore,
onRefetch,
onSelectUser,
}: UserSummaryListProps) {
const items: SummaryItem[] = users.map((u) => ({
id: u.user_id,
label: u.username ?? u.user_id,
subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined,
summaryText: u.profile_summary ?? `${u.total_messages} messages`,
onClick: () => onSelectUser(u.user_id),
}));
const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url]));
return (
<SummaryList
items={items}
loading={loading}
error={error}
searchValue={search}
onSearchChange={onSearchChange}
onRetry={onRefetch}
hasMore={hasMore}
onLoadMore={onLoadMore}
loadingMore={loading}
renderIcon={(item) => {
const avatarUrl = avatarMap.get(item.id);
return avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<User className="h-5 w-5 text-muted-foreground" />
</div>
);
}}
emptyMessage="No users found."
/>
);
}
@@ -1,126 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { DashboardStats } from "../../../entities/dashboard/types.js";
import {
getDashboardChannelDetail,
getDashboardStats,
getDashboardUserDetail,
listDashboardChannels,
listDashboardUsers,
} from "../../../shared/api/client.js";
import { useItemDetail } from "../../../shared/hooks/useItemDetail";
import { usePaginatedList } from "../../../shared/hooks/usePaginatedList";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-dashboard");
/**
* Fetch dashboard aggregate stats.
*/
export function useDashboardStats() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDashboardStats();
setStats(data);
} catch (e) {
const msg = e instanceof Error ? e.message : "Failed to load stats";
setError(msg);
logger.error("[useDashboardStats]", { error: msg });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetch().catch(() => undefined);
}, [fetch]);
return { stats, loading, error, refetch: fetch };
}
/**
* Fetch paginated user list with optional search.
*/
export function useDashboardUsers() {
const paginated = usePaginatedList(
(params) =>
listDashboardUsers({
limit: params.limit,
search: params.search,
cursor: params.cursor,
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
"",
);
return {
users: paginated.data,
loading: paginated.loading,
error: paginated.error,
search: paginated.search,
setSearch: paginated.setSearch,
loadMore: paginated.loadMore,
hasMore: paginated.hasMore,
refetch: paginated.refetch,
};
}
/**
* Fetch a single user detail by userId.
*/
export function useDashboardUserDetail(userId: string | null) {
const { data, loading, error, refetch } = useItemDetail(
(_guildId, entityId) => getDashboardUserDetail(entityId),
"",
userId,
"user",
);
return { detail: data, loading, error, refetch };
}
/**
* Fetch paginated channel list with optional search.
*/
export function useDashboardChannels() {
const paginated = usePaginatedList(
(params) =>
listDashboardChannels({
limit: params.limit,
search: params.search,
guild_id: params.guildId,
cursor: params.cursor,
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
"",
);
return {
channels: paginated.data,
loading: paginated.loading,
error: paginated.error,
search: paginated.search,
setSearch: paginated.setSearch,
loadMore: paginated.loadMore,
hasMore: paginated.hasMore,
refetch: paginated.refetch,
};
}
/**
* Fetch a single channel detail by channelId.
*/
export function useDashboardChannelDetail(channelId: string | null) {
const { data, loading, error, refetch } = useItemDetail(
(_guildId, entityId) => getDashboardChannelDetail(entityId),
"",
channelId,
"channel",
);
return { detail: data, loading, error, refetch };
}
@@ -1,164 +0,0 @@
/* ═══════════════════════════════════════════════════════════════════════════
* IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher
* Menampilkan overview komunitas dengan IMPHNEN approachable modernism.
* Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal).
* ═══════════════════════════════════════════════════════════════════════════ */
import { useState } from "react";
import {
BarChart3,
Hash,
Users,
} from "lucide-react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
import { ChannelSummaryList } from "./components/ChannelSummaryList";
import { DashboardStatsContent } from "./components/DashboardStats";
import { UserProfileDetail } from "./components/UserProfileDetail";
import { UserSummaryList } from "./components/UserSummaryList";
import {
useDashboardChannelDetail,
useDashboardChannels,
useDashboardUserDetail,
useDashboardUsers,
} from "./hooks/useDashboard";
export function DashboardPanel() {
const [activeTab, setActiveTab] = useState("stats");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
null,
);
const {
users,
loading: usersLoading,
error: usersError,
search: userSearch,
setSearch: setUserSearch,
loadMore: loadMoreUsers,
hasMore: hasMoreUsers,
refetch: refetchUsers,
} = useDashboardUsers();
const {
detail: userDetail,
loading: userDetailLoading,
error: userDetailError,
refetch: refetchUserDetail,
} = useDashboardUserDetail(selectedUserId);
const {
channels,
loading: channelsLoading,
error: channelsError,
search: channelSearch,
setSearch: setChannelSearch,
loadMore: loadMoreChannels,
hasMore: hasMoreChannels,
refetch: refetchChannels,
} = useDashboardChannels();
const {
detail: channelDetail,
loading: channelDetailLoading,
error: channelDetailError,
refetch: refetchChannelDetail,
} = useDashboardChannelDetail(selectedChannelId);
// Show user detail view
if (selectedUserId) {
return (
<UserProfileDetail
detail={userDetail}
loading={userDetailLoading}
error={userDetailError}
onBack={() => {
setSelectedUserId(null);
}}
onRefetch={refetchUserDetail}
/>
);
}
// Show channel detail view
if (selectedChannelId) {
return (
<ChannelProfileDetail
detail={channelDetail}
loading={channelDetailLoading}
error={channelDetailError}
onBack={() => {
setSelectedChannelId(null);
}}
onRefetch={refetchChannelDetail}
/>
);
}
return (
<div className="w-full">
{/* ── Page Header ───────────────────────────────────────────────── */}
<div className="mb-6">
<h2 className="typo-headline-md text-[#1a1a1a]">
Dashboard Guild
</h2>
<p className="typo-body-md text-[#666666] mt-1">
Pantau statistik, profil pengguna, dan aktivitas kanal komunitas
IMPHNEN secara real-time.
</p>
</div>
{/* ── Tabs ────────────────────────────────────────────────────────── */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-6 bg-[#f5f5f5] p-1 rounded-lg inline-flex">
<TabsTrigger value="stats" className="flex items-center gap-1.5">
<BarChart3 className="h-4 w-4" />
<span>Statistik</span>
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-1.5">
<Users className="h-4 w-4" />
<span>Pengguna</span>
</TabsTrigger>
<TabsTrigger value="channels" className="flex items-center gap-1.5">
<Hash className="h-4 w-4" />
<span>Kanal</span>
</TabsTrigger>
</TabsList>
<TabsContent value="stats">
<DashboardStatsContent />
</TabsContent>
<TabsContent value="users">
<UserSummaryList
users={users}
loading={usersLoading}
error={usersError}
search={userSearch}
onSearchChange={setUserSearch}
onLoadMore={loadMoreUsers}
hasMore={hasMoreUsers}
onRefetch={refetchUsers}
onSelectUser={setSelectedUserId}
/>
</TabsContent>
<TabsContent value="channels">
<ChannelSummaryList
channels={channels}
loading={channelsLoading}
error={channelsError}
search={channelSearch}
onSearchChange={setChannelSearch}
onLoadMore={loadMoreChannels}
hasMore={hasMoreChannels}
onRefetch={refetchChannels}
onSelectChannel={setSelectedChannelId}
/>
</TabsContent>
</Tabs>
</div>
);
}
@@ -1,49 +0,0 @@
import type { ActiveSpeaker } from "../../../entities/voice/types.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
if (speakers.length === 0) {
return <EmptyStateMascot />;
}
return (
<div className="space-y-2">
{speakers.map((s) => {
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-card p-3"
>
<img
src={s.avatar}
alt=""
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{s.username}</div>
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
}`}
/>
<span
className={`text-xs font-medium ${
s.speaking ? "text-success" : "text-muted-foreground"
}`}
>
{s.speaking ? "Speaking" : "Silent"}
</span>
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -1,82 +0,0 @@
import { useEffect, useRef } from "react";
interface AudioVisualizerProps {
levels: number[];
}
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ro = new ResizeObserver(() => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 128 * dpr;
canvas.style.height = "128px";
});
ro.observe(container);
return () => ro.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = canvas.width / dpr;
const height = canvas.height / dpr;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const barWidth = width / levels.length;
const maxBarHeight = height * 0.85;
const root = getComputedStyle(document.documentElement);
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, primaryColor);
gradient.addColorStop(1, primaryColor);
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;
ctx.fillStyle = gradient;
const radius = barWidth * 0.4;
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 ref={containerRef} className="relative w-full">
<canvas
ref={canvasRef}
width={0}
height={0}
className="w-full rounded-lg bg-primary/5"
style={{ height: "128px" }}
/>
</div>
);
}
@@ -1,29 +0,0 @@
// ─── Mic level meter — vertical bar showing outgoing audio RMS level ─────────
interface MicLevelMeterProps {
level: number; // 0-1
}
export function MicLevelMeter({ level }: MicLevelMeterProps) {
const pct = Math.round(level * 100);
// Color gradient: green <-> yellow <-> red
const hue = 120 - level * 120; // 120 (green) -> 0 (red)
const bg = `hsl(${hue}, 80%, 45%)`;
return (
<div
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
role="meter"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Microphone level"
>
<div
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
style={{ width: `${pct}%`, backgroundColor: bg }}
/>
</div>
);
}
@@ -1,116 +0,0 @@
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
import { useCallback, useEffect, useRef, 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));
const [muted, setMuted] = useState(false);
const prevVolumeRef = useRef(safeVolume);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Proper debounce: setTimeout instead of setInterval polling
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) >= 0.001)
onVolumeChange(normalized);
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [draftVolume, safeVolume, onVolumeChange]);
const handleMute = useCallback(() => {
if (muted) {
// Unmute: restore previous volume
const restore = prevVolumeRef.current;
setDraftVolume(Math.round(restore * 100));
onVolumeChange(restore);
setMuted(false);
} else {
// Mute: save current, set to 0
prevVolumeRef.current = safeVolume;
setDraftVolume(0);
onVolumeChange(0);
setMuted(true);
}
}, [muted, safeVolume, onVolumeChange]);
const submit = () => {
const t = source.trim();
if (!t) return;
onQueue(t);
setSource("");
};
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm 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">
<button
type="button"
onClick={handleMute}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{muted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(e) => {
setDraftVolume(Number(e.target.value));
if (muted) setMuted(false);
}}
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>
);
}
@@ -1,61 +0,0 @@
import { MonitorUp, Music2 } from "lucide-react";
import type { MediaItem } from "../../../entities/media/types.js";
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-xl 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-lg bg-primary/10 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-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{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>
);
}
@@ -1,207 +0,0 @@
// ─── Recordings Sub-Panel ──
import { Download, Mic, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { VoiceRecording } from "../../../entities/recording/types.js";
import { deleteRecording, listRecordings } from "../../../shared/api/client";
import { formatBytes, formatDate } from "../../../shared/lib/utils";
import { Badge, Button, Skeleton } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
import { WaveformPlayer } from "./WaveformPlayer";
export function RecordingsSubPanel() {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const loadRecordings = useCallback(
async (opts?: { signal?: AbortSignal }) => {
try {
setLoading(true);
setError(null);
const data = await listRecordings({ limit: 50 });
if (!opts?.signal?.aborted) {
setRecordings(data.items);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
}
} catch (err) {
if (!opts?.signal?.aborted)
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!opts?.signal?.aborted) setLoading(false);
}
},
[],
);
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return;
try {
setLoadingMore(true);
const data = await listRecordings({ limit: 50, cursor: nextCursor });
setRecordings((prev) => [...prev, ...data.items]);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingMore(false);
}
}, [nextCursor, loadingMore]);
useEffect(() => {
const ab = new AbortController();
loadRecordings({ signal: ab.signal });
const handler = () => loadRecordings();
window.addEventListener("voice_recording_uploaded", handler);
return () => {
ab.abort();
window.removeEventListener("voice_recording_uploaded", handler);
};
}, [loadRecordings]);
const handleDelete = useCallback(async (id: string) => {
if (!confirm("Delete this recording?")) return;
setDeletingIds((prev) => new Set(prev).add(id));
try {
await deleteRecording(id);
setRecordings((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, []);
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-card 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={() => loadRecordings()}>
Retry
</Button>
</div>
</div>
);
}
if (recordings.length === 0) {
return <EmptyStateMascot />;
}
return (
<div className="space-y-3">
{recordings.map((rec) => (
<div key={rec.id} className="rounded-xl border border-[#e0e0e0] bg-white">
<div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 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>
)}
{rec.transcription && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
{rec.transcription}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={deletingIds.has(rec.id)}
onClick={() => handleDelete(rec.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<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}
download={rec.filename}
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>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer
downloadUrl={rec.download_url}
filename={rec.filename}
/>
</div>
)}
</div>
))}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
);
}
@@ -1,47 +0,0 @@
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="rounded-xl border border-border bg-card p-4 shadow-sm 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>
);
}
@@ -1,119 +0,0 @@
import { Headphones, Radio } from "lucide-react";
import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import { Button, Select } from "../../../shared/ui";
import { MicLevelMeter } from "./MicLevelMeter";
interface VoiceConnectionCardProps {
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
voiceLoading: boolean;
isListening: boolean;
isStreaming: boolean;
micLevel: number;
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,
micLevel,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
}: VoiceConnectionCardProps) {
return (
<div className="rounded-xl 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 text-primary" /> 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 text-foreground">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 text-foreground">
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}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{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 ? "secondary" : "outline"}
onClick={onStreamingToggle}
>
<Radio className="mr-1.5 h-4 w-4" />{" "}
{isStreaming ? "Stop Transmit" : "Transmit"}
</Button>
{isStreaming && (
<div className="flex items-center gap-2 pl-1">
<span className="animate-pulse rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-medium text-white">
Hold Space
</span>
<MicLevelMeter level={micLevel} />
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,256 +0,0 @@
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
import { Pause, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createLogger } from "../../../shared/lib/logger";
const logger = createLogger("waveform-player");
const BAR_COUNT = 64;
const SAMPLE_RATE = 24000;
interface WaveformPlayerProps {
downloadUrl: string;
filename: string;
}
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
const [playing, setPlaying] = useState(false);
const [peaks, setPeaks] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const startTimeRef = useRef(0);
const startOffsetRef = useRef(0);
const rafRef = useRef<number>(0);
const decodedRef = useRef<AudioBuffer | null>(null);
const durationRef = useRef(0);
// Decode audio on mount
useEffect(() => {
let cancelled = false;
const ctx = new AudioContext();
audioContextRef.current = ctx;
fetch(downloadUrl)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then((buf) => ctx.decodeAudioData(buf))
.then((audioBuffer) => {
if (cancelled) return;
decodedRef.current = audioBuffer;
durationRef.current = audioBuffer.duration;
// Compute waveform peaks
const channel = audioBuffer.getChannelData(0);
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
const peakValues: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * samplesPerBar;
const end = Math.min(start + samplesPerBar, channel.length);
for (let j = start; j < end; j++) {
const abs = Math.abs(channel[j]);
if (abs > max) max = abs;
}
// Clamp so silent sections still show a tiny bar
peakValues.push(Math.max(0.01, max));
}
setPeaks(peakValues);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
logger.error("Failed to decode audio", { error: msg });
setError(msg);
setLoading(false);
});
return () => {
cancelled = true;
ctx.close();
};
}, [downloadUrl]);
// Draw waveform on canvas whenever peaks change or while playing
const drawWaveform = useCallback(
(progress = 0) => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 64 * dpr;
canvas.style.height = "64px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, 64);
if (peaks.length === 0) return;
const barWidth = rect.width / peaks.length;
const barGap = Math.max(1, barWidth * 0.15);
const barActualWidth = barWidth - barGap;
const progressPixel = rect.width * progress;
for (let i = 0; i < peaks.length; i++) {
const x = i * barWidth;
const height = Math.max(2, peaks[i] * 50);
const y = 32 - height / 2;
// Color: played vs unplayed
const isPlayed = x + barWidth <= progressPixel;
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
ctx.fillRect(x, y, barActualWidth, height);
}
},
[peaks],
);
// Initial draw when peaks change
useEffect(() => {
drawWaveform();
}, [drawWaveform]);
// Animation loop while playing
useEffect(() => {
if (!playing || !decodedRef.current) return;
const tick = () => {
if (!audioContextRef.current) return;
const elapsed =
audioContextRef.current.currentTime - startTimeRef.current;
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
drawWaveform(Math.min(1, Math.max(0, progress)));
if (progress >= 1) {
setPlaying(false);
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [playing, drawWaveform]);
const handleTogglePlay = useCallback(() => {
const ctx = audioContextRef.current;
const buffer = decodedRef.current;
if (!ctx || !buffer) return;
if (playing) {
// Pause
if (sourceRef.current) {
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
sourceRef.current.stop();
sourceRef.current.disconnect();
sourceRef.current = null;
}
setPlaying(false);
return;
}
// Resume / start
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, startOffsetRef.current);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
setPlaying(true);
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}, [playing]);
const handleSeek = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!decodedRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const offset = progress * durationRef.current;
const ctx = audioContextRef.current;
if (ctx && sourceRef.current) {
sourceRef.current.stop();
sourceRef.current.disconnect();
}
startOffsetRef.current = offset;
startTimeRef.current = ctx?.currentTime ?? 0;
drawWaveform(progress);
if (playing && ctx) {
const buffer = decodedRef.current;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, offset);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}
},
[playing, drawWaveform],
);
if (loading) {
return <div className="h-16 w-full animate-pulse rounded-md bg-muted" />;
}
if (error) {
return (
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
{error}
</div>
);
}
if (peaks.length === 0) return null;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleTogglePlay}
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
>
{playing ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</button>
<div
ref={containerRef}
className="relative flex-1 cursor-pointer"
onClick={handleSeek}
role="slider"
aria-label={`Playback seek for ${filename}`}
tabIndex={0}
>
<canvas ref={canvasRef} className="w-full" />
</div>
</div>
);
}
@@ -1,104 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { MediaState } from "../../../entities/media/types.js";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../../../shared/api/client";
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-media-control");
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
const { loading, error, execute, clearError } = useAsyncAction();
const refreshMedia = useCallback(async () => {
const state = await getMediaStatus();
setMediaState(state);
return state;
}, []);
const enqueue = useCallback(
async (source: string, mode: "music" | "screen") => {
const result = await execute(() => queueMedia(source, mode));
if (result) {
setMediaState(result);
logger.info("Media queued", { source, mode });
} else {
logger.error("Failed to queue media", { source, mode });
}
return result;
},
[execute],
);
const skip = useCallback(async () => {
const result = await execute(() => skipMedia());
if (result) {
setMediaState(result);
logger.info("Media skipped");
} else {
logger.error("Failed to skip media");
}
return result;
}, [execute]);
const stop = useCallback(async () => {
const result = await execute(() => stopMedia());
if (result) {
setMediaState(result);
logger.info("Media stopped");
} else {
logger.error("Failed to stop media");
}
return result;
}, [execute]);
const setVolume = useCallback(
async (volume: number) => {
clearError();
try {
const state = await setMediaVolume(volume);
setMediaState(state);
logger.info("Volume set", { volume });
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to set volume", { volume, error: message });
throw err;
}
},
[clearError],
);
useEffect(() => {
refreshMedia().catch((err) =>
logger.error("Failed to refresh media state on mount", {
error: String(err),
}),
);
}, [refreshMedia]);
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}
@@ -1,113 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../../../shared/api/client";
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-voice-control");
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,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
});
const { loading, error, execute, clearError } = useAsyncAction();
const refreshGuilds = useCallback(async () => {
clearError();
const nextGuilds = await getGuilds();
setGuilds(nextGuilds);
return nextGuilds;
}, [clearError]);
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) => {
const result = await execute(() => connectVoice(guildId, channelId));
if (result) {
setVoiceStatus(result);
logger.info("Connected to voice", { guildId, channelId });
} else {
logger.error("Failed to connect to voice", { guildId, channelId });
}
return result;
},
[execute],
);
const leaveVoice = useCallback(async () => {
const result = await execute(() => disconnectVoice());
if (result) {
setVoiceStatus(result);
logger.info("Disconnected from voice");
} else {
logger.error("Failed to disconnect from voice");
}
return result;
}, [execute]);
useEffect(() => {
refreshGuilds().catch((err) =>
logger.error("Failed to refresh guilds on mount", { error: String(err) }),
);
refreshVoiceStatus().catch((err) =>
logger.error("Failed to refresh voice status on mount", {
error: String(err),
}),
);
}, [refreshGuilds, refreshVoiceStatus]);
return {
guilds,
voiceChannels,
textChannels,
voiceStatus,
loading,
error,
refreshGuilds,
refreshVoiceStatus,
loadVoiceChannels,
loadTextTargets,
joinVoice,
leaveVoice,
};
}
@@ -1,171 +0,0 @@
// ─── Live Panel — thin composition layer ────────────────────────────────────
import { motion } from "framer-motion";
import { Mic, MonitorUp, Music2 } from "lucide-react";
import type { Channel, Guild } from "../../entities/guild/types.js";
import type { MediaState } from "../../entities/media/types.js";
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
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;
micLevel: number;
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,
micLevel,
mediaState,
mediaLoading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: LivePanelProps) {
return (
<motion.div
variants={cardStagger}
initial="initial"
animate="animate"
className="grid gap-6"
>
<motion.div variants={cardItem}>
<VoiceConnectionCard
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
status={status}
voiceLoading={voiceLoading}
isListening={isListening}
isStreaming={isStreaming}
micLevel={micLevel}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onJoin={onJoin}
onDisconnect={onDisconnect}
onListenToggle={onListenToggle}
onStreamingToggle={onStreamingToggle}
/>
</motion.div>
<motion.div
variants={cardItem}
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>
</motion.div>
<motion.div variants={cardItem}>
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
</motion.div>
<motion.div variants={cardItem}>
<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>
</motion.div>
</motion.div>
);
}
@@ -1,132 +0,0 @@
import type { MessageRecord } from "../../../entities/message/types.js";
import { parseMetadata } from "../../../shared/lib/utils.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ImageItem {
url: string;
title: string;
kind: "attachment" | "embed" | "sticker";
message: MessageRecord;
}
function kindBadge(kind: ImageItem["kind"]): string {
switch (kind) {
case "sticker":
return "bg-primary/10 text-primary border-primary/20";
case "attachment":
return "bg-primary-soft text-primary border-primary/30";
case "embed":
return "bg-tertiary-soft text-tertiary border-tertiary/20";
}
}
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 <EmptyStateMascot />;
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image) => {
// Stable key using message.id + url
const stableKey = `${image.message.id}-${image.kind}-${image.url}`;
return (
<a
key={stableKey}
href={image.url}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 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"
/>
)}
<span
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
>
{image.kind}
</span>
</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 ring-1 ring-primary/30">
<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>
);
}
@@ -1,561 +0,0 @@
import {
AlertCircle,
CheckCircle2,
Forward,
Hash,
Image as ImageIcon,
MessageCircle,
Pencil,
Reply,
RotateCw,
Smile,
Trash2,
Video,
} from "lucide-react";
import { Fragment, useEffect, useMemo, useState } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
import { parseMetadata } from "../../../shared/lib/utils.js";
import { getMessageById } from "../../../shared/api/client.js";
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
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) {
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;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
if (parts.length === 0) return content;
return <Fragment>{parts}</Fragment>;
}
// ─── Props ───────────────────────────────────────────────────────────────────
interface MessageCardProps {
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
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 severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-destructive-soft text-destructive border-destructive/20";
case "high":
return "bg-warning-soft text-warning border-warning/20";
case "medium":
return "bg-warning-soft text-warning border-warning/20";
case "low":
return "bg-info-soft text-info border-info/20";
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();
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
// ─── Single message row inside a group ───────────────────────────────────────
function MessageRow({
message,
onReanalyze,
}: {
message: MessageRecord;
onReanalyze: (id: string) => Promise<void>;
}) {
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);
// ── Fetch referenced message content for replies if not in metadata ──
const referenceMeta = metadata.reference;
const [fetchedRefContent, setFetchedRefContent] = useState<{
username: string;
content: string;
} | null>(null);
useEffect(() => {
if (
message.is_reply &&
referenceMeta?.messageId &&
!referenceMeta?.content &&
!message.deleted_at
) {
getMessageById(referenceMeta.messageId)
.then((refMsg) => {
if (refMsg) {
setFetchedRefContent({
username: refMsg.username,
content: refMsg.content,
});
}
})
.catch(() => {
// Referenced message might not exist in our DB
});
}
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
const analysisSummary = useMemo(() => {
const parts: string[] = [];
if (categories.length > 0) {
parts.push(categories.slice(0, 3).join(", "));
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
}
if (message.ai_severity && message.ai_severity !== "none") {
parts.push(message.ai_severity);
}
if (confidence != null) {
parts.push(`${Math.round(confidence * 100)}% confidence`);
}
if (parts.length === 0) return "View AI analysis";
return parts.join(" · ");
}, [categories, message.ai_severity, confidence]);
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 videoAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("video/") ||
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
);
const hasImages = imageAttachments.length > 0;
const hasVideos = videoAttachments.length > 0;
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
const isFallbackText =
/^\[(Attachment|Sticker):/i.test(displayContent) ||
/^\[Embed\]/i.test(displayContent);
const shouldShowContent = displayContent && !isFallbackText;
const handleReanalyze = async () => {
setIsReanalyzing(true);
try {
await onReanalyze(message.id);
} finally {
setIsReanalyzing(false);
}
};
// ── Reference context (reply / forward / crosspost) ─────────────────
const renderReferenceIndicator = () => {
// Use fetched content if metadata doesn't have it
const effectiveRepliedUsername =
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
const effectiveRepliedContent =
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
if (message.is_reply) {
return (
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
<span className="min-w-0">
<span className="font-medium text-foreground/60">
Replying to{" "}
{effectiveRepliedUsername
? `@${effectiveRepliedUsername}`
: "a message"}
</span>
{effectiveRepliedContent && (
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
{effectiveRepliedContent}
</span>
)}
</span>
</div>
);
}
if (message.is_forward) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
<Forward className="h-3 w-3 shrink-0 text-warning" />
<span className="font-medium text-warning/70">Forwarded</span>
</div>
);
}
if (message.is_crosspost) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
<span className="font-medium text-info/70">Crossposted</span>
</div>
);
}
return null;
};
const referenceIndicator = renderReferenceIndicator();
return (
<div className="space-y-2">
{/* Row header: time + edit/delete indicators + AI badges */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className="text-[11px] text-muted-foreground/70"
title={new Date(message.created_at).toLocaleString()}
>
{formatTime(message.created_at)}
</span>
{message.edited_at && (
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
<Pencil className="h-2.5 w-2.5" /> edited
</span>
)}
{message.deleted_at && (
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
<Trash2 className="h-2.5 w-2.5" /> deleted
</span>
)}
<div className="ml-auto flex items-center gap-1">
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
</StatusBadge>
{message.ai_severity && message.ai_severity !== "none" && (
<Badge
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
>
{message.ai_severity}
</Badge>
)}
{confidence != null && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 tabular-nums"
>
{Math.round(confidence * 100)}%
</Badge>
)}
</div>
</div>
{/* Reference context: reply / forward / crosspost */}
{referenceIndicator}
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
{shouldShowContent ? (
<p
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
message.deleted_at
? "text-muted-foreground/60"
: "text-foreground/90"
}`}
>
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{/* Stickers */}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-2">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-1.5"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
loading="lazy"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<Smile className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
))}
</div>
)}
{/* Attached images */}
{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-lg border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-16 w-16 object-cover transition-transform hover:scale-105"
loading="lazy"
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{imageAttachments.length - 4}
<ImageIcon className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Attached videos */}
{hasVideos && (
<div className="flex gap-2 overflow-x-auto">
{videoAttachments.slice(0, 4).map((vid) => (
<video
key={vid.url}
src={vid.url}
controls
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
preload="metadata"
/>
))}
{videoAttachments.length > 4 && (
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{videoAttachments.length - 4}
<Video className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-[10px]">
{category}
</Badge>
))}
</div>
)}
{/* AI Analysis — always expanded */}
{message.ai_analysis ? (
<div
className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged"
? "border-l-tertiary bg-tertiary/5"
: "border-l-success bg-success-soft"
}`}
>
<div className="flex items-start gap-2 text-[11px]">
<span className="mt-0.5 shrink-0">
{aiStatus === "flagged" ? "🚨" : "️"}
</span>
<div className="min-w-0 flex-1">
<span className="block font-medium text-foreground/70 mb-1">
{analysisSummary}
</span>
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
{message.ai_analysis}
</div>
</div>
</div>
</div>
) : null}
{/* AI Error */}
{message.ai_error ? (
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
AI error: {message.ai_error}
</div>
) : null}
{/* Re-analyze button */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-[11px] h-7 px-2.5"
>
<RotateCw
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-[11px] text-tertiary/70">
Click to retry analysis
</span>
)}
</div>
</div>
);
}
// ─── Group card: one card per user group ─────────────────────────────────────
export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
const firstMsg = messages[0];
const hasMultiple = messages.length > 1;
const meta = useMemo(
() => parseMetadata(firstMsg.metadata),
[firstMsg.metadata],
);
const channelMeta = meta.channel;
const locationLabel = useMemo(() => {
if (channelMeta?.threadName) {
return `# ${channelMeta.channelName || "unknown"} ${channelMeta.threadName}`;
}
if (channelMeta?.channelName) {
return `# ${channelMeta.channelName}`;
}
return null;
}, [channelMeta]);
return (
<article
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
}`}
>
<div className="flex gap-3 p-4">
{/* Avatar — only for first message */}
<img
src={
firstMsg.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
/>
<div className="min-w-0 flex-1">
{/* Group header: username + location + timestamp */}
<div className="flex items-baseline gap-2 mb-2">
<span className="font-semibold text-sm text-foreground">
{firstMsg.username || firstMsg.user_id}
</span>
{locationLabel && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
<Hash className="h-2.5 w-2.5" />
{locationLabel}
</span>
)}
<span
className="text-[11px] text-muted-foreground/60"
title={new Date(firstMsg.created_at).toLocaleString()}
>
{formatTimeAgo(firstMsg.created_at)}
{hasMultiple && ` · ${messages.length} messages`}
</span>
</div>
{/* Message rows — divided by separator when multiple */}
<div
className={
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
}
>
{messages.map((msg, idx) => (
<div
key={msg.id}
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
>
<MessageRow message={msg} onReanalyze={onReanalyze} />
</div>
))}
</div>
</div>
</div>
</article>
);
}
// ─── Skeleton ────────────────────────────────────────────────────────────────
export function MessageCardSkeleton() {
return (
<article className="rounded-xl 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>
);
}
@@ -1,120 +0,0 @@
import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { ScrollArea } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
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;
}
/** Messages from the same user within 5 minutes are visually grouped. */
const GROUP_WINDOW_MS = 5 * 60 * 1000;
interface MessageGroup {
messages: MessageRecord[];
}
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
const groups: MessageGroup[] = [];
for (const msg of messages) {
const lastGroup = groups[groups.length - 1];
if (
lastGroup &&
lastGroup.messages[0].user_id === msg.user_id &&
lastGroup.messages[lastGroup.messages.length - 1].created_at -
msg.created_at <
GROUP_WINDOW_MS
) {
lastGroup.messages.push(msg);
} else {
groups.push({ messages: [msg] });
}
}
return groups;
}
export function MessageFeed({
messages,
onReanalyze,
emptyText: _emptyText,
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" },
);
observer.observe(el);
return () => observer.disconnect();
}, [onLoadMore, hasMore]);
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
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 <EmptyStateMascot />;
}
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<motion.div
className="space-y-3"
variants={cardStagger}
initial="initial"
animate="animate"
>
{groupedMessages.map((group) => (
<motion.div key={group.messages[0].id} variants={cardItem}>
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
</motion.div>
))}
{/* 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-primary/40" />
)}
</div>
)}
</motion.div>
</ScrollArea>
);
}
@@ -1,57 +0,0 @@
// ─── Moderation alert toast listener ───────────────────────────────────────
// Listens for "moderation_alert" custom events dispatched from WebSocket
// message_analyzed handler, and shows toast notifications for flagged
// messages so moderators don't miss important alerts.
import { useEffect } from "react";
import { useToast } from "../../../shared/ui";
interface AlertDetail {
type: "flagged";
username: string;
severity: string;
categories: string;
brief: string;
}
function severityToToastType(
severity: string,
): "error" | "warning" | "info" | "success" {
switch (severity) {
case "critical":
case "high":
return "error";
case "medium":
return "warning";
case "low":
return "info";
default:
return "warning";
}
}
export function ModerationAlertListener() {
const { addToast } = useToast();
useEffect(() => {
const handler = (e: Event) => {
const { username, severity, categories, brief } = (
e as CustomEvent<AlertDetail>
).detail;
const sevLabel = severity ? `[${severity}]` : "";
const catLabel = categories
? `${categories.split(",").slice(0, 2).join(", ")}`
: "";
addToast(
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
severityToToastType(severity),
);
};
window.addEventListener("moderation_alert", handler);
return () => window.removeEventListener("moderation_alert", handler);
}, [addToast]);
return null;
}
@@ -1,161 +0,0 @@
import { useCallback, useRef, useState } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
import {
listMessages,
reanalyzeErrorBatch,
reanalyzeMessage,
} from "../../../shared/api/client";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-messages");
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 });
}
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 currentGuild = useRef<string | null>(null);
const fetchMessages = useCallback(async (guildId?: string) => {
if (!guildId) {
setMessages([]);
setCursor(null);
setHasMore(false);
return [];
}
currentGuild.current = guildId;
setLoading(true);
setError(null);
try {
const result = await listMessages({
guildId,
limit: PAGE_SIZE,
});
if (currentGuild.current === guildId) {
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);
logger.error("Failed to fetch messages", { guildId, error: message });
throw err;
} finally {
setLoading(false);
}
}, []);
const loadMore = useCallback(async () => {
if (!cursor || !currentGuild.current || loadingMore) return;
setLoadingMore(true);
try {
const result = await listMessages({
guildId: currentGuild.current,
cursor,
limit: PAGE_SIZE,
});
setMessages((prev) => [...prev, ...result.data]);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to load more messages", { error: message });
} finally {
setLoadingMore(false);
}
}, [cursor, loadingMore]);
const reanalyze = useCallback(async (id: string): Promise<void> => {
// Capture prior state inside the functional updater so we don't need
// `messages` as a useCallback dependency (avoids stale closure churn).
let saved: MessageRecord | undefined;
setMessages((prev) => {
saved = prev.find((m) => m.id === id);
return prev.map((message) =>
message.id === id
? {
...message,
ai_status: "pending" as const,
ai_error: null,
ai_analysis: null,
}
: message,
);
});
try {
await reanalyzeMessage(id);
} catch (err) {
// HTTP failed — revert the optimistic update so the UI stays truthful.
if (saved) {
const snapshot = saved;
setMessages((prev) =>
prev.map((message) => (message.id === id ? snapshot : message)),
);
}
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to reanalyze message", { id, error: message });
throw err;
}
}, []);
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
// Optimistically mark all error messages as pending
setMessages((prev) =>
prev.map((message) =>
message.ai_status === "error"
? {
...message,
ai_status: "pending" as const,
ai_error: null,
ai_analysis: null,
}
: message,
),
);
try {
const { count } = await reanalyzeErrorBatch({
guildId: currentGuild.current ?? undefined,
});
logger.info("Reanalyze all errors complete", { count });
return count;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to reanalyze error batch", { error: message });
throw err;
}
}, []);
return {
messages,
setMessages,
loading,
loadingMore,
error,
fetchMessages,
reanalyze,
reanalyzeAllErrors,
loadMore,
hasMore,
};
}
@@ -1,315 +0,0 @@
import { motion } from "framer-motion";
import { Filter, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { MessageRecord } from "../../shared/api/client";
import { request } from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ImageGrid } from "./components/ImageGrid";
import { MessageFeed } from "./components/MessageFeed";
interface MessagesPanelProps {
guildName: string | null;
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
onReanalyzeAllErrors?: () => Promise<number>;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
type AiFilter = "all" | "analyzed" | "clean" | "flagged" | "error" | "pending";
export function MessagesPanel({
guildName,
messages,
onReanalyze,
onReanalyzeAllErrors,
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>("analyzed");
const [viewTab, setViewTab] = useState<"all" | "images">("all");
const [retryingAll, setRetryingAll] = useState(false);
const [retriedCount, setRetriedCount] = useState<number | null>(null);
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setShowSearch(false);
return;
}
setIsSearching(true);
try {
const params = new URLSearchParams({ q: searchQuery, limit: "50" });
const data = await request<{ results: MessageRecord[] }>(
`/api/analysis/search?${params}`,
);
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,
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 === "analyzed")
return status !== "pending" && status !== null && status !== undefined;
if (aiFilter === "pending")
return status === "pending" || status === null || status === undefined;
return status === aiFilter;
});
}, [messages, searchResults, showSearch, aiFilter]);
return (
<motion.div
className="grid gap-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-primary">Messages</CardTitle>
{guildName && (
<p className="text-sm text-muted-foreground">
Monitoring all text channels in{" "}
<span className="font-medium text-foreground">{guildName}</span>
</p>
)}
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Messages are automatically captured from all text channels in the
monitored guild. Real-time updates arrive via WebSocket.
</p>
</CardContent>
</Card>
</motion.div>
{stats.total > 0 && (
<motion.div
variants={cardItem}
className="flex flex-wrap items-center gap-2"
>
<Badge
variant="outline"
className="text-xs border-primary/40 text-primary"
>
{stats.total} total{hasMore && !showSearch ? "+" : ""}
</Badge>
<Badge
variant="outline"
className="text-xs bg-success-soft text-success border-success/20"
>
{stats.clean} clean
</Badge>
<Badge
variant="outline"
className="text-xs bg-primary/10 text-primary border-primary/20"
>
{stats.flagged} flagged
</Badge>
<Badge
variant="outline"
className="text-xs bg-warning-soft text-warning border-warning/20"
>
{stats.error} error
</Badge>
<Badge
variant="outline"
className="text-xs text-muted-foreground border-border"
>
{stats.pending} pending
</Badge>
{stats.deleted > 0 && (
<Badge
variant="outline"
className="text-xs bg-destructive-soft text-destructive border-destructive/20"
>
{stats.deleted} deleted
</Badge>
)}
{stats.edited > 0 && (
<Badge variant="outline" className="text-xs">
{stats.edited} edited
</Badge>
)}
</motion.div>
)}
<motion.div
variants={cardItem}
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-primary" />
<Input
className="pl-9 rounded-full focus-visible:ring-primary"
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"
className="rounded-xl"
>
{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>
)}
{stats.error > 0 && onReanalyzeAllErrors && (
<Button
variant="destructive"
size="sm"
disabled={retryingAll}
onClick={async () => {
setRetryingAll(true);
setRetriedCount(null);
try {
const count = await onReanalyzeAllErrors();
setRetriedCount(count);
} finally {
setRetryingAll(false);
}
}}
className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20"
>
<RotateCw
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
/>
{retryingAll ? "Retrying..." : `Retry All Errors (${stats.error})`}
</Button>
)}
{retriedCount !== null && (
<span className="text-xs text-success">
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
re-analysis
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<Filter className="h-4 w-4 text-primary" />
{(
[
"all",
"analyzed",
"clean",
"flagged",
"error",
"pending",
] as AiFilter[]
).map((f) => (
<button
key={f}
onClick={() => setAiFilter(f)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
aiFilter === f
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{f}
</button>
))}
</div>
</motion.div>
{showSearch && searchResults.length > 0 && (
<motion.div
variants={cardItem}
className="text-sm text-muted-foreground"
>
Found {searchResults.length} result
{searchResults.length !== 1 ? "s" : ""}
</motion.div>
)}
<motion.div variants={cardItem}>
<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."
: "No captures yet."
}
onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore}
loadingMore={loadingMore}
/>
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={filteredMessages} />
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
);
}
-19
View File
@@ -1,19 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ToastProvider } from "./shared/ui";
import "./styles.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>,
);
-350
View File
@@ -1,350 +0,0 @@
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
import type { MessageRecord, PageResult } from "@bete/shared";
import type {
ChatResponse,
DashboardChannel,
DashboardChannelDetail,
DashboardStats,
DashboardUser,
DashboardUserDetail,
} from "../../entities/dashboard/types.js";
import type {
Channel,
Guild,
GuildVoiceEntry,
} from "../../entities/guild/types.js";
import type {
MediaItem,
MediaMode,
MediaState,
} from "../../entities/media/types.js";
import type {
VoiceRecording,
VoiceRecordingListResponse,
} from "../../entities/recording/types.js";
import type {
AppConfig,
DashboardTab,
UIState,
} from "../../entities/ui/types.js";
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
import { createLogger } from "../lib/logger.js";
const logger = createLogger("api");
const BE_API_URL = import.meta.env.VITE_BE_API_URL || "http://localhost:3001";
const DEFAULT_TIMEOUT_MS = 15000;
class ApiError extends Error {
code: string;
statusCode: number;
constructor(code: string, message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.code = code;
this.statusCode = statusCode;
}
}
// Cache admin password in memory — read from sessionStorage once on first call
let _cachedPassword: string | null = null;
function getAdminPassword(): string | null {
if (_cachedPassword === null) {
try {
_cachedPassword = sessionStorage.getItem("admin-password");
} catch {
_cachedPassword = null;
}
}
return _cachedPassword;
}
function buildSearchParams(
params: Record<string, string | number | undefined | null>,
): URLSearchParams {
const sp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null && value !== "") {
sp.set(key, String(value));
}
}
return sp;
}
export async function request<T>(
path: string,
init?: RequestInit,
timeoutMs?: number,
): Promise<T> {
const password = getAdminPassword();
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
logger.debug("Request", { method: init?.method ?? "GET", url });
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
signal,
...init,
});
if (!res.ok) {
let message = res.statusText;
let code = "REQUEST_FAILED";
try {
const body = (await res.json()) as { error?: string; message?: string };
if (body.message) message = body.message;
if (body.error) code = body.error;
} catch {
// ignore parse errors
}
logger.error("Request failed", { url, status: res.status, code, message });
throw new ApiError(code, message, res.status);
}
const result = (await res.json()) as T;
logger.debug("Response", { url, status: res.status });
return result;
}
export function getAPIURL(): string {
return BE_API_URL;
}
// ─── Re-exports ──────────────────────────────────────────────────────────────
export type {
ActiveSpeaker,
AppConfig,
Channel,
ChatResponse,
DashboardChannel,
DashboardChannelDetail,
DashboardStats,
DashboardTab,
DashboardUser,
DashboardUserDetail,
Guild,
GuildVoiceEntry,
MediaItem,
MediaMode,
MediaState,
MessageRecord,
PageResult,
UIState,
VoiceRecording,
VoiceRecordingListResponse,
VoiceStatus,
};
// ─── Messages ────────────────────────────────────────────────────────────────
export function listMessages(params: {
guildId: string;
channelId?: string;
limit?: number;
cursor?: string;
}): Promise<PageResult<MessageRecord>> {
const sp = buildSearchParams({
guildId: params.guildId,
limit: params.limit ?? 100,
channelId: params.channelId,
cursor: params.cursor,
});
return request<PageResult<MessageRecord>>(`/api/messages?${sp}`);
}
export function listReview(
params: URLSearchParams,
): Promise<PageResult<MessageRecord>> {
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
}
export function reanalyzeMessage(id: string): Promise<void> {
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
}
export function getMessageById(
id: string,
): Promise<MessageRecord | null> {
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
}
export function reanalyzeErrorBatch(opts: {
guildId?: string;
channelId?: string;
messageIds?: string[];
}): Promise<{ ok: boolean; count: number }> {
return request<{ ok: boolean; count: number }>(
"/api/messages/reanalyze-batch",
{ method: "POST", body: JSON.stringify(opts) },
);
}
// ─── Guilds / Config ─────────────────────────────────────────────────────────
export function getGuilds(): Promise<Guild[]> {
return request<Guild[]>("/api/guilds");
}
export function getAppConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config");
}
// ─── Voice ───────────────────────────────────────────────────────────────────
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
}
export function getTextChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
}
export function getVoiceStatus(): Promise<VoiceStatus> {
return request<VoiceStatus>("/api/voice/status");
}
export function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
return request<VoiceStatus>("/api/voice/connect", {
method: "POST",
body: JSON.stringify({ guildId, channelId }),
});
}
export function disconnectVoice(): Promise<VoiceStatus> {
return request<VoiceStatus>("/api/voice/disconnect", { method: "POST" });
}
// ─── Media ───────────────────────────────────────────────────────────────────
export function getMediaStatus(): Promise<MediaState> {
return request<MediaState>("/api/media/status");
}
export function queueMedia(
source: string,
mode: "music" | "screen",
): Promise<MediaState> {
return request<MediaState>("/api/media/queue", {
method: "POST",
body: JSON.stringify({ source, mode }),
});
}
export function skipMedia(): Promise<MediaState> {
return request<MediaState>("/api/media/skip", { method: "POST" });
}
export function stopMedia(): Promise<MediaState> {
return request<MediaState>("/api/media/stop", { method: "POST" });
}
export function setMediaVolume(volume: number): Promise<MediaState> {
return request<MediaState>("/api/media/volume", {
method: "POST",
body: JSON.stringify({ volume }),
});
}
// ─── Recordings ──────────────────────────────────────────────────────────────
export function listRecordings(params?: {
limit?: number;
cursor?: string;
}): Promise<VoiceRecordingListResponse> {
const sp = buildSearchParams({
limit: params?.limit ?? 50,
cursor: params?.cursor,
});
return request<VoiceRecordingListResponse>(`/api/recordings?${sp}`);
}
export function deleteRecording(id: string): Promise<void> {
return request<void>(`/api/recordings/${id}`, { method: "DELETE" });
}
// ─── Auth ────────────────────────────────────────────────────────────────────
export function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
});
}
// ─── Dashboard ─────────────────────────────────────────────────────────────────
export function getDashboardStats(): Promise<DashboardStats> {
return request<DashboardStats>("/api/dashboard/stats");
}
export function listDashboardUsers(
params: { limit?: number; cursor?: string; search?: string } = {},
): Promise<{ data: DashboardUser[]; nextCursor: string | null }> {
const sp = buildSearchParams({
limit: params.limit,
cursor: params.cursor,
search: params.search,
});
return request<{ data: DashboardUser[]; nextCursor: string | null }>(
`/api/dashboard/users?${sp}`,
);
}
export function getDashboardUserDetail(
userId: string,
): Promise<DashboardUserDetail> {
return request<DashboardUserDetail>(`/api/dashboard/users/${userId}`);
}
// ─── Dashboard Channels ─────────────────────────────────────────────────────────
export function listDashboardChannels(
params: {
limit?: number;
search?: string;
guild_id?: string;
cursor?: string;
} = {},
): Promise<{ data: DashboardChannel[]; nextCursor: string | null }> {
const sp = buildSearchParams({
limit: params.limit,
search: params.search,
guild_id: params.guild_id,
cursor: params.cursor,
});
return request<{ data: DashboardChannel[]; nextCursor: string | null }>(
`/api/dashboard/channels?${sp}`,
);
}
export function getDashboardChannelDetail(
channelId: string,
): Promise<DashboardChannelDetail> {
return request<DashboardChannelDetail>(
`/api/dashboard/channels/${channelId}`,
);
}
// ─── UI State ────────────────────────────────────────────────────────────────
export function getUIState(): Promise<UIState> {
return request<UIState>("/api/ui-state");
}
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
return request<UIState>("/api/ui-state", {
method: "POST",
body: JSON.stringify(patch),
});
}
@@ -1,37 +0,0 @@
// ─── Generic async action state hook ──────────────────────────────────────
import { useCallback, useState } from "react";
interface AsyncActionState {
loading: boolean;
error: string | null;
}
export function useAsyncAction() {
const [state, setState] = useState<AsyncActionState>({
loading: false,
error: null,
});
const execute = useCallback(
async <T>(fn: () => Promise<T>): Promise<T | null> => {
setState({ loading: true, error: null });
try {
const result = await fn();
setState({ loading: false, error: null });
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setState({ loading: false, error: message });
return null;
}
},
[],
);
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
}, []);
return { ...state, execute, clearError };
}
@@ -1,235 +0,0 @@
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { createLogger } from "../lib/logger.js";
const logger = createLogger("use-audio-playback");
const SAMPLE_RATE = 24000;
const CHANNELS = 1;
const LEVEL_COUNT = 32;
// Pre-computed level distribution shape — computed once at module load, not per render
const LEVEL_SHAPE = Array.from(
{ length: LEVEL_COUNT },
(_, i) => 0.3 + (Math.sin(i * 0.6) * 0.35 + 0.65) * 0.7,
);
/** Reverse lookup: userIdHash → userId, populated by handleIncomingBinary */
const userIdHashToId = new Map<number, string>();
export function useAudioPlayback(): {
isListening: boolean;
levels: number[];
handleIncomingPcm: (data: { userId: string; pcm: string }) => void;
handleIncomingBinary: (data: ArrayBuffer) => void;
registerUserId: (userId: string) => void;
toggleListening: () => Promise<void>;
audioContextRef: RefObject<AudioContext | null>;
} {
const [isListening, setIsListening] = useState(false);
const [levels, setLevels] = useState<number[]>(
Array.from({ length: LEVEL_COUNT }, () => 0.04),
);
const audioContextRef = useRef<AudioContext | null>(null);
const userTimelinesRef = useRef(new Map<string, number>());
// Cleanup AudioContext on unmount to prevent leak
useEffect(() => {
return () => {
const ctx = audioContextRef.current;
if (ctx) {
ctx.close();
audioContextRef.current = null;
}
userTimelinesRef.current.clear();
};
}, []);
// Prune stale timeline entries (> 30s old) based on current audioContext time
const pruneTimelines = useCallback(() => {
const now =
audioContextRef.current?.currentTime ?? performance.now() / 1000;
for (const [userId, endTime] of userTimelinesRef.current) {
if (endTime + 30 < now) userTimelinesRef.current.delete(userId);
}
}, []);
/**
* Handle incoming binary PCM from WS.
* Format per chunk: 4-byte userId hash (UInt32LE) + raw PCM (Int16).
* userId hash userId mapping is populated by voice_active_user events.
*/
const handleIncomingBinary = useCallback(
(buffer: ArrayBuffer) => {
const view = new DataView(buffer);
if (buffer.byteLength < 5) return; // Need at least 4-byte hash + 1 PCM byte
const userIdHash = view.getUint32(0, true);
const userId = userIdHashToId.get(userIdHash) ?? `user:${userIdHash}`;
const pcmBytes = buffer.byteLength - 4;
if (pcmBytes === 0) return;
const int16Array = new Int16Array(buffer, 4, pcmBytes / 2);
if (int16Array.length === 0) return;
// RMS + level computation (same as before)
let sumSquares = 0;
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
const normalized = int16Array[i] / 32768;
float32Array[i] = normalized;
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / int16Array.length);
const dbLevel = Math.min(1, Math.max(0.04, rms * 8));
setLevels((prev) =>
prev.map((_, index) =>
Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5),
),
);
const audioContext = audioContextRef.current;
if (!isListening || !audioContext) return;
const audioBuffer = audioContext.createBuffer(
CHANNELS,
float32Array.length,
SAMPLE_RATE,
);
audioBuffer.getChannelData(0).set(float32Array);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
const currentTime = audioContext.currentTime;
let nextStart = userTimelinesRef.current.get(userId) || 0;
if (nextStart < currentTime) nextStart = currentTime + 0.05;
source.start(nextStart);
userTimelinesRef.current.set(userId, nextStart + audioBuffer.duration);
pruneTimelines();
},
[isListening, pruneTimelines],
);
/**
* Register a userId hash mapping from voice_active_user events.
*/
const registerUserId = useCallback((userId: string) => {
const hash = fnv1a32(userId);
userIdHashToId.set(hash, userId);
}, []);
// Legacy JSON handler kept for backward compat
const handleIncomingPcm = useCallback(
(data: { userId: string; pcm: string }) => {
// Decode base64 PCM data
try {
const bytes = Uint8Array.from(atob(data.pcm), (c) => c.charCodeAt(0));
if (bytes.length === 0) return;
const int16Array = new Int16Array(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength / 2,
);
// 5b/5d: Real RMS calculation + Float32Array conversion in single pass
let sumSquares = 0;
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
const normalized = int16Array[i] / 32768;
float32Array[i] = normalized;
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / int16Array.length);
// Scale RMS to a lively visualization range, clamp to [0.04, 1.0]
const dbLevel = Math.min(1, Math.max(0.04, rms * 8));
// 5c: Use pre-computed LEVEL_SHAPE (no Date.now() per PCM frame)
setLevels((prev) =>
prev.map((_, index) =>
Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5),
),
);
const audioContext = audioContextRef.current;
if (!isListening || !audioContext) return;
const audioBuffer = audioContext.createBuffer(
CHANNELS,
float32Array.length,
SAMPLE_RATE,
);
audioBuffer.getChannelData(0).set(float32Array);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
// Schedule playback per user to avoid overlaps
const currentTime = audioContext.currentTime;
let nextStart = userTimelinesRef.current.get(data.userId) || 0;
if (nextStart < currentTime) nextStart = currentTime + 0.05;
source.start(nextStart);
userTimelinesRef.current.set(
data.userId,
nextStart + audioBuffer.duration,
);
pruneTimelines();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to decode PCM audio", {
userId: data.userId,
error: message,
});
}
},
[isListening, pruneTimelines],
);
const toggleListening = useCallback(async () => {
if (isListening) {
await audioContextRef.current?.suspend();
userTimelinesRef.current.clear();
setIsListening(false);
logger.info("Audio playback paused");
return;
}
const AudioContextCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
audioContextRef.current ??= new AudioContextCtor({
sampleRate: SAMPLE_RATE,
});
await audioContextRef.current.resume();
setIsListening(true);
logger.info("Audio playback started");
}, [isListening]);
return {
isListening,
levels,
handleIncomingPcm,
handleIncomingBinary,
registerUserId,
toggleListening,
audioContextRef,
};
}
/** 32-bit FNV-1a hash for userId → consistent 4-byte identifier */
function fnv1a32(str: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
@@ -1,207 +0,0 @@
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
import { useCallback, useRef, useState } from "react";
import { getAPIURL } from "../api/client.js";
// note: this hook only uses API functions, not entity types
import { createLogger } from "../lib/logger";
const SAMPLE_RATE = 24000;
const LEVEL_THROTTLE_MS = 50; // 20Hz mic level updates
const logger = createLogger("useAudioTransmit");
async function sendTransmitCommand(command: string): Promise<void> {
// Send via HTTP API (kept as exported function for backward compatibility)
const resp = await fetch(`${getAPIURL()}/api/voice/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
});
if (!resp.ok) {
logger.warn("HTTP command response", {
status: resp.status,
statusText: resp.statusText,
});
const text = await resp.text().catch(() => resp.statusText);
logger.warn("HTTP command failed", { error: text });
throw new Error(`HTTP ${resp.status}: ${text}`);
}
}
function sendWsCommand(
socketRef: { readonly current: WebSocket | null },
command: string,
): boolean {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify({
type: "voice_command",
command,
}),
);
return true;
}
return false;
}
export function useAudioTransmit(socketRef: {
readonly current: WebSocket | null;
}): {
isStreaming: boolean;
micError: string | null;
micLevel: number;
toggle: () => Promise<void>;
stopTransmit: () => void;
startTransmit: () => Promise<void>;
stop: () => void;
start: () => Promise<void>;
} {
const [isStreaming, setIsStreaming] = useState(false);
const [micError, setMicError] = useState<string | null>(null);
const [micLevel, setMicLevel] = useState(0);
const streamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const isTransmittingRef = useRef(false);
const lastLevelUpdateRef = useRef(0);
const stop = useCallback(() => {
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
if (!sendWsCommand(socketRef, "voice:transmit:stop")) {
sendTransmitCommand("voice:transmit:stop").catch(() => {});
}
setMicError(null);
setIsStreaming(false);
isTransmittingRef.current = false;
if (processorRef.current) {
processorRef.current.disconnect();
processorRef.current = null;
}
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
streamRef.current = null;
}
setMicLevel(0);
}, [socketRef]);
const start = useCallback(async () => {
// Reset mic error on new attempt
setMicError(null);
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
await sendTransmitCommand("voice:transmit:start");
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
if (err instanceof DOMException && err.name === "NotAllowedError") {
setMicError(
"Microphone access denied. Please allow microphone permissions.",
);
} else {
const message = err instanceof Error ? err.message : String(err);
setMicError(`Microphone access failed: ${message}`);
}
logger.error("getUserMedia failed", { error: String(err) });
return;
}
streamRef.current = stream;
setIsStreaming(true);
isTransmittingRef.current = true;
const AudioContextCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
sourceRef.current = source;
const processor = audioContext.createScriptProcessor(1024, 1, 1);
processorRef.current = processor;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (event) => {
const inputData = event.inputBuffer.getChannelData(0);
// Compute RMS from input buffer for mic level metering
let sumSquares = 0;
for (let i = 0; i < inputData.length; i++) {
sumSquares += inputData[i] * inputData[i];
}
const rms = Math.sqrt(sumSquares / inputData.length);
const now = Date.now();
if (now - lastLevelUpdateRef.current >= LEVEL_THROTTLE_MS) {
lastLevelUpdateRef.current = now;
// Scale so conversational speech hits ~0.3-0.6
setMicLevel(Math.min(1, rms * 3));
}
if (!isTransmittingRef.current) return;
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN)
return;
const pcmData = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++)
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
// Send as binary: 4-byte magic "PCM\0" + raw PCM Int16
const magic = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0"
const pcmBytes = new Uint8Array(pcmData.buffer);
const buf = new Uint8Array(magic.length + pcmBytes.length);
buf.set(magic, 0);
buf.set(pcmBytes, magic.length);
socketRef.current.send(buf.buffer);
};
}, [socketRef]);
const stopTransmit = useCallback(() => {
if (!isTransmittingRef.current) return;
isTransmittingRef.current = false;
if (!sendWsCommand(socketRef, "voice:transmit:stop")) {
sendTransmitCommand("voice:transmit:stop").catch(() => {});
}
setIsStreaming(false);
}, [socketRef]);
const startTransmit = useCallback(async () => {
if (isTransmittingRef.current) return;
isTransmittingRef.current = true;
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
await sendTransmitCommand("voice:transmit:start");
}
setIsStreaming(true);
}, [socketRef]);
const toggle = useCallback(async () => {
if (isStreaming) {
stopTransmit();
} else if (streamRef.current) {
// Mic already captured, resume transmission without re-acquiring
await startTransmit();
} else {
await start();
}
}, [isStreaming, startTransmit, stopTransmit, start]);
return {
isStreaming,
micError,
micLevel,
toggle,
stopTransmit,
startTransmit,
stop,
start,
};
}
@@ -1,73 +0,0 @@
import { type Variants } from "framer-motion";
/**
* Parent container variant for staggerChildren.
* Use on motion.div wrapping a list of cardItem children.
*/
export const cardStagger: Variants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
};
/**
* Child item variant for fade + slide up.
* Intended as a child of cardStagger.
*/
export const cardItem: Variants = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
/**
* Single element variant: fade + slide up with a cubic-bezier ease.
* Supports exit animation (fade out + slide up).
*/
export const fadeSlideUp: Variants = {
initial: { opacity: 0, y: 24 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] },
},
exit: { opacity: 0, y: -12, transition: { duration: 0.2 } },
};
/**
* Simple fade variant for generic element transitions.
*/
export const fadeIn: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.3 } },
exit: { opacity: 0, transition: { duration: 0.2 } },
};
/**
* Scale + fade variant for badges, pills, or small decorative elements.
*/
export const scaleIn: Variants = {
initial: { opacity: 0, scale: 0.8 },
animate: {
opacity: 1,
scale: 1,
transition: { duration: 0.3, ease: "backOut" },
},
};
/**
* Spring-based entrance for important elements.
*/
export const springUp: Variants = {
initial: { opacity: 0, y: 30 },
animate: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 200, damping: 20 },
},
};
@@ -1,58 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Generic hook for fetching a single item by ID.
* Automatically refetches when guildId or entityId changes.
*/
export function useItemDetail<T>(
fetchFn: (guildId: string, entityId: string) => Promise<T>,
guildId: string,
entityId: string | null,
entityName = "item",
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const guildIdRef = useRef(guildId);
guildIdRef.current = guildId;
const fetchIdRef = useRef(0);
const fetch = useCallback(async () => {
if (!entityId) return;
const id = ++fetchIdRef.current;
setLoading(true);
setError(null);
try {
const result = await fetchFnRef.current(guildIdRef.current, entityId);
if (id !== fetchIdRef.current) return;
if (!result) {
setError(`${entityName} not found`);
return;
}
setData(result);
} catch (e) {
if (id !== fetchIdRef.current) return;
const msg =
e instanceof Error ? e.message : `Failed to load ${entityName}`;
setError(msg);
} finally {
if (id === fetchIdRef.current) {
setLoading(false);
}
}
}, [entityId, entityName]);
useEffect(() => {
fetch().catch(() => undefined);
}, [fetch]);
const refetch = useCallback(() => {
fetch().catch(() => undefined);
}, [fetch]);
return { data, loading, error, refetch };
}
@@ -1,61 +0,0 @@
// ─── Validated localStorage hook with shape checking ────────────────────────
import { useCallback, useState } from "react";
interface ShapeValidator<T> {
/** Returns true if the parsed value matches the expected shape */
validate: (value: unknown) => value is T;
/** Default value when storage is empty or invalid */
defaults: T;
}
export function useLocalStorage<T>(key: string, validator: ShapeValidator<T>) {
const [value, setValue] = useState<T>(() => loadStored(key, validator));
const update = useCallback(
(patch: T | ((prev: T) => T)) => {
setValue((prev) => {
const next =
typeof patch === "function" ? (patch as (prev: T) => T)(prev) : patch;
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
// ignore quota errors
}
return next;
});
},
[key],
);
return { value, setValue: update };
}
function loadStored<T>(key: string, validator: ShapeValidator<T>): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return validator.defaults;
const parsed = JSON.parse(raw) as unknown;
if (validator.validate(parsed)) return parsed;
return validator.defaults;
} catch {
return validator.defaults;
}
}
// ─── Pre-built validators for common shapes ─────────────────────────────────
export function recordValidator(): ShapeValidator<Record<string, unknown>> {
return {
validate: (v): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v),
defaults: {},
};
}
export function uiStateValidator(): ShapeValidator<Record<string, unknown>> {
return {
validate: (v): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v),
defaults: { activeTab: "messages" },
};
}
@@ -1,62 +0,0 @@
import { useCallback, useState } from "react";
import type { ChatResponse } from "../../entities/dashboard/types.js";
import { request } from "../api/client";
import { createLogger } from "../lib/logger";
const logger = createLogger("useMascotChat");
export interface ChatContext {
messageCount: number;
activeParticipants: number;
lastActivity: string;
topicsDiscussed: string[];
guildId?: string;
channelId?: string;
}
export function useMascotChat(context?: ChatContext) {
const [isOpen, setIsOpen] = useState(false);
const handleSendMessage = useCallback(
async (message: string): Promise<string> => {
try {
const data = await request<ChatResponse>("/api/chat", {
method: "POST",
body: JSON.stringify({ message, context }),
});
return data.response || fallbackResponse(message, context);
} catch (error) {
logger.warn("Mascot backend unavailable, using fallback", { error });
return fallbackResponse(message, context);
}
},
[context],
);
return {
isOpen,
setIsOpen,
handleSendMessage,
};
}
function fallbackResponse(input: string, context?: ChatContext): string {
const lower = input.toLowerCase();
if (lower.includes("ringkasan") || lower.includes("summary")) {
return `Aku rangkum cepat ya ✨ Ada ${context?.messageCount || 0} pesan dari ${context?.activeParticipants || 0} user aktif. Backend belum bisa dihubungi, jadi ini ringkasan lokal sementara.`;
}
if (lower.includes("berapa") && lower.includes("pesan")) {
return `Ada ${context?.messageCount || 0} pesan di konteks dashboard saat ini 📊`;
}
if (
lower.includes("berapa") &&
(lower.includes("orang") || lower.includes("user"))
) {
return `Ada ${context?.activeParticipants || 0} user aktif yang terdeteksi 👥`;
}
return `Aku belum bisa menghubungi backend, tapi dari konteks lokal ada ${context?.messageCount || 0} pesan dan ${context?.activeParticipants || 0} user aktif. Coba tanya "ringkasan obrolan" atau "berapa pesan" ya.`;
}
@@ -1,105 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
}
export interface UsePaginatedListParams {
limit: number;
search?: string;
cursor?: string;
guildId?: string;
}
/**
* Generic hook for fetching a paginated list with cursor-based pagination,
* search support, and automatic refetch when search/guildId changes.
*/
export function usePaginatedList<T>(
fetchFn: (params: UsePaginatedListParams) => Promise<PaginatedResult<T>>,
guildId = "",
initialSearch = "",
) {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [search, setSearch] = useState(initialSearch);
// Stable refs so fetch() can be a stable callback that reads latest values
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const guildIdRef = useRef(guildId);
guildIdRef.current = guildId;
const searchRef = useRef(search);
searchRef.current = search;
const fetchIdRef = useRef(0);
const fetch = useCallback(async (cursor?: string) => {
const id = ++fetchIdRef.current;
if (cursor) {
setLoadingMore(true);
} else {
setLoading(true);
setData([]);
}
setError(null);
try {
const result = await fetchFnRef.current({
limit: 20,
search: searchRef.current || undefined,
cursor,
guildId: guildIdRef.current || undefined,
});
if (id !== fetchIdRef.current) return;
if (cursor) {
setData((prev) => [...prev, ...result.data]);
} else {
setData(result.data);
}
setNextCursor(result.nextCursor);
} catch (e) {
if (id !== fetchIdRef.current) return;
const msg = e instanceof Error ? e.message : "Failed to load data";
setError(msg);
} finally {
if (id === fetchIdRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
}, []);
// Re-fetch on mount, search change, or guildId change
useEffect(() => {
fetch().catch(() => undefined);
}, [search, guildId, fetch]);
const loadMore = useCallback(() => {
if (nextCursor && !loading && !loadingMore) {
fetch(nextCursor).catch(() => undefined);
}
}, [nextCursor, loading, loadingMore, fetch]);
const refetch = useCallback(() => {
fetch().catch(() => undefined);
}, [fetch]);
return {
data,
loading,
loadingMore,
error,
nextCursor,
search,
setSearch,
hasMore: !!nextCursor,
loadMore,
refetch,
};
}
@@ -1,39 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'imphnen-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(resolved: 'light' | 'dark') {
const root = document.documentElement;
const transitioning = root.classList.contains('theme-transitioning');
if (!transitioning) root.classList.add('theme-transitioning');
root.dataset.theme = resolved;
if (!transitioning) {
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-transitioning')));
}
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
return 'system';
});
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
const setTheme = useCallback((t: Theme) => { setThemeState(t); localStorage.setItem(STORAGE_KEY, t); }, []);
const toggle = useCallback(() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), [resolvedTheme, setTheme]);
useEffect(() => { applyTheme(resolvedTheme); }, [resolvedTheme]);
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme(getSystemTheme());
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
return { theme, setTheme, resolvedTheme, toggle };
}
@@ -1,19 +0,0 @@
import { useCallback } from "react";
import type { UIState } from "../../entities/ui/types.js";
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
export function useUIState() {
const { value: uiState, setValue: setUIState } = useLocalStorage<UIState>(
"bete-dashboard-ui-state",
uiStateValidator(),
);
const patchUIState = useCallback(
(patch: Partial<UIState>) => {
setUIState((prev) => ({ ...prev, ...patch }));
},
[setUIState],
);
return { uiState, setUIState, patchUIState, loading: false, error: null };
}
@@ -1,22 +0,0 @@
// ─── Client-side structured logger ────────────────────────────────────────
const LOG_PREFIX = "[Bete]";
export function createLogger(context: string) {
const prefix = `${LOG_PREFIX} [${context}]`;
return {
debug: (msg: string, data?: Record<string, unknown>) => {
if (import.meta.env.DEV) console.debug(prefix, msg, data ?? "");
},
info: (msg: string, data?: Record<string, unknown>) => {
console.info(prefix, msg, data ?? "");
},
warn: (msg: string, data?: Record<string, unknown>) => {
console.warn(prefix, msg, data ?? "");
},
error: (msg: string, data?: Record<string, unknown>) => {
console.error(prefix, msg, data ?? "");
},
};
}
-50
View File
@@ -1,50 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
}
export function formatDate(value: number): string {
return new Date(value).toLocaleString();
}
export interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
channel?: {
channelId: string;
channelName?: string;
threadId?: string;
threadName?: string;
};
reference?: {
messageId: string | null;
channelId: string | null;
guildId: string | null;
type: string | null;
content: string | null;
repliedUsername: string | null;
repliedUserId: string | null;
} | null;
isCrosspost?: boolean;
}
export function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
const parsed = JSON.parse(value) as MessageMetadata;
return parsed;
} catch {
return {};
}
}
@@ -1,50 +0,0 @@
import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../../entities/ui/types.js";
import { cn } from "../lib/utils";
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
{ id: "messages", label: "Messages", Icon: MessageSquare },
{ id: "live", label: "Voice & Media", Icon: Radio },
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
];
interface MobileTabBarProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
}
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
return (
<nav
aria-label="Main navigation"
role="tablist"
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-white/80 backdrop-blur-lg pb-4 shadow-lg md:hidden"
>
{tabs.map(({ id, label, Icon }) => (
<button
key={id}
role="tab"
aria-selected={activeTab === id}
aria-controls={`tabpanel-${id}`}
type="button"
onClick={() => onTabChange(id)}
className={cn(
"relative flex flex-1 flex-col items-center gap-0.5 py-2 pt-3 text-xs font-medium transition-colors",
activeTab === id ? "text-[#23a1eb]" : "text-muted-foreground",
)}
>
{activeTab === id && (
<motion.div
layoutId="mobile-tab-dot"
className="absolute top-0 h-1 w-6 rounded-full bg-[#23a1eb]"
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
)}
<Icon className="h-5 w-5" />
<span className="text-[10px]">{label}</span>
</button>
))}
</nav>
);
}
-54
View File
@@ -1,54 +0,0 @@
/*
* IMPHNEN Badge Pill untuk status, kategori, dan label micro-interaction
* rounded-full (9999px), padding 4px 12px, font label-sm (12px, 500 weight)
* */
import type * as React from "react";
import { cn } from "../lib/utils";
type BadgeVariant =
| "default" /* Primary soft — #e1f0fd bg, #0d4a7a text */
| "primary" /* Same as default, explicit alias */
| "secondary" /* #e7f1ff bg, #003d99 text */
| "tertiary" /* #eef0ff bg, #1a2466 text */
| "destructive" /* #ffebee bg, #e4405f text */
| "outline" /* Border only, no fill */
| "success" /* #dcfce7 bg, green text */
| "warning" /* #fef3c7 bg, amber text */
| "info"; /* #dbeafe bg, blue text */
const variants: Record<BadgeVariant, string> = {
default: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
primary: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
secondary: "bg-[#e7f1ff] text-[#003d99] border-transparent",
tertiary: "bg-[#eef0ff] text-[#1a2466] border-transparent",
destructive: "bg-[#ffebee] text-[#e4405f] border-transparent",
outline: "bg-transparent text-[#666666] border-[#e0e0e0]",
success: "bg-success-soft text-success border-transparent",
warning: "bg-warning-soft text-warning border-transparent",
info: "bg-[#dbeafe] text-[#1e40af] border-transparent",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: BadgeVariant;
}
export function Badge({
className,
variant = "default",
...props
}: BadgeProps) {
return (
<div
role="status"
className={cn(
"inline-flex items-center rounded-full border px-3 py-1",
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
"transition-colors duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
variants[variant],
className,
)}
{...props}
/>
);
}
-100
View File
@@ -1,100 +0,0 @@
/*
* IMPHNEN Button Friendly, percaya diri, responsif.
* Primary: #23a1eb #1a8fd9 #0877c1
* Secondary: transparan dengan 1px border, fill subtle di hover
* */
import { Slot } from "@radix-ui/react-slot";
import type * as React from "react";
import { cn } from "../lib/utils";
type ButtonVariant =
| "default" /* Primary IMPHNEN blue */
| "secondary" /* Outline with subtle fill */
| "tertiary" /* Discord-style blurple */
| "destructive"/* Red semantic */
| "outline" /* Light border, no fill */
| "ghost" /* No border, fill on hover */
| "link"; /* Text-only */
type ButtonSize = "default" | "sm" | "lg" | "icon" | "icon-sm";
const variants: Record<ButtonVariant, string> = {
default:
"bg-[#23a1eb] text-white shadow-sm " +
"hover:bg-[#1a8fd9] " +
"active:bg-[#0877c1] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
secondary:
"bg-transparent text-[#23a1eb] border border-[#e0e0e0] " +
"hover:bg-[#f0f0f0] hover:border-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
destructive:
"bg-[#e4405f] text-white shadow-sm " +
"hover:bg-[#d63856] " +
"active:bg-[#c2304d] " +
"focus-visible:ring-2 focus-visible:ring-[#e4405f]/40",
outline:
"bg-transparent text-[#1a1a1a] border border-[#e0e0e0] " +
"hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
ghost:
"bg-transparent text-[#1a1a1a] " +
"hover:bg-[#f0f0f0] hover:text-[#23a1eb] " +
"active:bg-[#e1f0fd] " +
"focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
link:
"bg-transparent text-[#23a1eb] underline-offset-4 " +
"hover:underline " +
"active:text-[#0877c1]",
tertiary:
"bg-[#5865f2] text-white shadow-sm " +
"hover:bg-[#5865f2]/90 " +
"focus-visible:ring-2 focus-visible:ring-[#5865f2]/40",
};
const sizes: Record<ButtonSize, string> = {
default: "h-11 px-6 py-3", /* 44px height, 24px horizontal */
sm: "h-9 rounded-lg px-3 py-2", /* 36px compact */
lg: "h-12 rounded-lg px-8 py-3", /* 48px spacious */
icon: "h-11 w-11", /* Square 44x44 */
'icon-sm': 'h-8 w-8', /* Square 32x32 */
};
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
}
export function Button({
className,
variant = "default",
size = "default",
asChild = false,
disabled,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
aria-disabled={disabled || undefined}
className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap",
"font-sans font-semibold text-sm leading-5 tracking-[0.02em]",
"rounded-lg", /* 1rem / 16px — Friendly Geometry */
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
"focus-visible:outline-none focus-visible:ring-offset-2",
"active:scale-[0.97]",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
disabled={!asChild ? disabled : undefined}
{...props}
/>
);
}
-90
View File
@@ -1,90 +0,0 @@
/*
* IMPHNEN Card Primary content container
* rounded-xl (1.5rem), border subtle, shadow-sm default shadow-md hover
* */
import type * as React from "react";
import { cn } from "../lib/utils";
type CardVariant = 'default' | 'elevated' | 'bordered';
const variantClasses: Record<CardVariant, string> = {
default: 'shadow-sm hover:shadow-md',
elevated: 'shadow-md hover:shadow-lg',
bordered: 'shadow-none border-2',
};
export function Card({
className,
variant = 'default',
...props
}: React.HTMLAttributes<HTMLDivElement> & { variant?: CardVariant }) {
return (
<div
role="region"
className={cn(
"rounded-xl border border-[#e0e0e0] bg-white text-[#1a1a1a]",
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
"hover:border-[#23a1eb]",
variantClasses[variant],
className,
)}
{...props}
/>
);
}
export function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6 pb-4", className)}
{...props}
/>
);
}
export function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn(
"font-sans font-semibold text-lg leading-none tracking-tight text-[#1a1a1a]",
className,
)}
{...props}
/>
);
}
export function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("font-sans text-sm text-[#666666]", className)}
{...props}
/>
);
}
export function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
export function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
}
@@ -1,54 +0,0 @@
import { motion } from 'framer-motion';
import type { LucideIcon } from 'lucide-react';
import { Inbox } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '../lib/utils';
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
action?: ReactNode;
className?: string;
compact?: boolean;
}
const fadeSlideUp = {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] as const },
},
};
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
className,
compact = false,
}: EmptyStateProps) {
return (
<motion.div
variants={fadeSlideUp}
initial="initial"
animate="animate"
className={cn(
'flex flex-col items-center justify-center text-center',
compact ? 'py-8 gap-3' : 'py-16 gap-4',
className,
)}
>
<div className={cn('rounded-full bg-primary-soft p-3', compact ? 'p-2' : 'p-4')}>
<Icon className={cn('text-primary', compact ? 'h-5 w-5' : 'h-8 w-8')} />
</div>
{title && <h3 className="text-lg font-semibold text-[#1a1a1a]">{title}</h3>}
{description && (
<p className="text-sm text-[#666666] max-w-sm">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</motion.div>
);
}
-28
View File
@@ -1,28 +0,0 @@
// ─── Shared UI barrel export ────────────────────────────────────────────────
export { Badge } from "./badge";
export { Button } from "./button";
export { EmptyState } from "./empty-state";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card";
export { Input } from "./input";
export type {
ProfileDetailMessage,
ProfileDetailStats,
} from "./profile-detail";
export { ProfileDetail } from "./profile-detail";
export { ScrollArea } from "./scroll-area";
export { Select } from "./select";
export { Skeleton } from "./skeleton";
export type { StatusType } from "./status-badge";
export { StatusBadge } from "./status-badge";
export type { SummaryItem } from "./summary-list";
export { SummaryList } from "./summary-list";
export { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
export { ToastProvider, useToast } from "./toast";
-52
View File
@@ -1,52 +0,0 @@
/*
* IMPHNEN Input Clean, approachable, dengan focus glow signature
* rounded DEFAULT (0.5rem), bg #f0f0f0, border #e0e0e0
* Focus: border #23a1eb + 3px glow
* */
import type * as React from "react";
import { cn } from "../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
errorId?: string;
}
type InputVariant = 'default' | 'soft';
const variantClasses: Record<InputVariant, string> = {
default: 'border border-[#e0e0e0] bg-white',
soft: 'border-transparent bg-[#f5f5f5] focus-visible:border-[#23a1eb]',
};
export function Input({ className, type, errorId, variant = 'default', ...props }: InputProps & { variant?: InputVariant }) {
return (
<input
type={type}
aria-describedby={errorId}
className={cn(
/* Layout & sizing */
"flex h-10 w-full rounded-lg px-3 py-2",
/* Typography — Poppins body-md */
"font-sans text-sm text-[#1a1a1a]",
/* Visual — IMPHNEN input surface */
variantClasses[variant],
/* Placeholder */
"placeholder:text-[#999999]",
/* File input overrides */
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
/* Focus — signature IMPHNEN glow */
"focus-visible:outline-none",
"focus-visible:border-[#23a1eb]",
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
/* Disabled */
"disabled:cursor-not-allowed disabled:opacity-50",
/* Error state */
props["aria-invalid"] === "true" &&
"border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
className,
)}
{...props}
/>
);
}
@@ -1,206 +0,0 @@
import { ArrowLeft, RefreshCw } from "lucide-react";
import { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { Card, CardContent } from "./card";
import { Skeleton } from "./skeleton";
import { StatusBadge, type StatusType } from "./status-badge";
export interface ProfileDetailStats {
totalLabel: string;
totalValue: number;
cleanLabel: string;
cleanValue: number;
flaggedLabel: string;
flaggedValue: number;
}
export interface ProfileDetailMessage {
id: string;
content: string | null;
created_at: string | null;
ai_status: string | null;
}
interface ProfileDetailProps {
loading: boolean;
error: string | null;
onRetry: () => void;
onBack: () => void;
icon: ReactNode;
title: string;
subtitle?: string;
summaryLabel: string;
summaryText?: string;
lastAnalyzedLabel?: string;
stats: ProfileDetailStats;
messages: ProfileDetailMessage[];
messagesTitle?: string;
messagesEmptyText?: string;
className?: string;
}
function DetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-6 w-24" />
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-4 w-16 mb-2" />
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
export function ProfileDetail({
loading,
error,
onRetry,
onBack,
icon,
title,
subtitle,
summaryLabel,
summaryText,
lastAnalyzedLabel,
stats,
messages,
messagesTitle = "Recent Messages",
messagesEmptyText = "No messages found",
className,
}: ProfileDetailProps) {
if (loading) return <DetailSkeleton />;
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<p className="text-sm">{error}</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<RefreshCw className="h-4 w-4" /> Retry
</button>
</div>
);
}
return (
<div className={cn("space-y-6", className)}>
{/* Back button */}
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" /> Back
</button>
{/* Header */}
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon}
</div>
<div className="min-w-0">
<h2 className="text-xl font-semibold truncate">{title}</h2>
{subtitle && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{summaryLabel}: {summaryText ?? "N/A"}
</p>
{lastAnalyzedLabel && (
<p className="text-xs text-muted-foreground">
{lastAnalyzedLabel}
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
{stats.totalLabel}
</p>
<p className="text-2xl font-bold tabular-nums">
{stats.totalValue}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
{stats.cleanLabel}
</p>
<p className="text-2xl font-bold tabular-nums text-emerald-600">
{stats.cleanValue}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
{stats.flaggedLabel}
</p>
<p className="text-2xl font-bold tabular-nums text-red-600">
{stats.flaggedValue}
</p>
</CardContent>
</Card>
</div>
{/* Recent Messages */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
{messagesTitle}
</h3>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
{messagesEmptyText}
</p>
) : (
<div className="space-y-2">
{messages.map((msg) => (
<Card key={msg.id}>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm line-clamp-2 flex-1">
{msg.content ?? "(no content)"}
</p>
<StatusBadge status={msg.ai_status} />
</div>
{msg.created_at && (
<p className="text-xs text-muted-foreground mt-1">
{new Date(msg.created_at).toLocaleString()}
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
@@ -1,51 +0,0 @@
/*
* IMPHNEN ScrollArea Radix-based, scrollbar dengan primary accent
* */
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "../lib/utils";
export function ScrollArea({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentPropsWithoutRef<
typeof ScrollAreaPrimitive.ScrollAreaScrollbar
>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors duration-[150ms]",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#23a1eb]/20 hover:bg-[#23a1eb]/40 transition-colors" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
@@ -1,53 +0,0 @@
/*
* IMPHNEN Select Native select dengan styling IMPHNEN
* */
import type * as React from "react";
import { cn } from "../lib/utils";
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[];
placeholder?: string;
}
export function Select({
className,
options,
placeholder,
...props
}: SelectProps) {
return (
<select
className={cn(
"flex h-10 w-full rounded-lg px-3 py-2",
"font-sans text-sm text-[#1a1a1a]",
"bg-[#f5f5f5] border border-[#e0e0e0]",
"focus-visible:outline-none",
"focus-visible:border-[#23a1eb]",
"focus-visible:shadow-[0_0_0_3px_rgba(35,161,235,0.1)]",
"disabled:cursor-not-allowed disabled:opacity-50",
props["aria-invalid"] === "true" &&
"border-[#e4405f] shadow-[0_0_0_3px_rgba(228,64,95,0.1)]",
className,
)}
{...props}
>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
@@ -1,34 +0,0 @@
/*
* IMPHNEN Skeleton Loading state yang subtle & smooth
* */
import type { HTMLAttributes } from "react";
import { cn } from "../lib/utils";
type SkeletonVariant = "rounded" | "circular" | "rectangular";
const variantClasses: Record<SkeletonVariant, string> = {
rounded: "rounded-lg",
circular: "rounded-full",
rectangular: "rounded-none",
};
export function Skeleton({
variant = "rounded",
className,
...props
}: HTMLAttributes<HTMLDivElement> & { variant?: SkeletonVariant }) {
return (
<div
aria-hidden="true"
role="presentation"
className={cn(
"bg-[#f0f0f0]",
"animate-shimmer",
variantClasses[variant],
className,
)}
{...props}
/>
);
}
@@ -1,52 +0,0 @@
/*
* IMPHNEN StatusBadge Untuk AI status moderation (flagged/clean/error/dll)
* */
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
export type StatusType =
| "flagged"
| "clean"
| "warn"
| "pending"
| "processing"
| "error"
| "deleted"
| "none";
const statusStyles: Record<StatusType, string> = {
flagged: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
clean: "bg-[#dcfce7] text-[#166534] border-[#bbf7d0]",
warn: "bg-[#fef3c7] text-[#92400e] border-[#fde68a]",
pending: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
processing: "bg-[#e1f0fd] text-[#0d4a7a] border-[#bce1fb]",
error: "bg-[#ffebee] text-[#e4405f] border-[#ffcdd2]",
deleted: "bg-[#f0f0f0] text-[#999999] border-[#e0e0e0] line-through",
none: "bg-[#f5f5f5] text-[#666666] border-[#e0e0e0]",
};
interface StatusBadgeProps {
status: StatusType | string | null;
className?: string;
children?: ReactNode;
}
export function StatusBadge({ status, className, children }: StatusBadgeProps) {
const key = (status?.toLowerCase() ?? "none") as StatusType;
const style = statusStyles[key] ?? statusStyles.none;
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5",
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
style,
className,
)}
>
{children}
{children ? " " : null}
{status ?? "unknown"}
</span>
);
}
@@ -1,153 +0,0 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { Card, CardContent } from "./card";
import { Input } from "./input";
import { Skeleton } from "./skeleton";
export interface SummaryItem {
id: string;
label: string;
subtitle?: string;
summaryText: string;
summaryValue?: number;
onClick: () => void;
}
interface SummaryListProps<T extends SummaryItem> {
items: T[];
loading: boolean;
error: string | null;
searchValue: string;
onSearchChange: (value: string) => void;
onRetry: () => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
renderIcon: (item: T) => ReactNode;
emptyMessage?: string;
className?: string;
}
export function SummaryList<T extends SummaryItem>({
items,
loading,
error,
searchValue,
onSearchChange,
onRetry,
hasMore,
onLoadMore,
loadingMore,
renderIcon,
emptyMessage = "No items found",
className,
}: SummaryListProps<T>) {
return (
<div className={cn("space-y-4", className)}>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 rounded-full"
/>
</div>
{/* Error */}
{error && (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<p className="text-sm">{error}</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<RefreshCw className="h-4 w-4" /> Retry
</button>
</div>
)}
{/* Loading skeleton — only on initial load */}
{loading && items.length === 0 && !error && (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Empty */}
{!loading && !error && items.length === 0 && (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<p className="text-sm">{emptyMessage}</p>
</div>
)}
{/* Items */}
{!error && items.length > 0 && (
<>
<div className="grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<button
key={item.id}
onClick={item.onClick}
className="text-left w-full"
aria-label={item.label}
>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0 text-muted-foreground">
{renderIcon(item)}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">
{item.label}
</h3>
{item.subtitle && (
<p className="text-xs text-muted-foreground truncate">
{item.subtitle}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{item.summaryText}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0 mt-1" />
</div>
</CardContent>
</Card>
</button>
))}
</div>
{/* Load More */}
{hasMore && (
<div className="flex justify-center pt-2">
<button
onClick={onLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-xl border border-border px-6 py-2 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
>
{loadingMore && <Loader2 className="h-4 w-4 animate-spin" />}
Load More
</button>
</div>
)}
</>
)}
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
/*
* IMPHNEN Tabs Radix-based, style sesuai Approachable Modernism
* */
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
"inline-flex h-10 items-center justify-center",
"rounded-lg bg-[#f5f5f5] p-1",
"text-[#666666]",
className,
)}
{...props}
/>
);
}
export function TabsTrigger({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap",
"rounded-lg px-3 py-1.5",
"font-sans text-sm font-medium",
"transition-all duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-white data-[state=active]:text-[#1a1a1a] data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function TabsContent({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
className,
)}
{...props}
/>
);
}
-141
View File
@@ -1,141 +0,0 @@
/*
* IMPHNEN Toast Notifikasi ringan dengan IMPHNEN brand accent
* */
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Info,
X,
} from "lucide-react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "../lib/utils";
interface Toast {
id: string;
message: string;
type: "info" | "success" | "error" | "warning";
}
interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type?: Toast["type"]) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextType>({
toasts: [],
addToast: () => {},
removeToast: () => {},
});
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
const addToast = useCallback(
(message: string, type: Toast["type"] = "info") => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
setToasts((prev) => [...prev, { id, message, type }]);
const timer = setTimeout(() => {
removeToast(id);
}, 4000);
timersRef.current.set(id, timer);
},
[removeToast],
);
useEffect(() => {
const current = timersRef.current;
return () => {
for (const timer of current.values()) {
clearTimeout(timer);
}
current.clear();
};
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
}
export function useToast() {
return useContext(ToastContext);
}
const typeStyles: Record<Toast["type"], string> = {
info: "border-l-info bg-white text-info",
success: "border-l-success bg-white text-success",
error: "border-l-destructive bg-white text-destructive",
warning: "border-l-warning bg-white text-warning",
};
const typeIcons: Record<Toast["type"], React.ReactNode> = {
info: <Info className="h-4 w-4 text-info" />,
success: <CheckCircle2 className="h-4 w-4 text-success" />,
error: <AlertCircle className="h-4 w-4 text-destructive" />,
warning: <AlertTriangle className="h-4 w-4 text-warning" />,
};
function ToastContainer() {
const { toasts, removeToast } = useContext(ToastContext);
if (toasts.length === 0) return null;
return (
<div
role="alert"
aria-live="polite"
className="fixed top-4 right-4 z-40 flex flex-col gap-2"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="button"
tabIndex={0}
className={cn(
"group flex items-center gap-2.5 rounded-xl border border-[#e0e0e0] px-4 py-3 text-sm shadow-[0_4px_12px_rgba(0,0,0,0.08)] cursor-pointer transition-all duration-200 hover:scale-[1.02] border-l-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/40",
typeStyles[toast.type],
)}
onClick={() => removeToast(toast.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") {
removeToast(toast.id);
}
}}
>
<span className="flex-shrink-0">{typeIcons[toast.type]}</span>
<span className="flex-1 font-sans text-sm">{toast.message}</span>
<X
aria-label="Close notification"
className="h-3.5 w-3.5 flex-shrink-0 text-[#999999] md:opacity-0 md:group-hover:opacity-100 transition-opacity"
/>
</div>
))}
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
// ─── Typed event map for WebSocket events ────────────────────────────────────
//
// Types correspond to the data field sent by the backend's broadcastEvent().
// The backend unwraps the DiscordGatewayEvent envelope and forwards only
// the inner `data` field to frontend WebSocket clients.
import type {
AnalysisQueueStatus,
AttachmentRecord,
MessageRecord,
VoiceRecordingUploadData,
} from "@bete/shared";
export interface ActiveSpeakerData {
userId: string;
username: string;
avatar: string;
speaking: boolean;
}
export interface WsEventMap {
message_created: { data: MessageRecord };
message_updated: { data: MessageRecord & { edited_content?: string | null } };
message_deleted: {
data: { id: string; channel_id?: string; deleted_at: number };
};
message_analyzed: { data: MessageRecord };
attachment_created: { data: AttachmentRecord };
attachment_uploaded: { data: AttachmentRecord };
voice_recording_started: { data: Record<string, unknown> };
voice_recording_stopped: {
data: {
guild_id: string;
session_id: string;
duration_ms: number;
participants: number;
segment_count: number;
status: string;
stopped_at: number;
};
};
voice_recording_uploaded: { data: VoiceRecordingUploadData };
voice_pcm_data: {
data: { userId: string; pcm: string; metadata?: Record<string, unknown> };
};
voice_analyzed: { data: Record<string, unknown> };
voice_active_user: { data: ActiveSpeakerData };
user_state: { users: unknown[] };
ui_state: { state: Record<string, unknown> };
media_state: { state: Record<string, unknown> };
analysis_queue_status: { data: AnalysisQueueStatus };
reaction_added: { data: Record<string, unknown> };
reaction_removed: { data: Record<string, unknown> };
thread_created: { data: Record<string, unknown> };
thread_deleted: { data: Record<string, unknown> };
thread_updated: { data: Record<string, unknown> };
channel_topic_updated: { data: Record<string, unknown> };
presence_updated: { data: Record<string, unknown> };
guild_member_added: { data: Record<string, unknown> };
guild_member_removed: { data: Record<string, unknown> };
heartbeat: { data?: { timestamp: number } };
}
export type WsEventType = keyof WsEventMap;
export function parseWsMessage(
raw: string,
): { type: WsEventType; payload: Record<string, unknown> } | null {
try {
const parsed = JSON.parse(raw);
if (!parsed.type || typeof parsed.type !== "string") return null;
const { type, ...rest } = parsed;
return { type: type as WsEventType, payload: rest };
} catch {
return null;
}
}
-360
View File
@@ -1,360 +0,0 @@
// ─── WebSocket singleton with reconnect, typed events, and observable status ─
import type {
AttachmentRecord,
MessageRecord,
VoiceRecordingUploadData,
} from "@bete/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MediaState } from "../../entities/media/types.js";
import { createLogger } from "../lib/logger.js";
import type { ActiveSpeakerData } from "./events.js";
const logger = createLogger("socket");
export type WsStatus = "connecting" | "connected" | "disconnected" | "error";
export type BinaryHandler = (data: ArrayBuffer) => void;
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const MAX_RECONNECT_ATTEMPTS = 20;
function computeBackoff(attempt: number): number {
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, attempt),
RECONNECT_MAX_MS,
);
// Full jitter: random between 50% and 100% of delay
return delay * (0.5 + Math.random() * 0.5);
}
export interface WsHandlers {
onBinary?: BinaryHandler;
onMessageCreated?: (data: MessageRecord) => void;
onMessageUpdated?: (
data: MessageRecord & { edited_content?: string | null },
) => void;
onMessageDeleted?: (data: {
id: string;
channel_id?: string;
deleted_at: number;
}) => void;
onMessageAnalyzed?: (data: MessageRecord) => void;
onAttachmentCreated?: (data: AttachmentRecord) => void;
onAttachmentUploaded?: (data: AttachmentRecord) => void;
onUserState?: (users: ActiveSpeakerData[]) => void;
onUiState?: (state: Record<string, unknown>) => void;
onMediaState?: (state: MediaState) => void;
onVoiceRecordingStarted?: (data: Record<string, unknown>) => void;
onVoiceRecordingStopped?: (data: {
guild_id: string;
session_id: string;
duration_ms: number;
participants: number;
segment_count: number;
status: string;
stopped_at: number;
}) => void;
onVoiceRecordingUploaded?: (data: VoiceRecordingUploadData) => void;
onVoicePcmData?: (data: {
userId: string;
pcm: string;
metadata?: Record<string, unknown>;
}) => void;
onVoiceActiveUser?: (data: ActiveSpeakerData) => void;
onReactionAdded?: (data: Record<string, unknown>) => void;
onReactionRemoved?: (data: Record<string, unknown>) => void;
onThreadCreated?: (data: Record<string, unknown>) => void;
onThreadDeleted?: (data: Record<string, unknown>) => void;
onThreadUpdated?: (data: Record<string, unknown>) => void;
onChannelTopicUpdated?: (data: Record<string, unknown>) => void;
onPresenceUpdated?: (data: Record<string, unknown>) => void;
onGuildMemberAdded?: (data: Record<string, unknown>) => void;
onGuildMemberRemoved?: (data: Record<string, unknown>) => void;
onVoiceAnalyzed?: (data: Record<string, unknown>) => void;
}
let _wsInstance: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _closed = false;
let _reconnectAttempts = 0;
const _listeners = new Set<WsHandlers>();
const _statusCallbacks = new Set<(s: WsStatus) => void>();
function dispatchStatus(s: WsStatus): void {
for (const cb of _statusCallbacks) cb(s);
}
function doConnect(): WebSocket {
const BE_WS_URL =
import.meta.env.VITE_BE_WS_URL ||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
const url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
dispatchStatus("connecting");
logger.info("Connecting", { url });
ws.addEventListener("open", () => {
_reconnectAttempts = 0;
dispatchStatus("connected");
logger.info("Connected");
});
ws.addEventListener("error", () => {
dispatchStatus("error");
logger.error("WebSocket error");
});
ws.addEventListener("close", (event) => {
dispatchStatus("disconnected");
logger.info("Disconnected", { code: event.code, reason: event.reason });
if (!_closed && _listeners.size > 0) {
_reconnectAttempts++;
if (_reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
logger.error("Max reconnect attempts reached, giving up", {
attempts: _reconnectAttempts,
});
dispatchStatus("disconnected");
return;
}
const delay = computeBackoff(_reconnectAttempts);
logger.warn("Reconnecting", {
attempt: _reconnectAttempts,
delayMs: Math.round(delay),
});
_reconnectTimer = setTimeout(() => doReconnect(), delay);
}
});
ws.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
for (const h of _listeners) h.onBinary?.(event.data);
return;
}
if (typeof event.data !== "string") return;
try {
const msg = JSON.parse(event.data) as Record<string, unknown>;
for (const h of _listeners) {
switch (msg.type) {
case "message_created":
if (msg.data !== undefined)
h.onMessageCreated?.(msg.data as MessageRecord);
break;
case "message_updated":
if (msg.data !== undefined)
h.onMessageUpdated?.(
msg.data as MessageRecord & { edited_content?: string | null },
);
break;
case "message_deleted":
if (msg.data !== undefined)
h.onMessageDeleted?.(
msg.data as {
id: string;
channel_id?: string;
deleted_at: number;
},
);
break;
case "message_analyzed":
if (msg.data !== undefined)
h.onMessageAnalyzed?.(msg.data as MessageRecord);
break;
case "attachment_created":
if (msg.data !== undefined)
h.onAttachmentCreated?.(msg.data as AttachmentRecord);
break;
case "attachment_uploaded":
if (msg.data !== undefined)
h.onAttachmentUploaded?.(msg.data as AttachmentRecord);
break;
case "user_state":
h.onUserState?.(
(msg.users as unknown as ActiveSpeakerData[]) || [],
);
break;
case "ui_state":
h.onUiState?.(msg.state as Record<string, unknown>);
break;
case "media_state":
h.onMediaState?.(msg.state as MediaState);
break;
case "voice_recording_started":
if (msg.data !== undefined)
h.onVoiceRecordingStarted?.(msg.data as Record<string, unknown>);
break;
case "voice_recording_stopped":
if (msg.data !== undefined)
h.onVoiceRecordingStopped?.(
msg.data as {
guild_id: string;
session_id: string;
duration_ms: number;
participants: number;
segment_count: number;
status: string;
stopped_at: number;
},
);
break;
case "voice_recording_uploaded":
if (msg.data !== undefined)
h.onVoiceRecordingUploaded?.(
msg.data as VoiceRecordingUploadData,
);
break;
case "voice_pcm_data":
if (msg.data !== undefined)
h.onVoicePcmData?.(
msg.data as {
userId: string;
pcm: string;
metadata?: Record<string, unknown>;
},
);
break;
case "voice_active_user":
if (msg.data !== undefined)
h.onVoiceActiveUser?.(msg.data as ActiveSpeakerData);
break;
case "voice_analyzed":
if (msg.data !== undefined)
h.onVoiceAnalyzed?.(msg.data as Record<string, unknown>);
break;
case "reaction_added":
if (msg.data !== undefined)
h.onReactionAdded?.(msg.data as Record<string, unknown>);
break;
case "reaction_removed":
if (msg.data !== undefined)
h.onReactionRemoved?.(msg.data as Record<string, unknown>);
break;
case "thread_created":
if (msg.data !== undefined)
h.onThreadCreated?.(msg.data as Record<string, unknown>);
break;
case "thread_deleted":
if (msg.data !== undefined)
h.onThreadDeleted?.(msg.data as Record<string, unknown>);
break;
case "thread_updated":
if (msg.data !== undefined)
h.onThreadUpdated?.(msg.data as Record<string, unknown>);
break;
case "channel_topic_updated":
if (msg.data !== undefined)
h.onChannelTopicUpdated?.(msg.data as Record<string, unknown>);
break;
case "presence_updated":
if (msg.data !== undefined)
h.onPresenceUpdated?.(msg.data as Record<string, unknown>);
break;
case "guild_member_added":
if (msg.data !== undefined)
h.onGuildMemberAdded?.(msg.data as Record<string, unknown>);
break;
case "guild_member_removed":
if (msg.data !== undefined)
h.onGuildMemberRemoved?.(msg.data as Record<string, unknown>);
break;
case "analysis_queue_status":
// monitoring-only — no UI action needed
break;
}
}
} catch {
logger.error("Failed to parse message", {
raw: event.data.slice(0, 200),
});
}
});
return ws;
}
function doReconnect(): void {
if (_wsInstance) {
_wsInstance.close();
if (_reconnectTimer) clearTimeout(_reconnectTimer);
}
_closed = false;
_wsInstance = doConnect();
}
function ensureConnected(): void {
if (!_wsInstance || _wsInstance.readyState === WebSocket.CLOSED) {
if (_wsInstance) {
_wsInstance.close();
if (_reconnectTimer) clearTimeout(_reconnectTimer);
}
_closed = false;
_wsInstance = doConnect();
}
}
export function useDashboardSocket(handlers: WsHandlers) {
const [status, setStatus] = useState<WsStatus>("connecting");
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
const wrapper: WsHandlers = {
onBinary: (d) => handlersRef.current.onBinary?.(d),
onMessageCreated: (d) => handlersRef.current.onMessageCreated?.(d),
onMessageUpdated: (d) => handlersRef.current.onMessageUpdated?.(d),
onMessageDeleted: (d) => handlersRef.current.onMessageDeleted?.(d),
onMessageAnalyzed: (d) => handlersRef.current.onMessageAnalyzed?.(d),
onAttachmentCreated: (d) => handlersRef.current.onAttachmentCreated?.(d),
onAttachmentUploaded: (d) =>
handlersRef.current.onAttachmentUploaded?.(d),
onUserState: (u) => handlersRef.current.onUserState?.(u),
onUiState: (s) => handlersRef.current.onUiState?.(s),
onMediaState: (s) => handlersRef.current.onMediaState?.(s),
onVoiceRecordingStarted: (d) =>
handlersRef.current.onVoiceRecordingStarted?.(d),
onVoiceRecordingStopped: (d) =>
handlersRef.current.onVoiceRecordingStopped?.(d),
onVoiceRecordingUploaded: (d) =>
handlersRef.current.onVoiceRecordingUploaded?.(d),
onVoicePcmData: (d) => handlersRef.current.onVoicePcmData?.(d),
onVoiceActiveUser: (d) => handlersRef.current.onVoiceActiveUser?.(d),
onReactionAdded: (d) => handlersRef.current.onReactionAdded?.(d),
onReactionRemoved: (d) => handlersRef.current.onReactionRemoved?.(d),
onThreadCreated: (d) => handlersRef.current.onThreadCreated?.(d),
onThreadDeleted: (d) => handlersRef.current.onThreadDeleted?.(d),
onThreadUpdated: (d) => handlersRef.current.onThreadUpdated?.(d),
onChannelTopicUpdated: (d) =>
handlersRef.current.onChannelTopicUpdated?.(d),
onPresenceUpdated: (d) => handlersRef.current.onPresenceUpdated?.(d),
onGuildMemberAdded: (d) => handlersRef.current.onGuildMemberAdded?.(d),
onGuildMemberRemoved: (d) =>
handlersRef.current.onGuildMemberRemoved?.(d),
onVoiceAnalyzed: (d) => handlersRef.current.onVoiceAnalyzed?.(d),
};
_listeners.add(wrapper);
_statusCallbacks.add(setStatus);
if (_listeners.size === 1) {
ensureConnected();
}
return () => {
_listeners.delete(wrapper);
_statusCallbacks.delete(setStatus);
if (_listeners.size === 0) {
_closed = true;
if (_reconnectTimer) clearTimeout(_reconnectTimer);
_wsInstance?.close();
_wsInstance = null;
}
};
}, []);
const send = useCallback((data: ArrayBuffer | string) => {
if (_wsInstance?.readyState === WebSocket.OPEN) {
_wsInstance.send(data);
}
}, []);
return { status, send, socketRef: { current: _wsInstance } };
}
-675
View File
@@ -1,675 +0,0 @@
/*
IMPHNEN Design System Approachable Modernism
Manifestasi visual dari semangat komunitas programmer terbesar Indonesia.
Dibingkai dengan cinta oleh Cyrene 🌺
*/
@import "tailwindcss";
@config "../tailwind.config.js";
/* ─── Google Fonts: Poppins — monofamily yang friendly & percaya diri ─── */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap');
/*
BASE LAYER IMPHNEN Design Tokens
*/
@layer base {
:root {
/* ─── Surface & Background ────────────────────────────────────────── */
--background: #ffffff;
--foreground: #1a1a1a;
--card: #ffffff;
--card-foreground: #1a1a1a;
--muted: #f5f5f5;
--muted-foreground: #666666;
--accent: #f0f0f0;
--accent-foreground: #1a1a1a;
--popover: #ffffff;
--popover-foreground: #1a1a1a;
/* ─── Primary — Signature IMPHNEN Blue (#23a1eb) ─────────────────── */
/* Energi, kepercayaan, dan kehangatan digital */
--primary: #23a1eb;
--primary-foreground: #ffffff;
--primary-soft: #e1f0fd;
--primary-hover: #1a8fd9;
--primary-active: #0877c1;
/* ─── Secondary — Facebook Integration (#1877f2) ─────────────────── */
/* Lebih gelap & saturated untuk hierarki visual */
--secondary: #1877f2;
--secondary-foreground: #ffffff;
--secondary-soft: #e7f1ff;
/* ─── Tertiary — Discord Accent (#5865f2) ────────────────────────── */
/* Kehadiran brand di ekosistem platform */
--tertiary: #5865f2;
--tertiary-foreground: #ffffff;
--tertiary-soft: #eef0ff;
/* ─── Semantic Colors ────────────────────────────────────────────── */
--success: #22c55e;
--success-soft: #dcfce7;
--warning: #f59e0b;
--warning-soft: #fef3c7;
--destructive: #e4405f;
--destructive-foreground: #ffffff;
--destructive-soft: #ffebee;
--info: #3b82f6;
--info-soft: #dbeafe;
/* ─── Borders & Inputs ───────────────────────────────────────────── */
--border: #e0e0e0;
--border-hover: #cccccc;
--input: #e0e0e0;
--ring: #23a1eb;
/* ─── Radius — Friendly Geometry ─────────────────────────────────── */
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-full: 9999px;
/* ─── Elevation — Subtle Shadow Stack ────────────────────────────── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.12);
/* ─── Glow — Branded Auroras ─────────────────────────────────────── */
--primary-glow: rgba(35, 161, 235, 0.15);
--tertiary-glow: rgba(88, 101, 242, 0.15);
--destructive-glow: rgba(228, 64, 95, 0.15);
--success-glow: rgba(34, 197, 94, 0.15);
/* ─── Spacing — Rhythm System ────────────────────────────────────── */
--space-xs: 4px;
--space-sm: 12px;
--space-md: 24px;
--space-lg: 40px;
--space-xl: 64px;
--gutter: 24px;
--container-max: 1280px;
/* ─── Transition — Snappy & Responsive ──────────────────────────── */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ─── Dark Mode Theme ──────────────────────────────────────────────────── */
[data-theme="dark"] {
--background: #1c1c1f;
--foreground: #f0f0f2;
--card: #1c1c1f;
--card-foreground: #f0f0f2;
--muted: #141417;
--muted-foreground: #a0a0a6;
--accent: #26262a;
--accent-foreground: #f0f0f2;
--popover: #1c1c1f;
--popover-foreground: #f0f0f2;
--primary: #54a2ff;
--primary-foreground: #0d0d0f;
--primary-soft: #18263a;
--primary-hover: #3d8ee8;
--primary-active: #2a7ad4;
--secondary: #4a8ef5;
--secondary-foreground: #0d0d0f;
--secondary-soft: #1a274a;
--tertiary: #7984f5;
--tertiary-foreground: #0d0d0f;
--tertiary-soft: #20266a;
--success: #34d399;
--success-soft: #13261a;
--warning: #fbbf24;
--warning-soft: #261a10;
--destructive: #f87171;
--destructive-soft: #2a1418;
--info: #60a5fa;
--info-soft: #141e38;
--border: #343438;
--border-hover: #48484d;
--input: #343438;
--ring: #54a2ff;
--outline: #6a6a70;
--outline-variant: #404044;
--primary-glow: rgba(84,162,255,0.15);
--tertiary-glow: rgba(121,132,245,0.15);
--destructive-glow: rgba(248,113,113,0.15);
--success-glow: rgba(52,211,153,0.15);
--surface: #1c1c1f;
--surface-dim: #141417;
--surface-bright: #2c2c30;
--on-surface: #f0f0f2;
--on-surface-variant: #a0a0a6;
--inverse-surface: #f0f0f2;
--inverse-on-surface: #1c1c1f;
--surface-container: #26262a;
--surface-container-low: #202023;
--surface-container-high: #2c2c30;
--surface-container-highest: #323236;
}
/* ─── Global Resets ──────────────────────────────────────────────────── */
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
html,
body,
#root {
min-height: 100%;
}
/* ─── Smooth Scroll ──────────────────────────────────────────────────── */
html {
scroll-behavior: smooth;
}
/* ─── Selection ──────────────────────────────────────────────────────── */
::selection {
background-color: var(--primary-soft);
color: var(--on-primary-container, #0d4a7a);
}
/* ─── Theme Transition ─────────────────────────────────────────────────── */
html.theme-transitioning,
html.theme-transitioning *,
html.theme-transitioning *::before,
html.theme-transitioning *::after {
transition: background-color 200ms ease, color 150ms ease, border-color 150ms ease, box-shadow 200ms ease !important;
}
}
/*
THEME EXTENSION IMPHNEN Custom Tailwind Tokens
*/
@theme {
/* ─── Typography Scale — Poppins Monofamily ─────────────────────────── */
--font-family-display: 'Poppins', sans-serif;
--font-size-display: 60px;
--font-weight-display: 700;
--line-height-display: 68px;
--letter-spacing-display: -0.04em;
--font-size-headline-lg: 40px;
--font-weight-headline-lg: 600;
--line-height-headline-lg: 48px;
--letter-spacing-headline-lg: -0.02em;
--font-size-headline-md: 28px;
--font-weight-headline-md: 600;
--line-height-headline-md: 36px;
--letter-spacing-headline-md: -0.01em;
--font-size-title-lg: 20px;
--font-weight-title-lg: 600;
--line-height-title-lg: 28px;
--font-size-body-lg: 18px;
--font-weight-body-lg: 400;
--line-height-body-lg: 28px;
--letter-spacing-body-lg: 0.01em;
--font-size-body-md: 16px;
--font-weight-body-md: 400;
--line-height-body-md: 24px;
--letter-spacing-body-md: 0.01em;
--font-size-label-md: 14px;
--font-weight-label-md: 600;
--line-height-label-md: 20px;
--letter-spacing-label-md: 0.02em;
--font-size-label-sm: 12px;
--font-weight-label-sm: 500;
--line-height-label-sm: 16px;
--letter-spacing-label-sm: 0.03em;
/* ─── Brand Colors ──────────────────────────────────────────────────── */
--color-primary: #23a1eb;
--color-primary-foreground: #ffffff;
--color-primary-soft: #e1f0fd;
--color-primary-hover: #1a8fd9;
--color-primary-active: #0877c1;
--color-secondary: #1877f2;
--color-secondary-foreground: #ffffff;
--color-secondary-soft: #e7f1ff;
--color-tertiary: #5865f2;
--color-tertiary-foreground: #ffffff;
--color-tertiary-soft: #eef0ff;
--color-success: #22c55e;
--color-success-soft: #dcfce7;
--color-warning: #f59e0b;
--color-warning-soft: #fef3c7;
--color-destructive: #e4405f;
--color-destructive-soft: #ffebee;
--color-info: #3b82f6;
--color-info-soft: #dbeafe;
/* ─── Surface Bridge ────────────────────────────────────────────────── */
--color-surface: #ffffff;
--color-surface-dim: #f5f5f5;
--color-surface-bright: #ffffff;
--color-on-surface: #1a1a1a;
--color-on-surface-variant: #666666;
}
/*
COMPONENT LAYER IMPHNEN Design Patterns
*/
@layer components {
/* ─── Glass Morphism ────────────────────────────────────────────────── */
.im-surface {
@apply bg-white border border-[#e0e0e0] rounded-xl;
}
.im-glass {
@apply bg-white/70 backdrop-blur-sm border border-[#e0e0e0]/60 rounded-xl;
}
.im-glass-strong {
@apply bg-white/85 backdrop-blur-md border border-[#e0e0e0]/80 rounded-xl shadow-sm;
}
/* ─── Gradient Text ─────────────────────────────────────────────────── */
.im-gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2];
}
.im-gradient-text-warm {
@apply bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2];
}
/* ─── IMPHNEN Button Base ────────────────────────────────────────────── */
.im-btn {
@apply inline-flex items-center justify-center gap-2 font-semibold;
font-family: 'Poppins', sans-serif;
font-size: 14px;
letter-spacing: 0.02em;
line-height: 20px;
border-radius: var(--radius-lg);
padding: 12px 24px;
height: 44px;
transition: background-color var(--transition-fast);
}
.im-btn-primary {
@apply inline-flex items-center justify-center gap-2 font-semibold text-white;
font-family: 'Poppins', sans-serif;
font-size: 14px;
letter-spacing: 0.02em;
line-height: 20px;
border-radius: var(--radius-lg);
padding: 12px 24px;
height: 44px;
transition: background-color var(--transition-fast);
background-color: var(--primary);
}
.im-btn-primary:hover {
background-color: var(--primary-hover);
}
.im-btn-primary:active {
background-color: var(--primary-active);
}
.im-btn-secondary {
@apply inline-flex items-center justify-center gap-2 font-semibold;
font-family: 'Poppins', sans-serif;
font-size: 14px;
letter-spacing: 0.02em;
line-height: 20px;
border-radius: var(--radius-lg);
padding: 12px 24px;
height: 44px;
transition: background-color var(--transition-fast);
background-color: transparent;
color: var(--primary);
border: 1px solid var(--border);
}
.im-btn-secondary:hover {
background-color: var(--accent);
border-color: var(--primary);
}
.im-btn-ghost {
@apply inline-flex items-center justify-center gap-2 font-semibold;
font-family: 'Poppins', sans-serif;
font-size: 14px;
letter-spacing: 0.02em;
line-height: 20px;
border-radius: var(--radius-lg);
padding: 12px 24px;
height: 44px;
transition: background-color var(--transition-fast);
background-color: transparent;
color: var(--foreground);
}
.im-btn-ghost:hover {
background-color: var(--accent);
color: var(--primary);
}
/* ─── Badge / Pill ──────────────────────────────────────────────────── */
.im-badge {
@apply inline-flex items-center;
border-radius: var(--radius-full);
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.03em;
line-height: 16px;
background-color: var(--primary-soft);
color: var(--on-primary-container, #0d4a7a);
}
.im-badge-secondary {
background-color: var(--secondary-soft);
color: var(--on-secondary-container, #003d99);
}
.im-badge-tertiary {
background-color: var(--tertiary-soft);
color: var(--on-tertiary-container, #1a2466);
}
.im-badge-success {
background-color: var(--success-soft);
color: #166534;
}
.im-badge-warning {
background-color: var(--warning-soft);
color: #92400e;
}
.im-badge-destructive {
background-color: var(--destructive-soft);
color: var(--destructive);
}
/* ─── Card ──────────────────────────────────────────────────────────── */
.im-card {
background-color: var(--card);
border-radius: var(--radius-xl);
padding: var(--space-md);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
transition: all var(--transition-slow);
}
.im-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
/* ─── Input ─────────────────────────────────────────────────────────── */
.im-input {
background-color: var(--muted);
color: var(--foreground);
font-size: 16px;
line-height: 24px;
border-radius: var(--radius);
padding: var(--space-sm);
border: 1px solid var(--border);
transition: border-color var(--transition-fast);
width: 100%;
font-family: 'Poppins', sans-serif;
}
.im-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
outline: none;
}
/* ─── Nav Link ──────────────────────────────────────────────────────── */
.im-nav-link {
color: var(--foreground);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 8px 12px;
position: relative;
transition: color var(--transition-fast);
}
.im-nav-link:hover {
color: var(--primary);
}
.im-nav-link.active {
color: var(--primary);
}
.im-nav-link.active::after {
content: '';
position: absolute;
bottom: 0;
left: 12px;
right: 12px;
height: 2px;
background-color: var(--primary);
border-radius: 1px;
}
/* ─── Grid Pattern Background ───────────────────────────────────────── */
.im-grid-pattern {
background-image:
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
}
/*
UTILITY LAYER Animations & Effects
*/
@layer utilities {
/* ─── Entry Animations ──────────────────────────────────────────────── */
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.3s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
/* ─── Pulse & Shimmer ───────────────────────────────────────────────── */
.animate-bar-pulse {
animation: barPulse 0.4s ease-in-out infinite;
transform-origin: bottom;
}
.animate-shimmer {
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.06) 0%,
rgba(0, 0, 0, 0.02) 40%,
rgba(0, 0, 0, 0.06) 80%,
rgba(0, 0, 0, 0.08) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ─── Status Indicators ─────────────────────────────────────────────── */
.im-status-dot {
@apply inline-block h-2 w-2 rounded-full;
}
.im-status-dot-success {
@apply inline-block h-2 w-2 rounded-full bg-[#22c55e];
}
.im-status-dot-warning {
@apply inline-block h-2 w-2 rounded-full bg-[#f59e0b];
}
.im-status-dot-error {
@apply inline-block h-2 w-2 rounded-full bg-[#e4405f];
}
.im-status-dot-idle {
@apply inline-block h-2 w-2 rounded-full bg-[#cccccc];
}
/* ─── Divider ───────────────────────────────────────────────────────── */
.im-divider {
@apply w-full border-t;
border-color: var(--border);
}
.im-divider-label {
@apply flex items-center gap-3 text-xs font-medium text-[#999999];
}
.im-divider-label::before,
.im-divider-label::after {
content: '';
flex: 1;
border-top: 1px solid var(--border);
}
/* ─── Typography Utilities ──────────────────────────────────────────── */
.typo-display {
font-family: 'Poppins', sans-serif;
font-size: 60px;
font-weight: 700;
line-height: 68px;
letter-spacing: -0.04em;
}
.typo-headline-lg {
font-family: 'Poppins', sans-serif;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -0.02em;
}
.typo-headline-md {
font-family: 'Poppins', sans-serif;
font-size: 28px;
font-weight: 600;
line-height: 36px;
letter-spacing: -0.01em;
}
.typo-title-lg {
font-family: 'Poppins', sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.typo-body-lg {
font-family: 'Poppins', sans-serif;
font-size: 18px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
}
.typo-body-md {
font-family: 'Poppins', sans-serif;
font-size: 16px;
font-weight: 400;
line-height: 24px;
letter-spacing: 0.01em;
}
.typo-label-md {
font-family: 'Poppins', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0.02em;
}
.typo-label-sm {
font-family: 'Poppins', sans-serif;
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.03em;
}
}
/*
KEYFRAMES Animations
*/
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes barPulse {
0%, 100% { transform: scaleY(0.8); }
50% { transform: scaleY(1.2); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes glowPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
/* ─── IMPHNEN Mascot Wiggle ───────────────────────────────────────────── */
@keyframes mascotWiggle {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(-8deg); }
30% { transform: rotate(6deg); }
45% { transform: rotate(-4deg); }
60% { transform: rotate(2deg); }
}
.animate-mascot-wiggle {
animation: mascotWiggle 0.6s ease-in-out;
}
/* ─── IMPHNEN Notification Pulse ──────────────────────────────────────── */
@keyframes notificationPulse {
0% { box-shadow: 0 0 0 0 var(--primary-glow); }
70% { box-shadow: 0 0 0 8px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.animate-notification-pulse {
animation: notificationPulse 2s ease-in-out infinite;
}
/*
ACCESSIBILITY Reduced Motion
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@@ -1,95 +0,0 @@
/*
* IMPHNEN DashboardLayout The canvas for Guild Moderation Watcher
* Approachable Modernism: clean surfaces, subtle grid pattern, spring
* transitions, dan IMPHNEN signature glow.
* */
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header";
import { ParticleBackground } from "./particles/ParticleBackground";
import { Sidebar } from "./Sidebar";
import { TabStrip } from "./TabStrip";
interface DashboardLayoutProps {
activeTab: DashboardTab;
wsStatus: WsStatus;
voiceStatus: VoiceStatus;
onTabChange: (tab: DashboardTab) => void;
children: ReactNode;
recentMessages?: MessageRecord[];
guildId?: string;
channelId?: string;
guildName?: string;
flaggedCount?: number;
moderationQueue?: number;
}
export function DashboardLayout({
activeTab,
wsStatus,
voiceStatus,
onTabChange,
children,
recentMessages = [],
guildId,
channelId,
flaggedCount = 0,
}: DashboardLayoutProps) {
return (
<div className="relative min-h-screen bg-white text-[#1a1a1a]">
{/* Background layers */}
<ParticleBackground />
<div
className="fixed inset-0 pointer-events-none"
aria-hidden="true"
style={{
backgroundImage:
"linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px)",
backgroundSize: "40px 40px",
opacity: 0.5,
}}
/>
<div className="relative flex min-h-screen">
{/* Sidebar Navigation */}
<Sidebar
activeTab={activeTab}
onTabChange={onTabChange}
recentMessages={recentMessages}
guildId={guildId}
channelId={channelId}
flaggedCount={flaggedCount}
/>
{/* Main Content Area */}
<main className="flex min-w-0 flex-1 flex-col">
<Header
wsStatus={wsStatus}
voiceStatus={voiceStatus}
/>
<TabStrip activeTab={activeTab} onTabChange={onTabChange} />
{/* Page Content with entry animation */}
<motion.main
key={activeTab}
variants={fadeSlideUp}
initial="initial"
animate="animate"
exit="exit"
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
style={{ maxWidth: "1280px", margin: "0 auto", width: "100%" }}
>
{children}
</motion.main>
</main>
</div>
</div>
);
}
-133
View File
@@ -1,133 +0,0 @@
import { Moon, Shield, ShieldOff, Sun, Wifi, WifiOff } from "lucide-react";
import type { VoiceStatus } from "../entities/voice/types.js";
import { useTheme } from "../shared/hooks/useTheme";
import { cn } from "../shared/lib/utils";
import { Badge } from "../shared/ui";
import type { WsStatus } from "../shared/ws/socket";
/* ─── Theme Toggle ─────────────────────────────────────────────────────── */
function ThemeToggle() {
const { resolvedTheme, toggle } = useTheme();
return (
<button
onClick={toggle}
className="rounded-lg p-2 text-[#666666] hover:bg-[#f0f0f0] hover:text-[#1a1a1a] transition-colors duration-150"
aria-label="Toggle theme"
>
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
);
}
/* ─── WS Indicator ─────────────────────────────────────────────────────── */
function WsIndicator({ status }: { status: WsStatus }) {
const isConnected = status === "connected";
return (
<div className="flex items-center gap-1.5">
{isConnected ? (
<Wifi className="h-3 w-3 text-[#22c55e]" />
) : (
<WifiOff className="h-3 w-3 text-[#e4405f]" />
)}
<span
className={cn(
"text-xs font-medium",
isConnected ? "text-[#22c55e]" : "text-[#e4405f]",
)}
>
{status === "connected"
? "Online"
: status === "connecting"
? "Nyambung..."
: status === "error"
? "Error"
: "Putus"}
</span>
</div>
);
}
/* ─── Voice Indicator ──────────────────────────────────────────────────── */
function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
const isConnected = voiceStatus.connected;
return (
<div className="flex items-center gap-1.5">
{isConnected ? (
<Shield className="h-3 w-3 text-[#23a1eb]" />
) : (
<ShieldOff className="h-3 w-3 text-[#999999]" />
)}
<span
className={cn(
"text-xs font-medium",
isConnected ? "text-[#23a1eb]" : "text-[#999999]",
)}
>
{isConnected
? voiceStatus.activeChannelName || "Tersambung"
: "Siaga"}
</span>
</div>
);
}
/* ─── Main Header ──────────────────────────────────────────────────────── */
interface HeaderProps {
wsStatus: WsStatus;
voiceStatus: VoiceStatus;
}
export function Header({ wsStatus, voiceStatus }: HeaderProps) {
return (
<header className="sticky top-0 z-10 border-b border-[#e0e0e0]/50 bg-white/70 backdrop-blur-md px-4 py-3">
<div className="mx-auto flex max-w-[1280px] items-center justify-between">
{/* ── Left: Logo + Brand ──────────────────────────────────────── */}
<div className="flex items-center gap-3">
<img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
alt="IMPHNEN"
className="h-8 w-8"
/>
<h1 className="font-sans text-lg font-bold tracking-tight text-[#1a1a1a]">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#1877f2]">
IMPHNEN
</span>
<span className="mx-1.5 text-[#666666]">·</span>
<span className="font-semibold text-[#666666]">
Guild Watcher
</span>
</h1>
</div>
{/* ── Right: Status + Theme ──────────────────────────────────── */}
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={cn(
"border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
wsStatus === "connected"
? "text-[#22c55e]"
: wsStatus === "error"
? "text-[#e4405f]"
: "text-[#999999]",
)}
>
<WsIndicator status={wsStatus} />
</Badge>
<Badge
variant="outline"
className={cn(
"border-[#e0e0e0] bg-white/50 px-2.5 py-1 text-xs",
voiceStatus.connected ? "text-[#23a1eb]" : "text-[#666666]",
)}
>
<VoiceIndicator voiceStatus={voiceStatus} />
</Badge>
<ThemeToggle />
</div>
</div>
</header>
);
}
-208
View File
@@ -1,208 +0,0 @@
/*
* IMPHNEN Sidebar Navigation command center
* Minimal, collapsed by default, dengan Mascot yang playful.
* Fokus: Guild Moderation Watcher untuk komunitas IMPHNEN.
* */
import { motion } from "framer-motion";
import {
LayoutDashboard,
MessageSquare,
Radio,
} from "lucide-react";
import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import { useMascotChat } from "../shared/hooks/useMascotChat";
import { cn } from "../shared/lib/utils";
import { MascotChatbot } from "./mascot/MascotChatbot";
import { MascotImage } from "./mascot/MascotImage";
const navItems: Array<{
id: DashboardTab;
label: string;
icon: typeof Radio;
badge?: string;
}> = [
{
id: "messages",
label: "Pesan & Moderasi",
icon: MessageSquare,
badge: "Live",
},
{
id: "live",
label: "Voice & Media",
icon: Radio,
},
{
id: "dashboard",
label: "Dashboard Guild",
icon: LayoutDashboard,
},
];
interface SidebarProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
collapsed?: boolean;
recentMessages?: MessageRecord[];
guildId?: string;
channelId?: string;
flaggedCount?: number;
}
export function Sidebar({
activeTab,
onTabChange,
collapsed = false,
recentMessages = [],
guildId,
channelId,
flaggedCount = 0,
}: SidebarProps) {
const mascotChat = useMascotChat({
messageCount: recentMessages.length,
activeParticipants: new Set(
recentMessages.map((message) => message.user_id),
).size,
lastActivity: recentMessages.length > 0 ? "Aktif" : "Idle",
topicsDiscussed: ["Pesan", "Moderasi"],
guildId,
channelId,
});
return (
<>
<motion.nav
className={cn(
"relative hidden shrink-0 flex-col overflow-visible",
"border-r border-[#e0e0e0]/50",
"bg-white/70 backdrop-blur-sm",
"transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]",
"md:flex",
collapsed ? "w-16" : "w-64",
)}
layout
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{/* ── Brand Icon ────────────────────────────────────────────── */}
<div
className={cn(
"flex items-center py-5",
collapsed ? "justify-center" : "flex-col px-4",
)}
>
{/* Logo */}
<div className="relative">
<img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
alt="IMPHNEN"
className="h-8 w-8 rounded-xl"
/>
{/* Live indicator dot */}
<span className="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[#22c55e] ring-2 ring-white animate-pulse" />
</div>
{/* Brand text when expanded */}
{!collapsed && (
<div className="mt-4 text-center">
<h2 className="font-sans text-sm font-bold text-[#1a1a1a]">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-[#23a1eb] to-[#5865f2]">
IMPHNEN
</span>
</h2>
<p className="font-sans text-[10px] font-medium text-[#666666] mt-0.5 tracking-wider uppercase">
Guild Watcher
</p>
</div>
)}
{/* Mascot — only when expanded */}
{!collapsed && (
<img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot IMPHNEN"
className="mt-4 h-auto w-[120px] object-contain drop-shadow-md hover:animate-mascot-wiggle cursor-pointer"
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
/>
)}
</div>
{/* ── Navigation Items ──────────────────────────────────────── */}
<div className="flex flex-1 flex-col justify-center">
<div className="flex flex-col gap-1 px-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
title={collapsed ? item.label : undefined}
className={cn(
"group relative flex items-center rounded-xl p-2.5",
"font-sans text-sm font-medium",
"transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]",
collapsed ? "justify-center" : "gap-3",
isActive
? "bg-[#e1f0fd] text-[#23a1eb] ring-1 ring-[#23a1eb]/20"
: "text-[#666666] hover:bg-[#e1f0fd]/50 hover:text-[#23a1eb]/70",
)}
>
<div className="relative">
<Icon className="h-4 w-4 shrink-0" />
{/* Flagged dot indicator */}
{item.id === "messages" && flaggedCount > 0 && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-[#e4405f] ring-1 ring-white" />
)}
</div>
{!collapsed && (
<div className="flex items-center justify-between flex-1 min-w-0">
<span>{item.label}</span>
{item.badge && (
<span className="font-sans text-[10px] font-semibold text-[#23a1eb] bg-[#e1f0fd] px-1.5 py-0.5 rounded-full">
{item.badge}
</span>
)}
</div>
)}
</button>
);
})}
</div>
</div>
{/* ── Bottom: Mascot Chat Button ────────────────────────────── */}
<div className="flex justify-center pb-4">
<button
type="button"
onClick={() => mascotChat.setIsOpen(!mascotChat.isOpen)}
className={cn(
"relative z-50 rounded-xl p-1.5",
"transition-all duration-200 hover:scale-105",
"focus:outline-none focus:ring-2 focus:ring-[#23a1eb]/40",
mascotChat.isOpen && "bg-[#e1f0fd] ring-1 ring-[#23a1eb]/30",
)}
title="Chat dengan Mascot"
>
<div className="relative">
<MascotImage size="sm" />
{mascotChat.isOpen && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-[#23a1eb] ring-1 ring-white" />
)}
</div>
</button>
</div>
</motion.nav>
{/* ── Mascot Chatbot Panel ────────────────────────────────────── */}
<MascotChatbot
isOpen={mascotChat.isOpen}
onClose={() => mascotChat.setIsOpen(false)}
onSendMessage={mascotChat.handleSendMessage}
mascotName="Mascot IMPHNEN"
className="fixed bottom-[170px] left-[80px] z-[9999]"
/>
</>
);
}
@@ -1,49 +0,0 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, MessageSquare, Radio } from 'lucide-react';
import type { DashboardTab } from '../entities/ui/types.js';
import { cn } from '../shared/lib/utils';
const tabs: { id: DashboardTab; label: string; icon: typeof MessageSquare }[] = [
{ id: 'messages', label: 'Pesan & Moderasi', icon: MessageSquare },
{ id: 'live', label: 'Voice & Media', icon: Radio },
{ id: 'dashboard', label: 'Dashboard Guild', icon: LayoutDashboard },
];
interface TabStripProps {
activeTab: DashboardTab;
onTabChange: (tab: DashboardTab) => void;
className?: string;
}
export function TabStrip({ activeTab, onTabChange, className }: TabStripProps) {
return (
<nav className={cn('sticky top-14 z-30 border-b border-[#e0e0e0] bg-white/80 backdrop-blur-sm overflow-x-auto scrollbar-none', className)}>
<div className="mx-auto flex max-w-[1280px] gap-1 px-4 md:px-6 lg:px-8">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={cn(
'relative flex items-center gap-2 px-4 py-3 whitespace-nowrap text-sm font-semibold transition-colors duration-150',
isActive ? 'text-[#23a1eb]' : 'text-[#666666] hover:text-[#1a1a1a]',
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{tab.label}</span>
{isActive && (
<motion.div
layoutId="tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#23a1eb] rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
);
})}
</div>
</nav>
);
}
@@ -1,310 +0,0 @@
/*
* IMPHNEN MascotChatbot AI companion widget
* Floating chat panel dengan IMPHNEN signature branding.
* Friendly Geometry: rounded-xl container, rounded-lg elements.
* Signature timing: 150ms cubic-bezier(0.4, 0, 0.2, 1) untuk interaksi.
* */
import { AnimatePresence, motion } from "framer-motion";
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createLogger } from "../../shared/lib/logger.js";
import { cn } from "../../shared/lib/utils";
const logger = createLogger("mascot-chat");
export interface ChatMessage {
id: string;
role: "user" | "mascot";
content: string;
timestamp: number;
avatar?: string;
}
interface MascotChatbotProps {
onClose?: () => void;
isOpen?: boolean;
onSendMessage?: (message: string) => Promise<string>;
mascotName?: string;
mascotAvatar?: string;
className?: string;
}
export function MascotChatbot({
onClose,
isOpen = false,
onSendMessage,
mascotName = "Mascot IMPHNEN",
mascotAvatar = "https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png",
className,
}: MascotChatbotProps) {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: "init-1",
role: "mascot",
content:
"Halo! 👋 Aku mascot IMPHNEN. Ada yang bisa aku bantu tentang conversation atau analytics?",
timestamp: Date.now(),
},
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = async (e: { preventDefault: () => void }) => {
e.preventDefault();
if (!input.trim() || loading) return;
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: input.trim(),
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setLoading(true);
try {
let response = "Aku sedang memproses pertanyaanmu...";
if (onSendMessage) {
response = await onSendMessage(input.trim());
} else {
response = generateMascotResponse(input.trim(), messages);
}
const mascotMessage: ChatMessage = {
id: `mascot-${Date.now()}`,
role: "mascot",
content: response,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, mascotMessage]);
} catch (error) {
logger.error("Error sending message", {
error: error instanceof Error ? error.message : String(error),
});
const errorMessage: ChatMessage = {
id: `mascot-error-${Date.now()}`,
role: "mascot",
content: "Maaf, ada error saat aku memproses. Coba lagi ya! 😅",
timestamp: Date.now(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"w-96 bg-white rounded-xl shadow-lg border border-[#e0e0e0] overflow-hidden flex flex-col",
isMinimized ? "h-14" : "h-[520px]",
className,
)}
>
{/* ── Header: Branded Gradient ───────────────────────────────── */}
<div className="bg-gradient-to-r from-[#23a1eb] to-[#1877f2] p-3.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center">
<MessageCircle className="h-4 w-4 text-white" />
</div>
<div>
<h3 className="font-sans text-sm font-semibold text-white leading-tight">
{mascotName}
</h3>
<p className="font-sans text-[11px] text-white/75 leading-tight mt-0.5">
{loading ? "Mengetik..." : "Online"}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
title={isMinimized ? "Maximize" : "Minimize"}
>
{isMinimized ? (
<Maximize2 className="h-3.5 w-3.5 text-white" />
) : (
<Minimize2 className="h-3.5 w-3.5 text-white" />
)}
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onClose?.()}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
title="Close"
>
<X className="h-3.5 w-3.5 text-white" />
</motion.button>
</div>
</div>
{/* ── Messages Area ──────────────────────────────────────────── */}
{!isMinimized && (
<>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-white">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{message.role === "mascot" && (
<img
src={mascotAvatar}
alt={mascotName}
className="w-6 h-6 rounded-full object-cover shrink-0"
/>
)}
<div
className={cn(
"max-w-[280px] px-3 py-2 rounded-xl text-sm leading-relaxed [overflow-wrap:anywhere]",
message.role === "user"
? "bg-[#23a1eb] text-white rounded-br-[4px]"
: "bg-[#f5f5f5] text-[#1a1a1a] rounded-bl-[4px]",
)}
>
{message.content}
</div>
</motion.div>
))}
{/* ── Typing Indicator ──────────────────────────────── */}
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex gap-2 justify-start"
>
<img
src={mascotAvatar}
alt={mascotName}
className="w-6 h-6 rounded-full object-cover shrink-0"
/>
<div className="bg-[#f5f5f5] rounded-xl rounded-bl-[4px] px-3 py-2.5">
<div className="flex gap-1">
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.6, repeat: Infinity }}
className="w-2 h-2 bg-[#666666] rounded-full"
/>
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
className="w-2 h-2 bg-[#666666] rounded-full"
/>
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
className="w-2 h-2 bg-[#666666] rounded-full"
/>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
{/* ── Input Area ──────────────────────────────────────── */}
<form
onSubmit={handleSendMessage}
className="border-t border-[#e0e0e0] p-3 bg-white"
>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Tanya mascot..."
disabled={loading}
className="flex-1 px-3 py-2 rounded-lg border border-[#e0e0e0] bg-white text-[#1a1a1a] placeholder:text-[#999999] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#23a1eb]/30 focus-visible:border-[#23a1eb] text-sm disabled:opacity-50 transition-all duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
/>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
type="submit"
disabled={loading || !input.trim()}
className="p-2 bg-[#23a1eb] text-white rounded-lg hover:bg-[#1a8fd9] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)]"
>
<Send className="h-4 w-4" />
</motion.button>
</div>
</form>
</>
)}
</motion.div>
</AnimatePresence>
);
}
// Default mascot responses based on keywords
function generateMascotResponse(
input: string,
messages: ChatMessage[],
): string {
const lowerInput = input.toLowerCase();
const responseMap: Record<string, string> = {
halo: "Halo juga! 👋 Senang ketemu kamu di IMPHNEN. Ada yang bisa aku bantu?",
terima: "Sama-sama! 😊 Senang bisa membantu!",
apa: "Aku adalah mascot virtual IMPHNEN yang membantu kamu memahami conversation dan analytics. Tanya aku apa saja!",
siapa:
"Aku mascot IMPHNEN yang baik hati! Siap membantu dengan insights tentang chat dan analytics.",
chat: "Setiap chat yang terjadi di guild dianalisis untuk memberikan insights yang berguna. Keren kan? 😎",
pesan:
"Aku bisa memberikan ringkasan tentang pesan-pesan yang dikirim, siapa yang paling aktif, dan topik populer!",
analitik:
"Analytics menunjukkan pola conversation, waktu aktif, partisipan utama, dan banyak hal menarik! 📊",
berapa:
"Tanya aku 'berapa pesan hari ini' atau 'berapa orang yang chat' dan aku akan jawab dengan data real-time!",
};
for (const [keyword, response] of Object.entries(responseMap)) {
if (lowerInput.includes(keyword)) {
return response;
}
}
if (messages.length < 5) {
return "Bagus! Aku akan belajar tentang apa yang kamu tanya. Coba tanya aku tentang chat, analytics, atau partisipan! 🎯";
}
return `Menarik! "${input}" — itu hal yang perlu diperhatikan. Ada yang lain ingin kamu ketahui? 🤔`;
}
@@ -1,100 +0,0 @@
/*
* IMPHNEN MascotImage Anime mascot PNG dengan floating chat bubble
* Signature: rounded-xl untuk container, spring transitions, primary glow.
* Mascot adalah "wajah" IMPHNEN playful dan approachable.
* */
import { motion } from "framer-motion";
import { MessageCircle } from "lucide-react";
import { useEffect, useState } from "react";
interface MascotImageProps {
size?: "sm" | "md" | "lg";
className?: string;
showChat?: boolean;
chatMessage?: string;
persistChat?: boolean;
}
const sizeMap = {
sm: "w-16 h-auto",
md: "w-32 h-auto",
lg: "w-48 h-auto",
};
const chatSizeMap = {
sm: "max-w-[200px]",
md: "max-w-xs",
lg: "max-w-sm",
};
export function MascotImage({
size = "md",
className = "",
showChat = false,
chatMessage = "",
persistChat = false,
}: MascotImageProps) {
const sizeClass = sizeMap[size];
const chatSizeClass = chatSizeMap[size];
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (showChat && chatMessage) {
setIsVisible(true);
if (persistChat) return;
const timer = setTimeout(() => setIsVisible(false), 8000);
return () => clearTimeout(timer);
}
setIsVisible(false);
}, [showChat, chatMessage, persistChat]);
return (
<div className="relative inline-block">
<motion.img
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
alt="Mascot IMPHNEN"
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
{/* Floating Chat Bubble */}
{isVisible && chatMessage && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.8 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className={`absolute -top-2 -right-2 ${chatSizeClass} pointer-events-none`}
>
<div className="relative">
{/* Chat bubble */}
<div className="bg-[#23a1eb]/90 text-white rounded-xl px-3 py-2 shadow-md backdrop-blur-sm border border-[#23a1eb]/30">
<div className="flex items-start gap-1.5">
<MessageCircle className="h-3.5 w-3.5 shrink-0 mt-0.5 text-white/80" />
<p className="text-xs leading-relaxed font-medium line-clamp-3">
{chatMessage}
</p>
</div>
<div className="absolute -bottom-1 left-3 w-2.5 h-2.5 bg-[#23a1eb]/80 rounded-full" />
</div>
</div>
</motion.div>
)}
</div>
);
}
/**
* EmptyStateMascot Mascot untuk empty states
* Menampilkan mascot yang redup dengan pesan "Belum ada data"
*/
export function EmptyStateMascot() {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<MascotImage size="md" className="opacity-60" />
<p className="font-sans text-sm text-[#666666]">Belum ada data ditampilkan</p>
</div>
);
}
@@ -1,69 +0,0 @@
/*
* IMPHNEN Particle Background Glow orbs yang subtle
* Menggunakan primary (#23a1eb) dan tertiary (#5865f2) glow.
* */
import { useEffect, useState } from "react";
export function ParticleBackground() {
const [reducedMotion, setReducedMotion] = useState(true);
const [isMobile, setIsMobile] = useState(true);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
const mqReduced = window.matchMedia("(prefers-reduced-motion: reduce)");
setReducedMotion(mqReduced.matches);
const mqMobile = window.matchMedia("(max-width: 768px)");
setIsMobile(mqMobile.matches);
const handleReduced = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
const handleMobile = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mqReduced.addEventListener("change", handleReduced);
mqMobile.addEventListener("change", handleMobile);
requestAnimationFrame(() => setShouldRender(true));
return () => {
mqReduced.removeEventListener("change", handleReduced);
mqMobile.removeEventListener("change", handleMobile);
};
}, []);
if (reducedMotion || isMobile || !shouldRender) return null;
const root = getComputedStyle(document.documentElement);
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
const tertiaryColor = root.getPropertyValue("--tertiary").trim() || "#5865f2";
const secondaryColor = root.getPropertyValue("--secondary").trim() || "#1877f2";
return (
<div
className="fixed inset-0 pointer-events-none overflow-hidden"
aria-hidden="true"
style={{ zIndex: -1 }}
>
{/* Top-right glow — IMPHNEN Primary #23a1eb */}
<div
className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
style={{
backgroundColor: `${primaryColor}14`,
}}
/>
{/* Bottom-left glow — Discord Tertiary #5865f2 */}
<div
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl"
style={{
backgroundColor: `${tertiaryColor}0f`,
animation: "glowPulse 3s ease-in-out infinite",
animationDelay: "1.5s",
}}
/>
{/* Center-subtle glow — Secondary #1877f2 */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[600px] rounded-full blur-3xl"
style={{
backgroundColor: `${secondaryColor}08`,
}}
/>
</div>
);
}