refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped

- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-07-27 21:54:31 +07:00
co-authored by Claude Opus 4.8
parent 63f21513bd
commit 5802d02e29
223 changed files with 11499 additions and 13350 deletions
@@ -1,165 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { useReanalyze } from "@/hooks";
import { messagesApi } from "@/lib/api";
import { safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { SearchPanel } from "@/components/analysis/search-panel";
export default function AnalysisPage() {
const [query, setQuery] = useState("");
const [enabled, setEnabled] = useState(false);
const reanalyzeMut = useReanalyze();
const { data: results, isFetching } = useQuery<MessageRecord[]>({
queryKey: ["analysis-search", query],
queryFn: async () => {
const result = await messagesApi.search(query, 50);
return result.results;
},
enabled,
});
const handleSearch = useCallback(() => {
if (!query.trim()) return;
setEnabled(true);
}, [query]);
return (
<div className="space-y-5 animate-fade-in-up">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search message content, AI flags, analysis text…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-9 h-9"
/>
</div>
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
Search
</Button>
</div>
{isFetching ? (
<LoadingSkeleton count={5} height="h-28" />
) : results !== undefined ? (
<>
<p className="text-sm text-muted-foreground">
Found {results.length} result{results.length !== 1 ? "s" : ""}
</p>
{results.length === 0 ? (
<EmptyState
icon={Search}
title="No messages found matching your query."
/>
) : (
<div className="space-y-2">
{results.map((msg) => (
<Card key={msg.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
{msg.ai_status && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 h-4",
msg.ai_status === "clean" && "text-green-500",
msg.ai_status === "flagged" && "text-red-500",
)}
>
{msg.ai_status}
</Badge>
)}
</div>
<p className="text-sm leading-relaxed">{msg.content}</p>
{msg.ai_moderation_flags &&
msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map(
(flag) => (
<Badge
key={flag}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{flag}
</Badge>
),
)}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
<Sparkles className="size-3 inline mr-1" />
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress
value={msg.ai_confidence * 100}
className="h-1.5"
/>
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={() => reanalyzeMut.mutate(msg.id)}
>
<RefreshCw className="size-3 mr-1" />
Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Search className="size-12 text-muted-foreground/30 mb-4" />
<p className="text-sm text-muted-foreground">
Enter a search query to find messages across all channels.
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Searches message content, AI flags, and analysis text.
</p>
</div>
)}
<SearchPanel />
</div>
);
}
@@ -1,43 +1,16 @@
"use client";
import { BarChart3, Hash, Users } from "lucide-react";
import { useState } from "react";
import {
AlertCircle,
ArrowLeft,
BarChart3,
ChevronRight,
Clock,
Hash,
Search,
Shield,
Sparkles,
Users,
} from "lucide-react";
import Image from "next/image";
import { useCallback, useState } from "react";
import {
DetailStat,
EmptyState,
ErrorState,
LoadingSkeleton,
StatCard,
} from "@/components/shared";
ChannelDetailSection,
ChannelsSection,
StatsSection,
UserDetailSection,
UsersSection,
} from "@/components/dashboard";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useChannelDetail,
useChannels,
useStats,
useUserDetail,
useUsers,
} from "@/hooks";
import { dashboardApi } from "@/lib/api";
import { formatNumber } from "@/lib/format";
import type { DashboardUser } from "@/lib/types";
type View = "stats" | "users" | "channels" | "user-detail" | "channel-detail";
@@ -109,462 +82,3 @@ export default function DashboardPage() {
</div>
);
}
// ── Stats ──────────────────────────────────────────────────────
function StatsSection() {
const { data: stats, isLoading, error, refetch } = useStats();
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
if (isLoading || !stats)
return (
<div className="space-y-5 animate-fade-in-up">
<LoadingSkeleton count={8} height="h-28" columns={4} />
</div>
);
return (
<div className="space-y-5 animate-fade-in-up">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
label="Total Messages"
value={stats.total_messages}
icon={Hash}
/>
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
<StatCard label="Users" value={stats.total_users} icon={Users} />
<StatCard
label="Active 24h"
value={stats.active_users_24h}
icon={Sparkles}
/>
<StatCard
label="Flagged"
value={stats.total_flagged}
icon={AlertCircle}
variant="danger"
/>
<StatCard
label="Clean"
value={stats.total_clean}
icon={Shield}
variant="success"
/>
<StatCard
label="Voice Recordings"
value={stats.total_voice_recordings}
icon={Hash}
/>
<StatCard
label="AI Profiles"
value={stats.total_profiles}
icon={Sparkles}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Hash className="size-4 text-muted-foreground" /> Top Channels
</CardTitle>
</CardHeader>
<CardContent>
{stats.top_channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No channel data yet.
</p>
) : (
<div className="space-y-2">
{stats.top_channels.map((ch) => {
const max = stats.top_channels[0].message_count;
const pct = max > 0 ? (ch.message_count / max) * 100 : 0;
return (
<div key={ch.channel_id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">
#{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</span>
<span className="text-muted-foreground tabular-nums">
{formatNumber(ch.message_count)}
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-muted-foreground" /> Moderation
Queue
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{[
{
label: "Pending",
value: stats.moderation_overview.pending,
cls: "bg-muted/50",
},
{
label: "Processing",
value: stats.moderation_overview.processing,
cls: "bg-yellow-500/10 text-yellow-500",
},
{
label: "Errors",
value: stats.moderation_overview.error,
cls: "bg-destructive/10 text-destructive",
},
].map(({ label, value, cls }) => (
<div
key={label}
className={`rounded-lg p-3 text-center space-y-1.5 ${cls}`}
>
<div
className={`text-2xl font-bold tabular-nums ${cls.includes("yellow") ? "text-yellow-500" : cls.includes("destructive") ? "text-destructive" : ""}`}
>
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// ── Users ────────────────────────────────────────────────────
function UsersSection({ onSelect }: { onSelect: (id: string) => void }) {
const [search, setSearch] = useState("");
const { data: users, isLoading } = useUsers(search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" columns={2} />
) : !users || users.length === 0 ? (
<EmptyState icon={Users} title="No users found." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{users.map((u) => (
<Card
key={u.user_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(u.user_id)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden ring-1 ring-border">
{u.avatar_url ? (
<Image
src={u.avatar_url}
alt=""
width={40}
height={40}
className="size-full object-cover"
/>
) : (
(u.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{u.username ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{u.total_messages} messages</span>
{u.flagged_count > 0 && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{u.flagged_count} flagged
</Badge>
)}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
// ── User Detail ───────────────────────────────────────────────
function UserDetailSection({
userId,
onBack,
}: {
userId: string;
onBack: () => void;
}) {
const { data: user, isLoading } = useUserDetail(userId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!user) return <ErrorState message="User not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div className="flex items-center gap-4">
<div className="size-14 shrink-0 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden ring-2 ring-border">
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={56}
height={56}
className="size-full object-cover"
/>
) : (
(user.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold">
{user.username ?? "Unknown"}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{user.user_id}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={user.total_messages} />
<DetailStat
label="Flagged"
value={user.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat
label="Trust Score"
value={user.trust_score ?? 0}
suffix="%"
/>
</div>
{user.profile_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
AI Profile
</p>
</div>
<p className="text-sm leading-relaxed">{user.profile_summary}</p>
</div>
)}
{user.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2 max-h-80 overflow-y-auto">
{user.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-2">
<Clock className="size-3" />
{new Date(msg.created_at).toLocaleString()}
</p>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ── Channels ──────────────────────────────────────────────────
function ChannelsSection({
guildId,
onSelect,
}: {
guildId: string;
onSelect: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const {
data: channels,
isLoading,
refetch,
} = useChannels(guildId, search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search channels…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" />
) : !channels || channels.length === 0 ? (
<EmptyState icon={Hash} title="No channels found." />
) : (
<div className="space-y-2">
{channels.map((ch) => (
<Card
key={ch.channel_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(ch.channel_id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-sm font-medium truncate">
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{ch.total_messages} messages
{ch.flagged_count > 0
? ` · ${ch.flagged_count} flagged`
: ""}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0 ml-2" />
</div>
{ch.culture_summary && (
<p className="text-xs text-muted-foreground/70 mt-2 italic line-clamp-2 border-t border-border/50 pt-2">
&ldquo;{ch.culture_summary}&rdquo;
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
// ── Channel Detail ────────────────────────────────────────────
function ChannelDetailSection({
channelId,
onBack,
}: {
channelId: string;
onBack: () => void;
}) {
const { data: channel, isLoading } = useChannelDetail(channelId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!channel) return <ErrorState message="Channel not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Hash className="size-5 text-muted-foreground" />
{channel.channel_name ?? channel.channel_id.slice(0, 8)}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{channel.channel_id}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={channel.total_messages} />
<DetailStat
label="Flagged"
value={channel.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean"
value={channel.clean_count}
variant="success"
/>
</div>
{channel.culture_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Channel Culture
</p>
</div>
<p className="text-sm leading-relaxed italic">
&ldquo;{channel.culture_summary}&rdquo;
</p>
</div>
)}
{channel.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2">
{channel.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,167 +1,14 @@
"use client";
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import {
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaVolume,
useMediaWsSync,
} from "@/hooks";
import { MusicPlayer } from "@/components/media/music-player";
import { useWebSocket } from "@/lib/ws/context";
export default function MediaPage() {
const ws = useWebSocket();
const { data: mediaState } = useMediaState();
const queueMut = useMediaQueue();
const skipMut = useMediaSkip();
const stopMut = useMediaStop();
const volumeMut = useMediaVolume();
const [queueUrl, setQueueUrl] = useState("");
// Sync WS media_state into the query cache
useMediaWsSync(ws);
const handleQueue = useCallback(() => {
if (!queueUrl.trim()) return;
queueMut.mutate(queueUrl.trim());
setQueueUrl("");
}, [queueUrl, queueMut]);
const handleVolume = useCallback(
(value: number | readonly number[]) => {
const vol = Array.isArray(value) ? value[0] : value;
volumeMut.mutate(vol);
},
[volumeMut],
);
return (
<div className="space-y-5 animate-fade-in-up">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Music className="size-4 text-primary" />
Music Player
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Queue a URL (YouTube, audio file…)"
value={queueUrl}
onChange={(e) => setQueueUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9"
/>
<Button
onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending}
>
<Play className="size-4 mr-1.5" />
Queue
</Button>
</div>
{mediaState?.current ? (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
<Disc3 className="size-3" />
Now Playing
</p>
<div className="flex items-start gap-3">
{mediaState.current.thumbnailUrl && (
<Image
src={mediaState.current.thumbnailUrl}
alt=""
width={56}
height={56}
className="size-14 rounded-lg object-cover shadow-sm"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{mediaState.current.title ?? mediaState.current.source}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{mediaState.current.durationMs
? `${Math.floor(mediaState.current.durationMs / 60000)}:${String(Math.floor((mediaState.current.durationMs % 60000) / 1000)).padStart(2, "0")}`
: "Live"}
</p>
</div>
</div>
</div>
) : (
!mediaState?.queue?.length && (
<p className="text-sm text-muted-foreground py-8 text-center">
No media queued. Paste a URL above to start playing.
</p>
)
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => stopMut.mutate()}
>
<Square className="size-4 mr-1" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skipMut.mutate()}
>
<SkipForward className="size-4 mr-1" />
Skip
</Button>
<div className="flex items-center gap-2 ml-auto">
<Volume2 className="size-4 text-muted-foreground" />
<Slider
className="w-24"
defaultValue={[mediaState?.musicVolume ?? 0.5]}
value={[mediaState?.musicVolume ?? 0.5]}
onValueChange={handleVolume}
min={0}
max={1}
step={0.05}
/>
</div>
</div>
{mediaState && mediaState.queue.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground font-medium">
Queue ({mediaState.queue.length})
</p>
<div className="space-y-1">
{mediaState.queue.map((item, i) => (
<div
key={item.id ?? i}
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-2 text-sm"
>
<span className="text-xs text-muted-foreground font-mono w-5 text-right">
{i + 1}.
</span>
<span className="truncate flex-1">
{item.title ?? item.source}
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
<MusicPlayer ws={ws} />
</div>
);
}
@@ -1,24 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
ExternalLink,
Flag,
Hash,
ImageIcon,
Loader2,
MessageSquare,
RefreshCw,
Search,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Flag, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
import { useCallback, useState } from "react";
import { ImagesGrid } from "@/components/messages/images-grid";
import { MessageCard } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { ReviewList } from "@/components/messages/review-list";
import { ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -26,7 +17,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -49,9 +39,7 @@ import {
useTextChannels,
} from "@/hooks";
import { messagesApi } from "@/lib/api";
import { formatBytes, safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export default function MessagesPage() {
@@ -69,7 +57,7 @@ export default function MessagesPage() {
error,
refetch,
} = useMessages(guildId, selectedChannel || undefined);
const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore(
const { data: cursorData } = useMessagesHasMore(
guildId,
selectedChannel || undefined,
);
@@ -89,12 +77,9 @@ export default function MessagesPage() {
loading: detailLoading,
} = useMessageDetail(detailId);
// Images fetch is managed by the query hook (enabled when guildId is set)
// Review fetch is managed by the query hook
// Search query (manual trigger)
const [searchEnabled, setSearchEnabled] = useState(false);
const { data: searchResults, isFetching: searching } = useQuery<
const { data: searchResults } = useQuery<
MessageRecord[]
>({
queryKey: ["messages-search", guildId, searchQuery],
@@ -150,13 +135,13 @@ export default function MessagesPage() {
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => v && setSelectedChannel(v)}
onValueChange={(v) => setSelectedChannel(v ?? "")}
>
<SelectTrigger className="h-9 w-full sm:w-44">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value=" ">All channels</SelectItem>
<SelectItem value="">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
# {ch.name}
@@ -244,72 +229,16 @@ export default function MessagesPage() {
{/* ── IMAGES tab ── */}
{viewTab === "images" && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{!images || images.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<ImageIcon
className="size-10 text-muted-foreground/40 mb-3"
aria-label="No images"
/>
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
) : (
images.map((msg) => {
const imgUrl = extractImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => setDetailId(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imgUrl ? (
<img
src={imgUrl}
alt={msg.content || "Image"}
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
})
)}
</div>
<ImagesGrid images={images ?? []} onSelect={setDetailId} />
)}
{/* ── REVIEW tab ── */}
{viewTab === "review" && (
<div className="space-y-2 animate-fade-in-up">
{!reviews || reviews.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No flagged messages to review.
</p>
</div>
) : (
reviews.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
))
)}
</div>
<ReviewList
reviews={reviews ?? []}
onSelect={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
)}
{/* Detail dialog */}
@@ -329,7 +258,7 @@ export default function MessagesPage() {
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : detailMessage ? (
<DetailView
<MessageDetailView
message={detailMessage}
attachments={detailAttachments}
/>
@@ -340,349 +269,3 @@ export default function MessagesPage() {
</div>
);
}
// ── Message Card ────────────────────────────────────────────────
function MessageCard({
message: msg,
onClick,
onReanalyze,
}: {
message: MessageRecord;
onClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
const severity = (
{
low: "border-l-sky-400",
medium: "border-l-yellow-400",
high: "border-l-orange-400",
critical: "border-l-red-500",
} as Record<string, string>
)[msg.ai_severity ?? ""];
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:bg-accent/5 hover:shadow-sm",
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">
<Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)}
</span>
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{msg.ai_severity}
</Badge>
)}
{msg.type === "deleted" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
deleted
</Badge>
)}
{msg.type === "edited" && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
>
edited
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
msg.type === "deleted" &&
"italic text-muted-foreground line-through",
)}
>
{msg.content}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
if (!u) return null;
return (
<img
src={u}
alt=""
className="mt-2 max-h-48 rounded-lg border border-border/50 object-cover"
/>
);
})()}
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge
key={f}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
onReanalyze(msg.id);
}}
>
<RefreshCw className="size-3 mr-1" /> Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function AiStatusBadge({ status }: { status?: string | null }) {
const c = (
{
clean:
"bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20",
warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
error:
"bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20",
pending:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
processing:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
} as Record<string, string>
)[status ?? ""];
if (!c) return null;
return (
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", c)}
>
{status}
</Badge>
);
}
// ── Detail View ──────────────────────────────────────────────────
function DetailView({
message,
attachments,
}: {
message: MessageRecord;
attachments: {
id: string;
filename: string;
type: string;
size: number;
uploaded_url?: string | null;
discord_url?: string | null;
}[];
}) {
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<Avatar className="size-10">
<AvatarImage src={message.avatar_url ?? undefined} />
<AvatarFallback>
{message.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{message.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleString()}
</span>
{message.type === "deleted" && (
<Badge variant="destructive" className="text-[10px]">
deleted
</Badge>
)}
{message.type === "edited" && (
<Badge variant="outline" className="text-[10px]">
edited
</Badge>
)}
</div>
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
{message.content}
</p>
</div>
</div>
{message.ai_analysis && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium">
AI Analysis
</p>
</div>
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
</div>
)}
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Moderation Flags
</p>
<div className="flex flex-wrap gap-1.5">
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[11px]">
{f}
</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{message.ai_status && (
<MiniStat label="Status" value={message.ai_status} capitalize />
)}
{message.ai_severity && message.ai_severity !== "none" && (
<MiniStat
label="Severity"
value={message.ai_severity}
destructive
capitalize
/>
)}
{message.ai_confidence != null && (
<MiniStat
label="Confidence"
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
/>
)}
{message.ai_recommended_action &&
message.ai_recommended_action !== "none" && (
<MiniStat
label="Action"
value={message.ai_recommended_action}
capitalize
/>
)}
</div>
{attachments.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Attachments ({attachments.length})
</p>
<div className="grid grid-cols-2 gap-2">
{attachments.map((a) => (
<a
key={a.id}
href={a.uploaded_url ?? a.discord_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{a.filename}</p>
<p className="text-[11px] text-muted-foreground">
{a.type} · {formatBytes(a.size)}
</p>
</div>
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
);
}
function MiniStat({
label,
value,
destructive,
capitalize,
}: {
label: string;
value: string;
destructive?: boolean;
capitalize?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-sm font-medium mt-0.5",
capitalize && "capitalize",
destructive && "text-destructive",
)}
>
{value}
</p>
</CardContent>
</Card>
);
}
// ── Helpers ──────────────────────────────────────────────────────
function extractFirstImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
function extractImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
@@ -1,94 +1,14 @@
"use client";
import { Download, Headphones, Trash2 } from "lucide-react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
useDeleteRecording,
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import { formatBytes } from "@/lib/format";
import { RecordingList } from "@/components/recordings/recording-list";
import { useWebSocket } from "@/lib/ws/context";
export default function RecordingsPage() {
const ws = useWebSocket();
const { data: recordings, isLoading } = useRecordings();
const deleteMut = useDeleteRecording();
useRecordingsWsSync(ws);
return (
<div className="space-y-5 animate-fade-in-up">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Headphones className="size-4 text-primary" />
Voice Recordings
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingSkeleton count={5} height="h-16" />
) : !recordings || recordings.length === 0 ? (
<EmptyState icon={Headphones} title="No recordings yet." />
) : (
<div className="space-y-2">
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-3 rounded-lg border border-border/50 p-3 hover:bg-muted/30 transition-colors"
>
<Avatar className="size-8">
<AvatarImage src={rec.avatar_url ?? undefined} />
<AvatarFallback>
{(rec.username ?? "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{rec.username}
</p>
<p className="text-xs text-muted-foreground">
{rec.channel_name ?? rec.channel_id ?? "Unknown channel"}{" "}
— {new Date(rec.created_at).toLocaleString()}
</p>
</div>
<Badge
variant="outline"
className="text-[10px] font-mono shrink-0"
>
{formatBytes(rec.size_bytes)}
</Badge>
{rec.download_url && (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (rec.download_url)
window.open(rec.download_url, "_blank");
}}
>
<Download className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => deleteMut.mutate(rec.id)}
className="hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<RecordingList ws={ws} />
</div>
);
}
@@ -1,26 +1,10 @@
"use client";
import {
Headphones,
Loader2,
Mic,
Radio,
RadioOff,
UserCheck,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
import { MicrophoneCard } from "@/components/voice/microphone-card";
import { VoiceConnectionCard } from "@/components/voice/voice-connection-card";
import {
useGuilds,
useMicTransmit,
@@ -30,20 +14,18 @@ import {
useVoiceDisconnect,
useVoiceStatus,
} from "@/hooks";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export default function VoicePage() {
const ws = useWebSocket();
const { data: voiceStatus } = useVoiceStatus();
const { data: guilds = [] } = useGuilds();
const { channels: voiceChannels, fetch: fetchChannels } = useVoiceChannels();
const [selectedGuild, setSelectedGuild] = useState("");
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const { speakers, subscribe } = useSpeakers();
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit();
const [selectedGuild, setSelectedGuild] = useState("");
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
@@ -52,200 +34,50 @@ export default function VoicePage() {
return () => unsub();
}, [ws, subscribe]);
const handleGuildChange = useCallback(
(guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
setSelectedChannel("");
return;
}
setSelectedGuild(guildId);
const handleGuildChange = useCallback((guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
setSelectedChannel("");
fetchChannels(guildId);
return;
}
setSelectedGuild(guildId);
}, []);
const handleMicToggle = useCallback(
async (checked: boolean) => {
setMicActive(checked);
try {
await micMut.mutateAsync(checked);
} catch {
setMicActive(!checked);
}
},
[fetchChannels],
[micMut],
);
const activeSpeakers = speakers.filter((s) => s.speaking);
const connected = voiceStatus?.connected;
const connected = voiceStatus?.connected ?? false;
return (
<div className="space-y-5 animate-fade-in-up">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Radio className="size-4 text-primary" />
Voice Connection
</div>
<Badge
variant={connected ? "default" : "secondary"}
className={cn(
connected &&
"bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/20",
)}
>
<span
className={cn(
"size-1.5 rounded-full mr-1.5 inline-block",
connected
? "bg-green-500 shadow-[0_0_6px] shadow-green-500/60"
: "bg-muted-foreground",
)}
/>
{connected ? "Connected" : "Disconnected"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{connected && voiceStatus?.activeChannelName && (
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
<Headphones className="size-4" />
Connected to{" "}
<span className="font-medium text-foreground">
{voiceStatus.activeChannelName}
</span>
</p>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Select value={selectedGuild} onValueChange={handleGuildChange}>
<SelectTrigger className="flex-1 h-9">
<SelectValue placeholder="Select guild…" />
</SelectTrigger>
<SelectContent>
{guilds.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedChannel}
onValueChange={(v) => v && setSelectedChannel(v)}
disabled={!selectedGuild}
>
<SelectTrigger className="flex-1 h-9">
<SelectValue placeholder="Select channel…" />
</SelectTrigger>
<SelectContent>
{voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{connected ? (
<Button
variant="destructive"
onClick={() => disconnectMut.mutate()}
disabled={disconnectMut.isPending}
>
{disconnectMut.isPending ? (
<Loader2 className="size-4 animate-spin mr-1.5" />
) : (
<RadioOff className="size-4 mr-1.5" />
)}
Disconnect
</Button>
) : (
<Button
onClick={() =>
connectMut.mutate({
guildId: selectedGuild,
channelId: selectedChannel,
})
}
disabled={
connectMut.isPending || !selectedGuild || !selectedChannel
}
>
{connectMut.isPending ? (
<Loader2 className="size-4 animate-spin mr-1.5" />
) : (
<Radio className="size-4 mr-1.5" />
)}
Connect
</Button>
)}
</div>
</CardContent>
</Card>
{activeSpeakers.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<UserCheck className="size-4 text-primary" />
Active Speakers
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{activeSpeakers.map((s) => (
<div
key={s.userId}
className="flex items-center gap-2 rounded-full border border-border/50 bg-card px-3 py-1.5 shadow-sm"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full rounded-full bg-green-400 opacity-75 live-pulse-ring" />
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
</span>
<span className="text-sm">{s.username}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<Mic className="size-4 text-primary" />
Microphone
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{micActive ? "On" : "Off"}
</span>
<Switch
checked={micActive}
onCheckedChange={async (checked) => {
setMicActive(checked);
try {
await micMut.mutateAsync(checked);
} catch {
setMicActive(!checked);
}
}}
disabled={!connected}
/>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{!connected && (
<p className="text-xs text-muted-foreground">
Connect to a voice channel first.
</p>
)}
{micActive && (
<div className="flex items-center gap-2 mt-1">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full rounded-full bg-red-400 opacity-75 live-pulse-ring" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<span className="text-sm text-muted-foreground">
Transmitting…
</span>
</div>
)}
</CardContent>
</Card>
<VoiceConnectionCard
selectedGuild={selectedGuild}
onGuildChange={handleGuildChange}
selectedChannel={selectedChannel}
onChannelChange={(v) => setSelectedChannel(v)}
guilds={guilds}
voiceChannels={voiceChannels}
connected={connected}
activeChannelName={voiceStatus?.activeChannelName}
connectMut={connectMut}
disconnectMut={disconnectMut}
/>
<ActiveSpeakersPanel activeSpeakers={activeSpeakers} />
<MicrophoneCard
connected={connected}
micActive={micActive}
onMicToggle={handleMicToggle}
/>
</div>
);
}
+52 -41
View File
@@ -39,6 +39,8 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -47,9 +49,9 @@
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* Sky blue accent gradient */
--accent-gradient: linear-gradient(135deg, oklch(0.65 0.18 240), oklch(0.65 0.15 200), oklch(0.65 0.12 180));
--accent-gradient-subtle: linear-gradient(135deg, oklch(0.65 0.18 240 / 0.15), oklch(0.65 0.12 180 / 0.05));
/* Teal-cyan accent gradient for monitoring hub look */
--accent-gradient: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185));
--accent-gradient-subtle: linear-gradient(135deg, oklch(0.62 0.17 215 / 0.15), oklch(0.65 0.12 185 / 0.05));
/* Glass morphism */
--glass-bg: oklch(1 0 0 / 0.05);
@@ -71,9 +73,11 @@
--accent: oklch(0.65 0.15 220);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--warning: oklch(0.7 0.18 75);
--warning-foreground: oklch(0.98 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.65 0.18 240);
--ring: oklch(0.55 0.18 240);
--chart-1: oklch(0.55 0.18 240);
--chart-2: oklch(0.55 0.15 200);
--chart-3: oklch(0.55 0.12 180);
@@ -87,63 +91,68 @@
--sidebar-accent: oklch(0.95 0 0);
--sidebar-accent-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.65 0.18 240);
--sidebar-ring: oklch(0.55 0.18 240);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
/* Deep navy-slate base */
--background: oklch(0.12 0.02 240);
--foreground: oklch(0.92 0.01 240);
/* Deeper navy canvas — monitoring hub feel */
--background: oklch(0.09 0.015 245);
--foreground: oklch(0.93 0.01 245);
/* Slightly lighter card */
--card: oklch(0.16 0.025 240);
--card-foreground: oklch(0.92 0.01 240);
/* Card surface with subtle separation */
--card: oklch(0.13 0.02 245);
--card-foreground: oklch(0.93 0.01 245);
--popover: oklch(0.16 0.025 240);
--popover-foreground: oklch(0.92 0.01 240);
--popover: oklch(0.13 0.02 245);
--popover-foreground: oklch(0.93 0.01 245);
/* Sky blue primary */
--primary: oklch(0.65 0.18 240);
/* Teal-cyan primary */
--primary: oklch(0.62 0.17 215);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.22 0.02 240);
--secondary-foreground: oklch(0.92 0.01 240);
--secondary: oklch(0.2 0.015 245);
--secondary-foreground: oklch(0.93 0.01 245);
--muted: oklch(0.2 0.015 240);
--muted-foreground: oklch(0.6 0.02 240);
--muted: oklch(0.17 0.015 245);
--muted-foreground: oklch(0.55 0.02 245);
--accent: oklch(0.7 0.15 220);
/* Electric blue-purple accent */
--accent: oklch(0.7 0.18 260);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.6 0.22 25);
--border: oklch(1 0 0 / 0.08);
--input: oklch(1 0 0 / 0.12);
/* Amber-gold warning (distinct from red) */
--warning: oklch(0.7 0.17 75);
--warning-foreground: oklch(0.12 0 0);
--ring: oklch(0.65 0.18 240);
--border: oklch(1 0 0 / 0.06);
--input: oklch(1 0 0 / 0.1);
/* Blue-teal-cyan chart palette */
--chart-1: oklch(0.65 0.18 240);
--chart-2: oklch(0.6 0.15 200);
--chart-3: oklch(0.6 0.12 180);
--chart-4: oklch(0.7 0.15 220);
--chart-5: oklch(0.55 0.15 260);
--ring: oklch(0.62 0.17 215);
/* Deeper sidebar */
--sidebar: oklch(0.1 0.015 240);
--sidebar-foreground: oklch(0.92 0.01 240);
--sidebar-primary: oklch(0.65 0.18 240);
/* Teal-cyan chart palette */
--chart-1: oklch(0.62 0.17 215);
--chart-2: oklch(0.6 0.15 195);
--chart-3: oklch(0.65 0.12 185);
--chart-4: oklch(0.7 0.18 260);
--chart-5: oklch(0.55 0.15 280);
/* Deeper sidebar with teal accent */
--sidebar: oklch(0.075 0.01 245);
--sidebar-foreground: oklch(0.93 0.01 245);
--sidebar-primary: oklch(0.62 0.17 215);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.2 0.02 240);
--sidebar-accent-foreground: oklch(0.92 0.01 240);
--sidebar-border: oklch(1 0 0 / 0.06);
--sidebar-ring: oklch(0.65 0.18 240);
--sidebar-accent: oklch(0.16 0.025 215);
--sidebar-accent-foreground: oklch(0.93 0.01 245);
--sidebar-border: oklch(1 0 0 / 0.04);
--sidebar-ring: oklch(0.62 0.17 215);
/* Glass overrides for dark */
--glass-bg: oklch(1 0 0 / 0.05);
--glass-border: oklch(1 0 0 / 0.1);
--glass-bg: oklch(1 0 0 / 0.04);
--glass-border: oklch(1 0 0 / 0.08);
}
@layer base {
@@ -152,6 +161,8 @@
}
body {
@apply bg-background text-foreground;
background-image: radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px);
background-size: 24px 24px;
}
html {
@apply font-sans scroll-smooth;
@@ -159,12 +170,12 @@
/* Custom selection color */
::selection {
background: oklch(0.65 0.18 240 / 0.3);
background: oklch(0.62 0.17 215 / 0.3);
color: inherit;
}
.dark ::selection {
background: oklch(0.65 0.18 240 / 0.4);
background: oklch(0.62 0.17 215 / 0.4);
}
/* Scrollbar styling */
@@ -0,0 +1,166 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { useReanalyze } from "@/hooks";
import { messagesApi } from "@/lib/api";
import { safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
export function SearchPanel() {
const [query, setQuery] = useState("");
const [enabled, setEnabled] = useState(false);
const reanalyzeMut = useReanalyze();
const { data: results, isFetching } = useQuery<MessageRecord[]>({
queryKey: ["analysis-search", query],
queryFn: async () => {
const result = await messagesApi.search(query, 50);
return result.results;
},
enabled,
});
const handleSearch = useCallback(() => {
if (!query.trim()) return;
setEnabled(true);
}, [query]);
return (
<div className="space-y-5 animate-fade-in-up">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search message content, AI flags, analysis text…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-9 h-9"
/>
</div>
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
Search
</Button>
</div>
{isFetching ? (
<LoadingSkeleton count={5} height="h-28" />
) : results !== undefined ? (
<>
<p className="text-sm text-muted-foreground">
Found {results.length} result{results.length !== 1 ? "s" : ""}
</p>
{results.length === 0 ? (
<EmptyState
icon={Search}
title="No messages found matching your query."
/>
) : (
<div className="space-y-2">
{results.map((msg) => (
<Card key={msg.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
{msg.ai_status && (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 h-4",
msg.ai_status === "clean" && "text-green-500",
msg.ai_status === "flagged" && "text-red-500",
)}
>
{msg.ai_status}
</Badge>
)}
</div>
<p className="text-sm leading-relaxed">{msg.content}</p>
{msg.ai_moderation_flags &&
msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map(
(flag) => (
<Badge
key={flag}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{flag}
</Badge>
),
)}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
<Sparkles className="size-3 inline mr-1" />
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress
value={msg.ai_confidence * 100}
className="h-1.5"
/>
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={() => reanalyzeMut.mutate(msg.id)}
>
<RefreshCw className="size-3 mr-1" />
Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Search className="size-12 text-muted-foreground/30 mb-4" />
<p className="text-sm text-muted-foreground">
Enter a search query to find messages across all channels.
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Searches message content, AI flags, and analysis text.
</p>
</div>
)}
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Bot,
Loader2,
@@ -30,16 +31,28 @@ export function Chatbot() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatHistoryMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
const { data: historyMessages = [] } = useQuery({
queryKey: ["chatbot-history"],
queryFn: () => chatbotApi.getHistory(),
enabled: open,
});
useEffect(() => {
if (!open) return;
chatbotApi
.getHistory()
.then(setMessages)
.catch(() => {});
}, [open]);
if (historyMessages.length > 0) setMessages(historyMessages);
}, [historyMessages]);
const sendMut = useMutation({
mutationFn: (text: string) => chatbotApi.send(text),
onSuccess: () => qc.invalidateQueries({ queryKey: ["chatbot-history"] }),
});
const clearMut = useMutation({
mutationFn: () => chatbotApi.clearHistory(),
onSuccess: () => qc.setQueryData(["chatbot-history"], []),
});
useEffect(() => {
if (scrollRef.current) {
@@ -47,18 +60,13 @@ export function Chatbot() {
}
}, []);
const handleClear = useCallback(async () => {
try {
await chatbotApi.clearHistory();
setMessages([]);
} catch (err) {
console.error("chatbot/clearHistory:", err);
}
}, []);
const handleClear = useCallback(() => {
clearMut.mutate();
setMessages([]);
}, [clearMut]);
const handleSend = useCallback(async () => {
if (!input.trim() || sending) return;
setSending(true);
if (!input.trim() || sendMut.isPending) return;
const text = input.trim();
setInput("");
@@ -69,7 +77,7 @@ export function Chatbot() {
]);
try {
const resp = await chatbotApi.send(text);
const resp = await sendMut.mutateAsync(text);
setMessages((prev) => [
...prev,
{
@@ -78,8 +86,7 @@ export function Chatbot() {
timestamp: resp.timestamp,
},
]);
} catch (err) {
console.error("chatbot/send:", err);
} catch {
setMessages((prev) => [
...prev,
{
@@ -88,10 +95,8 @@ export function Chatbot() {
timestamp: new Date().toISOString(),
},
]);
} finally {
setSending(false);
}
}, [input, sending]);
}, [input, sendMut]);
return (
<>
@@ -170,7 +175,7 @@ export function Chatbot() {
</div>
</div>
))}
{sending && (
{sendMut.isPending && (
<div className="flex items-start gap-2">
<Avatar className="size-6 shrink-0">
<AvatarFallback className="text-[10px] bg-muted">
@@ -199,15 +204,15 @@ export function Chatbot() {
placeholder="Ask the mascot…"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={sending}
disabled={sendMut.isPending}
className="h-8 flex-1"
/>
<Button
type="submit"
size="icon-sm"
disabled={!input.trim() || sending}
disabled={!input.trim() || sendMut.isPending}
>
{sending ? (
{sendMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
@@ -0,0 +1,93 @@
"use client";
import { ArrowLeft, Clock, Hash, Sparkles } from "lucide-react";
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useChannelDetail } from "@/hooks";
export function ChannelDetailSection({
channelId,
onBack,
}: {
channelId: string;
onBack: () => void;
}) {
const { data: channel, isLoading } = useChannelDetail(channelId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!channel) return <ErrorState message="Channel not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Hash className="size-5 text-muted-foreground" />
{channel.channel_name ?? channel.channel_id.slice(0, 8)}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{channel.channel_id}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={channel.total_messages} />
<DetailStat
label="Flagged"
value={channel.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean"
value={channel.clean_count}
variant="success"
/>
</div>
{channel.culture_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Channel Culture
</p>
</div>
<p className="text-sm leading-relaxed italic">
&ldquo;{channel.culture_summary}&rdquo;
</p>
</div>
)}
{channel.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2">
{channel.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
{msg.username}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { ChevronRight, Hash, Search } from "lucide-react";
import { useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useChannels } from "@/hooks";
export function ChannelsSection({
guildId,
onSelect,
}: {
guildId: string;
onSelect: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const {
data: channels,
isLoading,
refetch,
} = useChannels(guildId, search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search channels…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" />
) : !channels || channels.length === 0 ? (
<EmptyState icon={Hash} title="No channels found." />
) : (
<div className="space-y-2">
{channels.map((ch) => (
<Card
key={ch.channel_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(ch.channel_id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-sm font-medium truncate">
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{ch.total_messages} messages
{ch.flagged_count > 0
? ` · ${ch.flagged_count} flagged`
: ""}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0 ml-2" />
</div>
{ch.culture_summary && (
<p className="text-xs text-muted-foreground/70 mt-2 italic line-clamp-2 border-t border-border/50 pt-2">
&ldquo;{ch.culture_summary}&rdquo;
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,5 @@
export { ChannelDetailSection } from "./channel-detail-section";
export { ChannelsSection } from "./channels-section";
export { StatsSection } from "./stats-section";
export { UserDetailSection } from "./user-detail-section";
export { UsersSection } from "./users-section";
@@ -0,0 +1,145 @@
"use client";
import {
AlertCircle,
Clock,
Hash,
Shield,
Sparkles,
Users,
} from "lucide-react";
import { ErrorState, LoadingSkeleton, StatCard } from "@/components/shared";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useStats } from "@/hooks";
import { formatNumber } from "@/lib/format";
export function StatsSection() {
const { data: stats, isLoading, error, refetch } = useStats();
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
if (isLoading || !stats)
return (
<div className="space-y-5 animate-fade-in-up">
<LoadingSkeleton count={8} height="h-28" columns={4} />
</div>
);
return (
<div className="space-y-5 animate-fade-in-up">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
label="Total Messages"
value={stats.total_messages}
icon={Hash}
/>
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
<StatCard label="Users" value={stats.total_users} icon={Users} />
<StatCard
label="Active 24h"
value={stats.active_users_24h}
icon={Sparkles}
/>
<StatCard
label="Flagged"
value={stats.total_flagged}
icon={AlertCircle}
variant="danger"
/>
<StatCard
label="Clean"
value={stats.total_clean}
icon={Shield}
variant="success"
/>
<StatCard
label="Voice Recordings"
value={stats.total_voice_recordings}
icon={Hash}
/>
<StatCard
label="AI Profiles"
value={stats.total_profiles}
icon={Sparkles}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Hash className="size-4 text-muted-foreground" /> Top Channels
</CardTitle>
</CardHeader>
<CardContent>
{stats.top_channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No channel data yet.
</p>
) : (
<div className="space-y-2">
{stats.top_channels.map((ch) => {
const max = stats.top_channels[0].message_count;
const pct = max > 0 ? (ch.message_count / max) * 100 : 0;
return (
<div key={ch.channel_id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">
#{ch.channel_name ?? ch.channel_id.slice(0, 8)}
</span>
<span className="text-muted-foreground tabular-nums">
{formatNumber(ch.message_count)}
</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-muted-foreground" /> Moderation
Queue
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{[
{
label: "Pending",
value: stats.moderation_overview.pending,
cls: "bg-muted/50",
},
{
label: "Processing",
value: stats.moderation_overview.processing,
cls: "bg-yellow-500/10 text-yellow-500",
},
{
label: "Errors",
value: stats.moderation_overview.error,
cls: "bg-destructive/10 text-destructive",
},
].map(({ label, value, cls }) => (
<div
key={label}
className={`rounded-lg p-3 text-center space-y-1.5 ${cls}`}
>
<div
className={`text-2xl font-bold tabular-nums ${cls.includes("yellow") ? "text-yellow-500" : cls.includes("destructive") ? "text-destructive" : ""}`}
>
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { ArrowLeft, Clock, Sparkles } from "lucide-react";
import Image from "next/image";
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useUserDetail } from "@/hooks";
export function UserDetailSection({
userId,
onBack,
}: {
userId: string;
onBack: () => void;
}) {
const { data: user, isLoading } = useUserDetail(userId);
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
if (!user) return <ErrorState message="User not found." />;
return (
<div className="space-y-5 animate-fade-in-up">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> Back
</Button>
<Card>
<CardContent className="p-6 space-y-5">
<div className="flex items-center gap-4">
<div className="size-14 shrink-0 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden ring-2 ring-border">
{user.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={56}
height={56}
className="size-full object-cover"
/>
) : (
(user.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold">
{user.username ?? "Unknown"}
</h2>
<p className="text-xs text-muted-foreground font-mono">
{user.user_id}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<DetailStat label="Messages" value={user.total_messages} />
<DetailStat
label="Flagged"
value={user.flagged_count}
variant="danger"
/>
<DetailStat
label="Clean Streak"
value={user.clean_message_streak ?? 0}
/>
<DetailStat
label="Trust Score"
value={user.trust_score ?? 0}
suffix="%"
/>
</div>
{user.profile_summary && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
AI Profile
</p>
</div>
<p className="text-sm leading-relaxed">{user.profile_summary}</p>
</div>
)}
{user.recent_messages.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" /> Recent
Messages
</h3>
<div className="space-y-2 max-h-80 overflow-y-auto">
{user.recent_messages.slice(0, 5).map((msg) => (
<div
key={msg.id}
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
>
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-2">
<Clock className="size-3" />
{new Date(msg.created_at).toLocaleString()}
</p>
<p className="text-sm">{msg.content}</p>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { ChevronRight, Search, Users } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useUsers } from "@/hooks";
export function UsersSection({ onSelect }: { onSelect: (id: string) => void }) {
const [search, setSearch] = useState("");
const { data: users, isLoading } = useUsers(search || undefined);
return (
<div className="space-y-4 animate-fade-in-up">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{isLoading ? (
<LoadingSkeleton count={6} height="h-20" columns={2} />
) : !users || users.length === 0 ? (
<EmptyState icon={Users} title="No users found." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{users.map((u) => (
<Card
key={u.user_id}
className="cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => onSelect(u.user_id)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden ring-1 ring-border">
{u.avatar_url ? (
<Image
src={u.avatar_url}
alt=""
width={40}
height={40}
className="size-full object-cover"
/>
) : (
(u.username ?? "?").charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{u.username ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{u.total_messages} messages</span>
{u.flagged_count > 0 && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{u.flagged_count} flagged
</Badge>
)}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { dashboardApi, voiceApi } from "@/lib/api";
import { cn } from "@/lib/utils";
interface LiveStats {
totalMessages: number | null;
todayMessages: number | null;
totalFlagged: number | null;
totalRecordings: number | null;
guildCount: number;
wsConnected: boolean;
}
export function LiveStats() {
const [stats, setStats] = useState<LiveStats>({
totalMessages: null,
todayMessages: null,
totalFlagged: null,
totalRecordings: null,
guildCount: 0,
wsConnected: false,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetch() {
try {
const [dashStats, guilds] = await Promise.all([
dashboardApi.getStats().catch(() => null),
voiceApi.getGuilds().catch(() => [] as { id: string }[]),
]);
if (cancelled) return;
setStats({
totalMessages: dashStats?.total_messages ?? null,
todayMessages: dashStats?.today_messages ?? null,
totalFlagged: dashStats?.total_flagged ?? null,
totalRecordings: dashStats?.total_voice_recordings ?? null,
guildCount: guilds.length,
wsConnected: false,
});
} catch (err) {
console.error("live-stats:", err);
} finally {
if (!cancelled) setLoading(false);
}
}
fetch();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
);
}
const items = [
{
label: "Messages Captured",
value: stats.totalMessages ?? "—",
color: "from-sky-500/20 to-cyan-500/10 border-sky-500/30",
},
{
label: "Today",
value: stats.todayMessages ?? "—",
color: "from-emerald-500/20 to-teal-500/10 border-emerald-500/30",
},
{
label: "Flagged",
value: stats.totalFlagged ?? "—",
color: "from-rose-500/20 to-pink-500/10 border-rose-500/30",
},
{
label: "Voice Recordings",
value: stats.totalRecordings ?? "—",
color: "from-violet-500/20 to-purple-500/10 border-violet-500/30",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{items.map((item) => (
<div
key={item.label}
className={cn(
"rounded-xl border bg-gradient-to-br p-4 text-center backdrop-blur-sm",
item.color,
)}
>
<div className="text-2xl md:text-3xl font-bold tabular-nums tracking-tight">
{typeof item.value === "number"
? item.value.toLocaleString()
: item.value}
</div>
<div className="text-xs text-muted-foreground mt-1">{item.label}</div>
</div>
))}
<div className="col-span-full text-center mt-2">
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full rounded-full bg-green-400 opacity-75 animate-ping" />
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
</span>
{stats.guildCount > 0
? `Monitoring ${stats.guildCount} guild${stats.guildCount > 1 ? "s" : ""}`
: "Connecting to gateway…"}
</div>
</div>
</div>
);
}
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { navItems } from "@/lib/navigation";
import { isActivePath, navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
@@ -31,11 +31,7 @@ export function AppHeader() {
const pageTitle =
navItems
.filter((n) =>
n.matchPrefix === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(n.matchPrefix),
)
.filter((n) => isActivePath(pathname, n.matchPrefix))
.map((n) => n.label)
.at(0) ?? "Dashboard";
@@ -55,7 +51,7 @@ export function AppHeader() {
return (
<>
<header className="flex h-14 items-center gap-3 border-b border-border/50 bg-background/60 backdrop-blur-lg px-4 shrink-0">
<header className="flex h-14 items-center gap-3 border-b border-border/50 bg-background/70 backdrop-blur-xl px-4 shrink-0 shadow-[0_1px_0_0_oklch(0.62_0.17_215_/_0.06)]">
{/* Mobile menu button */}
<Button
variant="ghost"
@@ -67,25 +63,25 @@ export function AppHeader() {
<PanelLeft className="size-4" />
</Button>
<h1 className="text-sm font-semibold">{pageTitle}</h1>
<h1 className="text-base font-semibold tracking-tight">{pageTitle}</h1>
<div className="flex-1" />
<Badge
variant={statusVariant}
className="gap-1.5 px-2.5 py-1 cursor-default select-none"
className="gap-1.5 px-2.5 py-1 cursor-default select-none text-xs"
>
<span
className={cn(
"size-1.5 rounded-full",
status === "connected" &&
"bg-green-500 shadow-[0_0_6px] shadow-green-500/60",
status === "connecting" && "bg-yellow-500 animate-pulse",
"bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60",
status === "connecting" && "bg-amber-400 animate-pulse",
status === "disconnected" && "bg-destructive",
status === "error" && "bg-destructive",
)}
/>
<span className="hidden sm:inline text-xs">{statusLabel}</span>
<span className="hidden sm:inline">{statusLabel}</span>
</Badge>
<Button
@@ -93,7 +89,7 @@ export function AppHeader() {
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
className="size-8"
className="size-8 text-muted-foreground hover:text-foreground"
>
<Sun
className={cn(
@@ -119,7 +115,7 @@ export function AppHeader() {
<div className="fixed inset-0 z-50 md:hidden">
{/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */}
<div
className="absolute inset-0 bg-black/50"
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
role="button"
tabIndex={0}
onClick={() => setMobileOpen(false)}
@@ -127,7 +123,15 @@ export function AppHeader() {
if (e.key === "Enter" || e.key === " ") setMobileOpen(false);
}}
/>
<aside className="absolute left-0 top-0 bottom-0 w-60 bg-sidebar border-r border-sidebar-border p-2 space-y-0.5">
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-sidebar border-r border-sidebar-border p-2 space-y-0.5">
<div className="flex h-14 items-center gap-3 px-3 mb-1 border-b border-sidebar-border/50">
<div className="flex size-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold">
D
</div>
<span className="text-sm font-bold text-gradient">
Discord Automod
</span>
</div>
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActivePath(pathname, matchPrefix);
return (
@@ -139,14 +143,19 @@ export function AppHeader() {
setMobileOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all text-left",
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-all text-left",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50",
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
)}
>
<Icon className="size-4 shrink-0" />
<Icon
className={cn("size-4 shrink-0", active && "text-cyan-400")}
/>
<span>{label}</span>
{active && (
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500" />
)}
</button>
);
})}
@@ -156,8 +165,3 @@ export function AppHeader() {
</>
);
}
function isActivePath(pathname: string, prefix: string) {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
}
@@ -2,7 +2,7 @@
import { usePathname, useRouter } from "next/navigation";
import { navItems } from "@/lib/navigation";
import { isActivePath, navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
@@ -11,14 +11,10 @@ export function AppSidebar() {
const router = useRouter();
const { status } = useWebSocket();
const isActive = (prefix: string) => {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
};
const connectionDot = {
connected: "bg-green-500",
connecting: "bg-yellow-500 animate-pulse",
connected:
"bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse",
connecting: "bg-amber-400 animate-pulse",
disconnected: "bg-destructive",
error: "bg-destructive",
}[status];
@@ -31,18 +27,18 @@ export function AppSidebar() {
}[status];
return (
<aside className="hidden md:flex md:w-60 flex-col border-r border-border/50 bg-sidebar shrink-0">
<aside className="hidden md:flex md:w-64 flex-col border-r border-border/50 bg-sidebar shrink-0">
{/* Brand */}
<div className="flex h-14 items-center gap-2.5 border-b border-sidebar-border/50 px-4 shrink-0">
<div className="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-cyan-400 text-white text-xs font-bold">
<div className="flex h-14 items-center gap-3 border-b border-sidebar-border/50 px-4 shrink-0">
<div className="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold shadow-lg shadow-cyan-500/20">
D
</div>
<div>
<div className="text-sm font-bold tracking-tight">
<span className="text-gradient">Discord Automod</span>
</div>
<div className="text-[10px] text-muted-foreground tracking-widest uppercase leading-none">
Dashboard
<div className="text-[10px] text-muted-foreground/60 tracking-widest uppercase leading-none">
Monitor
</div>
</div>
</div>
@@ -50,28 +46,28 @@ export function AppSidebar() {
{/* Nav */}
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
const active = isActivePath(pathname, matchPrefix);
return (
<button
key={href}
type="button"
onClick={() => router.push(href)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all duration-150 text-left",
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all duration-150 text-left group relative",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
)}
>
<Icon
className={cn(
"size-4 shrink-0 transition-all",
active && "text-sky-400",
active && "text-cyan-400",
)}
/>
<span className="truncate">{label}</span>
{active && (
<div className="ml-auto w-0.5 h-4 rounded-full bg-gradient-to-b from-sky-400 to-cyan-400" />
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500 shadow-[0_0_8px] shadow-cyan-400/60" />
)}
</button>
);
@@ -80,23 +76,22 @@ export function AppSidebar() {
{/* Connection status */}
<div className="border-t border-sidebar-border/50 p-3 shrink-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="relative flex size-2 shrink-0">
<span
className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connectionDot,
status === "connected" && "animate-ping",
)}
/>
<span
className={cn(
"relative inline-flex size-2 rounded-full",
connectionDot,
status === "connected" ? "bg-emerald-500" : connectionDot,
)}
/>
</span>
<span className="text-xs text-muted-foreground truncate">
<span className="text-xs text-muted-foreground/70 truncate font-medium tracking-wide">
{connectionLabel}
</span>
</div>
@@ -3,35 +3,30 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { mobileNavItems } from "@/lib/navigation";
import { isActivePath, mobileNavItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
export function MobileNav() {
const pathname = usePathname();
const isActive = (prefix: string) => {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
};
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-lg">
<div className="flex">
{mobileNavItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
const active = isActivePath(pathname, matchPrefix);
return (
<Link
key={href}
href={href}
className={cn(
"flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all relative",
active ? "text-sky-400" : "text-muted-foreground",
"flex-1 flex flex-col items-center gap-1 py-2 text-[11px] font-medium transition-all relative",
active ? "text-cyan-400" : "text-muted-foreground/60",
)}
>
<Icon className="size-5" />
<span>{label}</span>
{active && (
<span className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-gradient-to-r from-sky-400 to-cyan-400" />
<span className="absolute -top-px left-1/2 -translate-x-1/2 size-1 rounded-full bg-cyan-400 shadow-[0_0_6px] shadow-cyan-400/80" />
)}
</Link>
);
@@ -0,0 +1,160 @@
"use client";
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import {
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaVolume,
useMediaWsSync,
} from "@/hooks";
import type { WsHook } from "@/lib/ws-hook";
interface MusicPlayerProps {
ws: WsHook;
}
export function MusicPlayer({ ws }: MusicPlayerProps) {
const { data: mediaState } = useMediaState();
const queueMut = useMediaQueue();
const skipMut = useMediaSkip();
const stopMut = useMediaStop();
const volumeMut = useMediaVolume();
const [queueUrl, setQueueUrl] = useState("");
// Sync WS media_state into the query cache
useMediaWsSync(ws);
const handleQueue = useCallback(() => {
if (!queueUrl.trim()) return;
queueMut.mutate(queueUrl.trim());
setQueueUrl("");
}, [queueUrl, queueMut]);
const handleVolume = useCallback(
(value: number | readonly number[]) => {
const vol = Array.isArray(value) ? value[0] : value;
volumeMut.mutate(vol);
},
[volumeMut],
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Music className="size-4 text-primary" />
Music Player
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Queue a URL (YouTube, audio file…)"
value={queueUrl}
onChange={(e) => setQueueUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9"
/>
<Button
onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending}
>
<Play className="size-4 mr-1.5" />
Queue
</Button>
</div>
{mediaState?.current ? (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
<Disc3 className="size-3" />
Now Playing
</p>
<div className="flex items-start gap-3">
{mediaState.current.thumbnailUrl && (
<Image
src={mediaState.current.thumbnailUrl}
alt=""
width={56}
height={56}
className="size-14 rounded-lg object-cover shadow-sm"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{mediaState.current.title ?? mediaState.current.source}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{mediaState.current.durationMs
? `${Math.floor(mediaState.current.durationMs / 60000)}:${String(Math.floor((mediaState.current.durationMs % 60000) / 1000)).padStart(2, "0")}`
: "Live"}
</p>
</div>
</div>
</div>
) : (
!mediaState?.queue?.length && (
<p className="text-sm text-muted-foreground py-8 text-center">
No media queued. Paste a URL above to start playing.
</p>
)
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => stopMut.mutate()}>
<Square className="size-4 mr-1" />
Stop
</Button>
<Button variant="outline" size="sm" onClick={() => skipMut.mutate()}>
<SkipForward className="size-4 mr-1" />
Skip
</Button>
<div className="flex items-center gap-2 ml-auto">
<Volume2 className="size-4 text-muted-foreground" />
<Slider
className="w-24"
defaultValue={[mediaState?.musicVolume ?? 0.5]}
value={[mediaState?.musicVolume ?? 0.5]}
onValueChange={handleVolume}
min={0}
max={1}
step={0.05}
/>
</div>
</div>
{mediaState && mediaState.queue.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground font-medium">
Queue ({mediaState.queue.length})
</p>
<div className="space-y-1">
{mediaState.queue.map((item, i) => (
<div
key={item.id ?? i}
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-2 text-sm"
>
<span className="text-xs text-muted-foreground font-mono w-5 text-right">
{i + 1}.
</span>
<span className="truncate flex-1">
{item.title ?? item.source}
</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,28 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const STATUS_STYLES: Record<string, string> = {
clean:
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
warn: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20",
pending: "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400 border-cyan-500/20",
processing:
"bg-cyan-500/15 text-cyan-600 dark:text-cyan-400 border-cyan-500/20",
};
export function AiStatusBadge({ status }: { status?: string | null }) {
const style = STATUS_STYLES[status ?? ""];
if (!style) return null;
return (
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", style)}
>
{status}
</Badge>
);
}
@@ -0,0 +1,62 @@
"use client";
import { ImageIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
import type { MessageRecord } from "@/lib/types";
import { extractFirstImage } from "./message-card";
export function ImagesGrid({
images,
onSelect,
}: {
images: MessageRecord[];
onSelect: (id: string) => void;
}) {
if (!images || images.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<ImageIcon
className="size-10 text-muted-foreground/40 mb-3"
aria-label="No images"
/>
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{images.map((msg) => {
const imgUrl = extractFirstImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => onSelect(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imgUrl ? (
<img
src={imgUrl}
alt={msg.content || "Image"}
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { Hash, RefreshCw } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiStatusBadge } from "./ai-status-badge";
export function MessageCard({
message: msg,
onClick,
onReanalyze,
}: {
message: MessageRecord;
onClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
const severity = (
{
low: "border-l-cyan-500/40",
medium: "border-l-amber-500/60",
high: "border-l-orange-500/70",
critical: "border-l-red-500/80",
} as Record<string, string>
)[msg.ai_severity ?? ""];
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:shadow-[0_0_16px_oklch(0.62_0.17_215_/_0.08)] hover:border-cyan-500/20",
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">
<Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)}
</span>
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{msg.ai_severity}
</Badge>
)}
{msg.type === "deleted" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
deleted
</Badge>
)}
{msg.type === "edited" && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
>
edited
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
msg.type === "deleted" &&
"italic text-muted-foreground line-through",
)}
>
{msg.content}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
if (!u) return null;
return (
<img
src={u}
alt=""
className="mt-2 max-h-48 rounded-lg border border-border/50 object-cover"
/>
);
})()}
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge
key={f}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
onReanalyze(msg.id);
}}
>
<RefreshCw className="size-3 mr-1" /> Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
export function extractFirstImage(
metadata: string | null | undefined,
): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
@@ -0,0 +1,165 @@
"use client";
import { ExternalLink, Sparkles } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { formatBytes, safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
function MiniStat({
label,
value,
destructive,
capitalize,
}: {
label: string;
value: string;
destructive?: boolean;
capitalize?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-sm font-medium mt-0.5",
capitalize && "capitalize",
destructive && "text-destructive",
)}
>
{value}
</p>
</CardContent>
</Card>
);
}
export function MessageDetailView({
message,
attachments,
}: {
message: MessageRecord;
attachments: {
id: string;
filename: string;
type: string;
size: number;
uploaded_url?: string | null;
discord_url?: string | null;
}[];
}) {
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<Avatar className="size-10">
<AvatarImage src={message.avatar_url ?? undefined} />
<AvatarFallback>
{message.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{message.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleString()}
</span>
{message.type === "deleted" && (
<Badge variant="destructive" className="text-[10px]">
deleted
</Badge>
)}
{message.type === "edited" && (
<Badge variant="outline" className="text-[10px]">
edited
</Badge>
)}
</div>
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
{message.content}
</p>
</div>
</div>
{message.ai_analysis && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium">
AI Analysis
</p>
</div>
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
</div>
)}
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Moderation Flags
</p>
<div className="flex flex-wrap gap-1.5">
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[11px]">
{f}
</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{message.ai_status && (
<MiniStat label="Status" value={message.ai_status} capitalize />
)}
{message.ai_severity && message.ai_severity !== "none" && (
<MiniStat
label="Severity"
value={message.ai_severity}
destructive
capitalize
/>
)}
{message.ai_confidence != null && (
<MiniStat
label="Confidence"
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
/>
)}
{message.ai_recommended_action &&
message.ai_recommended_action !== "none" && (
<MiniStat
label="Action"
value={message.ai_recommended_action}
capitalize
/>
)}
</div>
{attachments.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Attachments ({attachments.length})
</p>
<div className="grid grid-cols-2 gap-2">
{attachments.map((a) => (
<a
key={a.id}
href={a.uploaded_url ?? a.discord_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{a.filename}</p>
<p className="text-[11px] text-muted-foreground">
{a.type} · {formatBytes(a.size)}
</p>
</div>
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { Flag } from "lucide-react";
import type { MessageRecord } from "@/lib/types";
import { MessageCard } from "./message-card";
export function ReviewList({
reviews,
onSelect,
onReanalyze,
}: {
reviews: MessageRecord[];
onSelect: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
if (!reviews || reviews.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No flagged messages to review.
</p>
</div>
);
}
return (
<div className="space-y-2 animate-fade-in-up">
{reviews.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={onSelect}
onReanalyze={onReanalyze}
/>
))}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Download, Headphones, Trash2 } from "lucide-react";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
useDeleteRecording,
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import { formatBytes } from "@/lib/format";
import type { WsHook } from "@/lib/ws-hook";
interface RecordingListProps {
ws: WsHook;
}
export function RecordingList({ ws }: RecordingListProps) {
const { data: recordings, isLoading } = useRecordings();
const deleteMut = useDeleteRecording();
useRecordingsWsSync(ws);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Headphones className="size-4 text-primary" />
Voice Recordings
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingSkeleton count={5} height="h-16" />
) : !recordings || recordings.length === 0 ? (
<EmptyState icon={Headphones} title="No recordings yet." />
) : (
<div className="space-y-2">
{recordings.map((rec) => (
<div
key={rec.id}
className="flex items-center gap-3 rounded-lg border border-border/50 p-3 hover:bg-muted/30 transition-colors"
>
<Avatar className="size-8">
<AvatarImage src={rec.avatar_url ?? undefined} />
<AvatarFallback>
{(rec.username ?? "?").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{rec.username}</p>
<p className="text-xs text-muted-foreground">
{rec.channel_name ?? rec.channel_id ?? "Unknown channel"} —{" "}
{new Date(rec.created_at).toLocaleString()}
</p>
</div>
<Badge
variant="outline"
className="text-[10px] font-mono shrink-0"
>
{formatBytes(rec.size_bytes)}
</Badge>
{rec.download_url && (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (rec.download_url)
window.open(rec.download_url, "_blank");
}}
>
<Download className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => deleteMut.mutate(rec.id)}
className="hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -9,6 +9,12 @@ interface DetailStatProps {
suffix?: string;
}
const valueColor = {
default: "",
danger: "text-red-400",
success: "text-emerald-400",
};
/**
* Small stat label used inside detail views.
*/
@@ -19,15 +25,13 @@ export function DetailStat({
suffix,
}: DetailStatProps) {
return (
<Card>
<Card className="bg-gradient-to-br from-cyan-500/5 to-transparent border-cyan-500/10">
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xs text-muted-foreground/70 tracking-wide">
{label}
</p>
<p
className={cn(
"text-lg font-bold tabular-nums",
variant === "danger" && "text-destructive",
variant === "success" && "text-green-500",
)}
className={cn("text-lg font-bold tabular-nums", valueColor[variant])}
>
{formatNumber(value)}
{suffix}
@@ -1,7 +1,7 @@
"use client";
import { AlertCircle, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { configApi, voiceApi } from "@/lib/api";
import type { Guild } from "@/lib/types";
import { useConfig, useGuilds } from "@/hooks";
export interface GuildSelectorProps {
/** Currently selected guild ID */
@@ -34,51 +33,22 @@ export function GuildSelector({
onChange,
autoHide = true,
}: GuildSelectorProps) {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { data: guilds = [], isLoading, error, refetch } = useGuilds();
const { data: config } = useConfig();
const initDone = useRef(false);
const fetchGuilds = useCallback(() => {
setLoading(true);
setError(null);
const tryAutoSelect = (list: Guild[]) => {
if (value || list.length === 0 || initDone.current) return;
initDone.current = true;
// Try config's monitorGuildId first, then fall back to first guild
configApi
.get()
.then((cfg) => {
const preferred = cfg.monitorGuildId ?? list[0].id;
if (preferred) onChange(preferred);
})
.catch(() => {
onChange(list[0].id);
});
};
voiceApi
.getGuilds()
.then((list) => {
setGuilds(list);
tryAutoSelect(list);
})
.catch((err) =>
setError(err instanceof Error ? err.message : "Failed to load guilds"),
)
.finally(() => setLoading(false));
}, [value, onChange]);
useEffect(() => {
fetchGuilds();
}, [fetchGuilds]);
if (value || guilds.length === 0 || initDone.current) return;
initDone.current = true;
const preferred = config?.monitorGuildId ?? guilds[0].id;
if (preferred) onChange(preferred);
}, [value, guilds, config, onChange]);
// Auto-hide when there's exactly one guild and autoHide is on
if (autoHide && guilds.length <= 1 && !loading && !error) return null;
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
if (loading) {
if (isLoading) {
return (
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3">
<Skeleton className="h-8 w-36" />
@@ -93,10 +63,10 @@ export function GuildSelector({
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-destructive shrink-0" />
<p className="text-sm text-muted-foreground">
Could not load guilds: {error}
Could not load guilds: {error?.message ?? "Failed to load"}
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchGuilds}>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
@@ -26,7 +26,7 @@ export function LoadingSkeleton({
<div
className={cn(
"grid gap-3",
columns > 1 ? `grid-cols-1 md:grid-cols-${columns}` : "grid-cols-1",
columns > 1 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1",
className,
)}
>
@@ -8,9 +8,30 @@ interface StatCardProps {
label: string;
value: number;
icon: LucideIcon;
variant?: "default" | "danger" | "success";
variant?: "default" | "danger" | "success" | "warning";
}
const variantStyles = {
default: "from-cyan-500/10 to-teal-500/5 border-cyan-500/20",
danger: "from-red-500/10 to-rose-500/5 border-red-500/20",
success: "from-emerald-500/10 to-green-500/5 border-emerald-500/20",
warning: "from-amber-500/10 to-yellow-500/5 border-amber-500/20",
};
const iconBg = {
default: "bg-cyan-500/15 text-cyan-400",
danger: "bg-red-500/15 text-red-400",
success: "bg-emerald-500/15 text-emerald-400",
warning: "bg-amber-500/15 text-amber-400",
};
const valueColor = {
default: "",
danger: "text-red-400",
success: "text-emerald-400",
warning: "text-amber-400",
};
/**
* Metric card used across dashboard and landing pages.
*/
@@ -21,16 +42,22 @@ export function StatCard({
variant = "default",
}: StatCardProps) {
return (
<Card>
<Card
className={cn(
"border bg-gradient-to-br backdrop-blur-sm",
variantStyles[variant],
)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xs text-muted-foreground/80 tracking-wide">
{label}
</p>
<p
className={cn(
"text-2xl font-bold tabular-nums tracking-tight",
variant === "danger" && "text-destructive",
variant === "success" && "text-green-500",
valueColor[variant],
)}
>
{formatNumber(value)}
@@ -39,11 +66,7 @@ export function StatCard({
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
variant === "danger"
? "bg-destructive/10 text-destructive"
: variant === "success"
? "bg-green-500/10 text-green-500"
: "bg-primary/10 text-primary",
iconBg[variant],
)}
>
<Icon className="size-4" />
File diff suppressed because one or more lines are too long
@@ -1,186 +0,0 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
@@ -1,76 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
);
}
export { Alert, AlertAction, AlertDescription, AlertTitle };
@@ -1,22 +0,0 @@
import { cn } from "@/lib/utils";
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
);
}
export { AspectRatio };
File diff suppressed because one or more lines are too long
@@ -1,124 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props,
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"a">) {
return (
<a
data-slot="breadcrumb-page"
aria-disabled="true"
aria-current="page"
tabIndex={0}
className={cn(
"font-normal text-foreground pointer-events-none",
className,
)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};
@@ -1,128 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className,
),
},
props,
),
render,
state: {
slot: "bubble-content",
},
});
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
);
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions };
@@ -1,86 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"fieldset"> &
VariantProps<typeof buttonGroupVariants>) {
return (
<fieldset
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
),
},
props,
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
File diff suppressed because one or more lines are too long
@@ -1,243 +0,0 @@
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"section"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<section
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</section>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({
className,
...props
}: React.ComponentProps<"fieldset">) {
const { orientation } = useCarousel();
return (
<fieldset
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full border-0 p-0",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
File diff suppressed because one or more lines are too long
@@ -1,192 +0,0 @@
"use client";
import { Command as CommandPrimitive } from "cmdk";
import { CheckIcon, SearchIcon } from "lucide-react";
import type * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
"use client";
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
File diff suppressed because one or more lines are too long
@@ -1,104 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
};
@@ -1,238 +0,0 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) {
return (
<fieldset
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && (
<li key={error.message ?? index}>{error.message}</li>
),
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};

Some files were not shown because too many files have changed in this diff Show More