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
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:
co-authored by
Claude Opus 4.8
parent
63f21513bd
commit
5802d02e29
@@ -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">
|
||||
“{channel.culture_summary}”
|
||||
</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">
|
||||
“{ch.culture_summary}”
|
||||
</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,
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,199 +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 { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemGroup,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
@@ -1,71 +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";
|
||||
|
||||
const markerVariants = cva(
|
||||
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
separator:
|
||||
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
|
||||
border: "border-b border-border pb-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Marker({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(markerVariants({ variant, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "marker",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-icon"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-content"
|
||||
className={cn(
|
||||
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Marker, MarkerContent, MarkerIcon, markerVariants };
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user