2 Commits
Author SHA1 Message Date
asepharyana 4f9d4a5c7d refactor: remove unused UI components and replace GlassCard with Card in voice components
- Deleted Item, Kbd, Marker, Message, NativeSelect, Questionnaire, Spinner components.
- Replaced GlassCard with Card in VoiceActivityTimeline, VoiceConnectionCard, ListenControl, MicControl, and SpeakerWaveform components.
- Introduced AppSidebar and ThemeToggle components for improved navigation and theme management.
2026-08-07 17:33:12 +07:00
asepharyana 2c995b41d7 feat: add new UI components including RadioGroup, Resizable, Sidebar, Spinner, Table, Toast, and ToggleGroup
- Implemented RadioGroup and RadioGroupItem for radio button functionality.
- Created ResizablePanelGroup, ResizablePanel, and ResizableHandle for resizable panels.
- Developed Sidebar component with context for state management and various subcomponents (SidebarTrigger, SidebarMenu, etc.).
- Added Spinner component for loading indicators.
- Introduced Table component with TableHeader, TableBody, TableFooter, and related subcomponents for structured data display.
- Built Toast component for notifications with customizable actions and icons.
- Implemented ToggleGroup and ToggleGroupItem for toggle button functionality with context support.
2026-08-07 16:11:33 +07:00
79 changed files with 4594 additions and 832 deletions
@@ -7,10 +7,15 @@ import {
ChatbotProvider, ChatbotProvider,
useChatbot, useChatbot,
} from "@/components/chatbot/chatbot-context"; } from "@/components/chatbot/chatbot-context";
import { HiddenSidebar } from "@/components/layout/hidden-sidebar"; import { AppSidebar } from "@/components/layout/app-sidebar";
import { MobileNav } from "@/components/layout/mobile-nav";
import { TopNav } from "@/components/layout/top-nav";
import { MiniPlayer } from "@/components/media/mini-player"; import { MiniPlayer } from "@/components/media/mini-player";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
import { useWebSocket, WsProvider } from "@/lib/ws/context"; import { useWebSocket, WsProvider } from "@/lib/ws/context";
@@ -69,29 +74,41 @@ export default function DashboardLayout({
<ChatbotProvider> <ChatbotProvider>
<ChatbotGuildSync guildId={guildId} /> <ChatbotGuildSync guildId={guildId} />
<ChatbotExpressionSync /> <ChatbotExpressionSync />
<div className="min-h-screen bg-canvas"> <div className="min-h-svh bg-canvas">
<TopNav /> <SidebarProvider>
<HiddenSidebar <AppSidebar />
guildId={guildId} <SidebarInset className="gap-0">
onGuildChange={(g) => setGuildId(g ?? "")} <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-canvas">
/> <SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 h-6 max-md:hidden"
/>
<div className="font-semibold max-md:hidden">Overview</div>
<div className="ms-auto">
<GuildSelector
value={guildId}
onChange={(g) => setGuildId(g ?? "")}
/>
</div>
</header>
{/* Sub-nav space — filled per-page */} <main className="flex flex-1 flex-col gap-4 p-4 pb-28 md:p-6 lg:pb-8">
<div className="pt-11"> <div className="mx-auto w-full max-w-[1440px]">
<main className="p-4 md:p-6 pb-24 md:pb-6 max-w-[1600px] mx-auto"> <Suspense
<Suspense fallback={
fallback={ <div className="flex h-[60vh] items-center justify-center">
<div className="flex h-[60vh] items-center justify-center"> <div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" /> </div>
</div> }
} >
> {children}
{children} </Suspense>
</Suspense> </div>
</main> </main>
</div> </SidebarInset>
</SidebarProvider>
<MobileNav />
<MiniPlayer /> <MiniPlayer />
<ChatbotContainer /> <ChatbotContainer />
</div> </div>
@@ -3,8 +3,6 @@
import { Flag, Image, Loader2, Search } from "lucide-react"; import { Flag, Image, Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { GlassPanel } from "@/components/glass/panel";
import { SubNav } from "@/components/layout/sub-nav"; import { SubNav } from "@/components/layout/sub-nav";
import { Lightbox } from "@/components/messages/lightbox"; import { Lightbox } from "@/components/messages/lightbox";
import { extractFirstImage } from "@/components/messages/message-card"; import { extractFirstImage } from "@/components/messages/message-card";
@@ -13,6 +11,7 @@ import { MessageList } from "@/components/messages/message-list";
import { SearchOverlay } from "@/components/messages/search-overlay"; import { SearchOverlay } from "@/components/messages/search-overlay";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector"; import { GuildSelector } from "@/components/shared/guild-selector";
import { Card } from "@/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -218,12 +217,14 @@ export default function MessagesView({
{detailId && ( {detailId && (
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5"> <div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
{detailLoading ? ( {detailLoading ? (
<GlassPanel <Card
dense className={cn(
className="flex items-center justify-center py-12" "flex items-center justify-center py-12",
"[--card-spacing:0px]",
)}
> >
<Loader2 className="size-5 animate-spin text-text-secondary/60" /> <Loader2 className="size-5 animate-spin text-text-secondary/60" />
</GlassPanel> </Card>
) : detailMessage ? ( ) : detailMessage ? (
<div className="space-y-3"> <div className="space-y-3">
<button <button
@@ -337,10 +338,14 @@ function ReviewList({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item) => ( {items.map((item) => (
<GlassCard <Card
key={item.id} key={item.id}
variant="danger" className={cn(
className="cursor-pointer p-3" "cursor-pointer p-3",
"border border-red-500/30 ring-red-500/20",
"[--card-spacing:0px]",
"rounded-2xl",
)}
onClick={() => onSelect(item.id)} onClick={() => onSelect(item.id)}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -351,7 +356,7 @@ function ReviewList({
</p> </p>
</div> </div>
</div> </div>
</GlassCard> </Card>
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<EmptyState <EmptyState
+20
View File
@@ -56,6 +56,16 @@
/* Ring / focus outline for shadcn outline-ring utility */ /* Ring / focus outline for shadcn outline-ring utility */
--color-ring: var(--color-primary); --color-ring: var(--color-primary);
/* Sidebar (shadcn) */
--color-sidebar: oklch(1 0 0 / 0.6);
--color-sidebar-foreground: var(--color-text-primary);
--color-sidebar-primary: var(--color-primary);
--color-sidebar-primary-foreground: var(--color-primary-foreground);
--color-sidebar-accent: oklch(0.93 0.01 250 / 0.7);
--color-sidebar-accent-foreground: var(--color-text-primary);
--color-sidebar-border: var(--color-border);
--color-sidebar-ring: var(--color-ring);
/* Radius */ /* Radius */
--radius-card: 16px; --radius-card: 16px;
--radius-panel: 12px; --radius-panel: 12px;
@@ -102,6 +112,16 @@
--color-accent: var(--color-primary); --color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground); --color-accent-foreground: var(--color-primary-foreground);
--color-ring: var(--color-primary); --color-ring: var(--color-primary);
/* Sidebar (shadcn) */
--color-sidebar: oklch(0.11 0.02 245 / 0.55);
--color-sidebar-foreground: var(--color-text-primary);
--color-sidebar-primary: var(--color-primary);
--color-sidebar-primary-foreground: var(--color-primary-foreground);
--color-sidebar-accent: oklch(0.17 0.02 245 / 0.7);
--color-sidebar-accent-foreground: var(--color-text-primary);
--color-sidebar-border: var(--color-border);
--color-sidebar-ring: var(--color-ring);
} }
@layer utilities { @layer utilities {
+10 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
@@ -30,7 +31,15 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{children} <ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
enableColorScheme={false}
disableTransitionOnChange
>
{children}
</ThemeProvider>
<Toaster position="bottom-right" richColors closeButton /> <Toaster position="bottom-right" richColors closeButton />
</body> </body>
</html> </html>
@@ -9,8 +9,9 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils";
interface ActivityChartProps { interface ActivityChartProps {
data?: { day: string; messages: number; flagged: number }[]; data?: { day: string; messages: number; flagged: number }[];
@@ -28,7 +29,7 @@ export function ActivityChart({ data = [] }: ActivityChartProps) {
const mounted = useMounted(); const mounted = useMounted();
return ( return (
<GlassCard variant="base"> <Card className={cn("[--card-spacing:0px]", "rounded-2xl", "p-5")}>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Message Activity Message Activity
@@ -123,6 +124,6 @@ export function ActivityChart({ data = [] }: ActivityChartProps) {
<div className="h-full w-full animate-pulse rounded-md bg-card/40" /> <div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)} )}
</div> </div>
</GlassCard> </Card>
); );
} }
@@ -2,7 +2,6 @@
import { Hash, Search } from "lucide-react"; import { Hash, Search } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { useChannelDetail, useChannels } from "@/hooks"; import { useChannelDetail, useChannels } from "@/hooks";
import { renderMessageContent } from "@/lib/format"; import { renderMessageContent } from "@/lib/format";
import type { DashboardChannel } from "@/lib/types"; import type { DashboardChannel } from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelsSection({ guildId }: { guildId?: string }) { export function ChannelsSection({ guildId }: { guildId?: string }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -31,7 +31,14 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
if (error) { if (error) {
return ( return (
<GlassCard variant="danger" className="p-6 text-sm"> <Card
className={cn(
"p-6 text-sm",
"border border-red-500/30 ring-red-500/20",
"[--card-spacing:0px]",
"rounded-2xl",
)}
>
Failed to load channels: {error.message} Failed to load channels: {error.message}
<Button <Button
variant="outline" variant="outline"
@@ -41,7 +48,7 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
> >
Retry Retry
</Button> </Button>
</GlassCard> </Card>
); );
} }
@@ -76,7 +83,9 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
)} )}
</div> </div>
<GlassCard variant="base" className="h-fit"> <Card
className={cn("h-fit", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{detail ? ( {detail ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -144,7 +153,7 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
</p> </p>
</div> </div>
)} )}
</GlassCard> </Card>
</div> </div>
); );
} }
@@ -9,8 +9,9 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils";
interface HourlyActivityChartProps { interface HourlyActivityChartProps {
data?: { hour: number; messages: number; flagged: number }[]; data?: { hour: number; messages: number; flagged: number }[];
@@ -46,7 +47,7 @@ export function HourlyActivityChart({ data = [] }: HourlyActivityChartProps) {
const maxMessages = Math.max(...full.map((d) => d.messages)); const maxMessages = Math.max(...full.map((d) => d.messages));
return ( return (
<GlassCard variant="base"> <Card className={cn("[--card-spacing:0px]", "rounded-2xl", "p-5")}>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Hourly Activity Hourly Activity
@@ -103,6 +104,6 @@ export function HourlyActivityChart({ data = [] }: HourlyActivityChartProps) {
<div className="h-full w-full animate-pulse rounded-md bg-card/40" /> <div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)} )}
</div> </div>
</GlassCard> </Card>
); );
} }
@@ -1,8 +1,9 @@
"use client"; "use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils";
interface ModerationDonutProps { interface ModerationDonutProps {
data?: { name: string; value: number; color: string }[]; data?: { name: string; value: number; color: string }[];
@@ -27,7 +28,7 @@ export function ModerationDonut({ data = [] }: ModerationDonutProps) {
: 0; : 0;
return ( return (
<GlassCard variant="base"> <Card className={cn("[--card-spacing:0px]", "rounded-2xl", "p-5")}>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Moderation Breakdown Moderation Breakdown
@@ -96,6 +97,6 @@ export function ModerationDonut({ data = [] }: ModerationDonutProps) {
)} )}
</div> </div>
</div> </div>
</GlassCard> </Card>
); );
} }
@@ -1,11 +1,12 @@
"use client"; "use client";
import { Flame, Heart, SmilePlus } from "lucide-react"; import { Flame, Heart, SmilePlus } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { useTopReactions, useTopReactors } from "@/hooks"; import { useTopReactions, useTopReactors } from "@/hooks";
import { renderMessageContent } from "@/lib/format"; import { renderMessageContent } from "@/lib/format";
import { cn } from "@/lib/utils";
function formatReactionTime(ts: number | null): string { function formatReactionTime(ts: number | null): string {
if (!ts) return ""; if (!ts) return "";
@@ -31,19 +32,23 @@ export function ReactionsSection() {
{reactionsLoading ? ( {reactionsLoading ? (
<LoadingSkeleton count={5} height="h-16" /> <LoadingSkeleton count={5} height="h-16" />
) : !reactions || reactions.length === 0 ? ( ) : !reactions || reactions.length === 0 ? (
<GlassCard className="p-6"> <Card className={cn("p-6", "[--card-spacing:0px]", "rounded-2xl")}>
<EmptyState <EmptyState
icon={Heart} icon={Heart}
title="Belum ada reaksi" title="Belum ada reaksi"
description="Pesan dengan reaksi emoji akan muncul di sini." description="Pesan dengan reaksi emoji akan muncul di sini."
/> />
</GlassCard> </Card>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{reactions.map((r, i) => ( {reactions.map((r, i) => (
<GlassCard <Card
key={r.message_id} key={r.message_id}
className="flex items-center gap-3 p-3" className={cn(
"flex items-center gap-3 p-3",
"[--card-spacing:0px]",
"rounded-2xl",
)}
> >
<span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50"> <span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50">
{i + 1} {i + 1}
@@ -76,7 +81,7 @@ export function ReactionsSection() {
<Heart className="size-3" /> <Heart className="size-3" />
{r.reaction_count} {r.reaction_count}
</Badge> </Badge>
</GlassCard> </Card>
))} ))}
</div> </div>
)} )}
@@ -90,19 +95,23 @@ export function ReactionsSection() {
{reactorsLoading ? ( {reactorsLoading ? (
<LoadingSkeleton count={5} height="h-14" /> <LoadingSkeleton count={5} height="h-14" />
) : !reactors || reactors.length === 0 ? ( ) : !reactors || reactors.length === 0 ? (
<GlassCard className="p-6"> <Card className={cn("p-6", "[--card-spacing:0px]", "rounded-2xl")}>
<EmptyState <EmptyState
icon={SmilePlus} icon={SmilePlus}
title="Belum ada reaktor" title="Belum ada reaktor"
description="User yang ngasih reaksi emoji akan muncul di sini." description="User yang ngasih reaksi emoji akan muncul di sini."
/> />
</GlassCard> </Card>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{reactors.map((r, i) => ( {reactors.map((r, i) => (
<GlassCard <Card
key={r.user_id} key={r.user_id}
className="flex items-center gap-3 p-3" className={cn(
"flex items-center gap-3 p-3",
"[--card-spacing:0px]",
"rounded-2xl",
)}
> >
<span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50"> <span className="w-6 shrink-0 text-center font-mono text-xs text-text-secondary/50">
{i + 1} {i + 1}
@@ -120,7 +129,7 @@ export function ReactionsSection() {
<Flame className="size-3" /> <Flame className="size-3" />
{r.net_count} {r.net_count}
</Badge> </Badge>
</GlassCard> </Card>
))} ))}
</div> </div>
)} )}
@@ -2,7 +2,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer } from "recharts"; import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -39,7 +39,13 @@ export function StatCard({
const numValue = typeof value === "number" ? value : Number(value); const numValue = typeof value === "number" ? value : Number(value);
return ( return (
<GlassCard variant="base" className="relative overflow-hidden p-4"> <Card
className={cn(
"relative overflow-hidden p-4",
"[--card-spacing:0px]",
"rounded-2xl",
)}
>
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className={cn("p-1.5 rounded-md", bgAccent)}> <div className={cn("p-1.5 rounded-md", bgAccent)}>
<Icon className="size-4" /> <Icon className="size-4" />
@@ -90,6 +96,6 @@ export function StatCard({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</GlassCard> </Card>
); );
} }
@@ -8,8 +8,9 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils";
interface TopChannelsChartProps { interface TopChannelsChartProps {
data?: { name: string; count: number }[]; data?: { name: string; count: number }[];
@@ -19,7 +20,7 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
const mounted = useMounted(); const mounted = useMounted();
return ( return (
<GlassCard variant="base"> <Card className={cn("[--card-spacing:0px]", "rounded-2xl", "p-5")}>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Top Channels Top Channels
@@ -68,6 +69,6 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
<div className="h-full w-full animate-pulse rounded-md bg-card/40" /> <div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)} )}
</div> </div>
</GlassCard> </Card>
); );
} }
@@ -2,7 +2,6 @@
import { Search, Users, UserX } from "lucide-react"; import { Search, Users, UserX } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -12,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { useUserDetail, useUsers } from "@/hooks"; import { useUserDetail, useUsers } from "@/hooks";
import { renderMessageContent } from "@/lib/format"; import { renderMessageContent } from "@/lib/format";
import type { DashboardUser } from "@/lib/types"; import type { DashboardUser } from "@/lib/types";
import { cn } from "@/lib/utils";
const TRUST_TIERS = [ const TRUST_TIERS = [
{ {
@@ -67,7 +67,14 @@ export function UsersSection() {
if (error) { if (error) {
return ( return (
<GlassCard variant="danger" className="p-6 text-sm"> <Card
className={cn(
"p-6 text-sm",
"border border-red-500/30 ring-red-500/20",
"[--card-spacing:0px]",
"rounded-2xl",
)}
>
Failed to load users: {error.message} Failed to load users: {error.message}
<Button <Button
variant="outline" variant="outline"
@@ -77,7 +84,7 @@ export function UsersSection() {
> >
Retry Retry
</Button> </Button>
</GlassCard> </Card>
); );
} }
@@ -112,7 +119,9 @@ export function UsersSection() {
)} )}
</div> </div>
<GlassCard variant="base" className="h-fit"> <Card
className={cn("h-fit", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{detail ? ( {detail ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -196,7 +205,7 @@ export function UsersSection() {
</p> </p>
</div> </div>
)} )}
</GlassCard> </Card>
</div> </div>
); );
} }
@@ -1,31 +0,0 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
type GlassVariant = "base" | "elevated" | "interactive" | "danger";
interface GlassCardProps extends ComponentPropsWithoutRef<"div"> {
variant?: GlassVariant;
}
const variantStyles: Record<GlassVariant, string> = {
base: "glass rounded-[var(--radius-card)]",
elevated: "glass-elevated rounded-[var(--radius-card)]",
interactive:
"glass rounded-[var(--radius-card)] transition-all duration-150 hover:scale-[1.01] hover:border-[var(--color-border-glow)] cursor-pointer",
danger: "glass rounded-[var(--radius-card)] border-red-500/30",
};
export function GlassCard({
variant = "base",
className,
children,
...props
}: GlassCardProps) {
return (
<div className={cn(variantStyles[variant], "p-5", className)} {...props}>
{children}
</div>
);
}
@@ -1,2 +0,0 @@
export { GlassCard } from "./card";
export { GlassPanel } from "./panel";
@@ -1,28 +0,0 @@
"use client";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
interface GlassPanelProps extends ComponentPropsWithoutRef<"div"> {
dense?: boolean;
}
export function GlassPanel({
dense = false,
className,
children,
...props
}: GlassPanelProps) {
return (
<div
className={cn(
"glass rounded-[var(--radius-panel)]",
dense ? "p-3" : "p-4",
className,
)}
{...props}
>
{children}
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import { isActivePath, navItems } from "@/lib/navigation";
/**
* Primary application navigation pure shadcn Sidebar primitives.
* Renders as a fixed desktop rail (collapsible to icons) and a Sheet
* on mobile via the Sidebar component itself.
*/
export function AppSidebar() {
const pathname = usePathname();
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" render={<Link href="/dashboard" />}>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-[10px] font-bold">
D
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Discord Automod</span>
<span className="truncate text-xs text-muted-foreground">
Moderation dashboard
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarMenu>
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActivePath(pathname, matchPrefix);
return (
<SidebarMenuItem key={href}>
<SidebarMenuButton
render={<Link href={href} />}
isActive={active}
tooltip={label}
className="group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:justify-center"
>
<Icon />
<span>{label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<ThemeToggle />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
@@ -1,54 +0,0 @@
"use client";
import { useState } from "react";
import { GuildSelector } from "@/components/shared/guild-selector";
interface HiddenSidebarProps {
guildId: string;
onGuildChange: (guildId: string) => void;
}
export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
const [visible, setVisible] = useState(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const handleMouseEnter = () => {
if (hideTimer) clearTimeout(hideTimer);
setVisible(true);
};
const handleMouseLeave = () => {
hideTimer = setTimeout(() => setVisible(false), 300);
};
return (
<>
{/* Hotspot trigger */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: transparent mouse detection zone, not interactive content */}
<div
className="fixed left-0 top-0 bottom-0 w-1 z-50"
onMouseEnter={handleMouseEnter}
/>
{/* Sidebar */}
<div
role="region"
aria-label="Guild selector sidebar"
className={`fixed left-0 top-0 bottom-0 z-40 w-56 glass-intense border-r border-glass-border transition-transform duration-150 ease-out ${
visible ? "translate-x-0" : "-translate-x-full"
}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex h-11 items-center gap-2 px-4 border-b border-glass-border">
<span className="text-xs font-semibold tracking-wider uppercase text-text-secondary">
Guilds
</span>
</div>
<div className="p-3 space-y-4">
<GuildSelector value={guildId} onChange={onGuildChange} />
</div>
</div>
</>
);
}
@@ -1,41 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { isActivePath, mobileNavItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
export function MobileNav() {
const pathname = usePathname();
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-30 glass-intense border-t border-glass-border">
<div className="flex items-center justify-around h-14 px-2">
{mobileNavItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActivePath(pathname, matchPrefix);
return (
<Link
key={href}
href={href}
className={cn(
"flex flex-col items-center gap-0.5 py-1 px-3 rounded-lg transition-all relative min-w-0",
active
? "text-primary"
: "text-text-secondary/50 hover:text-text-secondary/80",
)}
>
<Icon className="size-5" />
<span className="text-[10px] font-medium leading-tight">
{label}
</span>
{active && (
<span className="absolute -top-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-primary shadow-[0_0_6px] shadow-primary/80" />
)}
</Link>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="w-full justify-start" />
}
>
<Sun className="scale-100 dark:scale-0" />
<Moon className="absolute scale-0 dark:scale-100" />
<span className="truncate pl-1.5">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 size-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 size-4" />
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,87 +0,0 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { isActivePath, navItems } from "@/lib/navigation";
export function TopNav() {
const pathname = usePathname();
const router = useRouter();
const [theme, setTheme] = useState<"light" | "dark">("dark");
// Apply persisted theme on mount (client-side only — avoids SSR hydration
// mismatch from touching <html> during server render).
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
const initial =
stored === "light" ? "light" : stored === "dark" ? "dark" : "light";
setTheme(initial);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(initial);
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
};
return (
<header className="fixed top-0 left-0 right-0 z-40 h-11 flex items-center gap-1 px-3 glass-intense border-b border-[var(--color-border-glow)]">
{/* Brand */}
<div className="flex items-center gap-2 mr-4 shrink-0">
<div className="relative flex size-6 items-center justify-center rounded-md bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-[10px] font-bold">
D
<span className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/80 animate-pulse" />
</div>
<span className="text-xs font-semibold text-text-primary tracking-tight hidden sm:inline">
Discord Automod
</span>
</div>
{/* Nav links */}
<nav className="flex items-center gap-0.5 flex-1 justify-center">
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActivePath(pathname, matchPrefix);
return (
<button
key={href}
type="button"
onClick={() => router.push(href)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${
active
? "text-text-primary"
: "text-text-secondary/60 hover:text-text-primary/80"
}`}
>
<Icon className="size-3.5" />
<span className="hidden sm:inline">{label}</span>
{active && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60" />
)}
</button>
);
})}
</nav>
{/* Right side */}
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={toggleTheme}
className="size-7 flex items-center justify-center rounded-md text-text-secondary/60 hover:text-text-primary hover:bg-glass-bg transition-all"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Moon className="size-3.5" />
) : (
<Sun className="size-3.5" />
)}
</button>
</div>
</header>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { GlassPanel } from "@/components/glass/panel"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AiAnalysisPanelProps { interface AiAnalysisPanelProps {
@@ -37,11 +37,11 @@ export function AiAnalysisPanel({
if (!status || status === "pending") { if (!status || status === "pending") {
return ( return (
<GlassPanel dense> <Card className={cn("[--card-spacing:0px]", "p-3")}>
<span className="text-xs text-text-secondary/50"> <span className="text-xs text-text-secondary/50">
AI analysis pending AI analysis pending
</span> </span>
</GlassPanel> </Card>
); );
} }
@@ -55,7 +55,7 @@ export function AiAnalysisPanel({
: categories || []; : categories || [];
return ( return (
<GlassPanel dense className="space-y-2"> <Card className={cn("space-y-2", "[--card-spacing:0px]", "p-3")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
AI Analysis AI Analysis
@@ -155,6 +155,6 @@ export function AiAnalysisPanel({
<span className="font-mono text-accent-amber">{action}</span> <span className="font-mono text-accent-amber">{action}</span>
</div> </div>
)} )}
</GlassPanel> </Card>
); );
} }
@@ -1,9 +1,10 @@
"use client"; "use client";
import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react"; import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types"; import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiAnalysisPanel } from "./ai-analysis-panel"; import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AttachmentsGrid } from "./attachments-grid"; import { AttachmentsGrid } from "./attachments-grid";
@@ -21,7 +22,9 @@ export function MessageDetailView({
onImageClick, onImageClick,
}: MessageDetailViewProps) { }: MessageDetailViewProps) {
return ( return (
<GlassCard variant="base" className="h-full"> <Card
className={cn("h-full", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{onBack && ( {onBack && (
<button <button
type="button" type="button"
@@ -94,6 +97,6 @@ export function MessageDetailView({
score={message.ai_moderation_score} score={message.ai_moderation_score}
analysis={message.ai_analysis} analysis={message.ai_analysis}
/> />
</GlassCard> </Card>
); );
} }
@@ -1,9 +1,10 @@
"use client"; "use client";
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react"; import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types"; import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiAnalysisPanel } from "./ai-analysis-panel"; import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AttachmentsGrid } from "./attachments-grid"; import { AttachmentsGrid } from "./attachments-grid";
@@ -19,7 +20,9 @@ export function MessageDetail({
onBack, onBack,
}: MessageDetailProps) { }: MessageDetailProps) {
return ( return (
<GlassCard variant="base" className="h-full"> <Card
className={cn("h-full", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{onBack && ( {onBack && (
<button <button
type="button" type="button"
@@ -68,6 +71,6 @@ export function MessageDetail({
score={message.ai_moderation_score} score={message.ai_moderation_score}
analysis={message.ai_analysis} analysis={message.ai_analysis}
/> />
</GlassCard> </Card>
); );
} }
@@ -12,9 +12,9 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { useModerationActions, useModerationStats } from "@/hooks"; import { useModerationActions, useModerationStats } from "@/hooks";
import { renderMessageContent } from "@/lib/format"; import { renderMessageContent } from "@/lib/format";
import type { import type {
@@ -173,13 +173,13 @@ export function ModerationSection({
{actionsLoading && !actions ? ( {actionsLoading && !actions ? (
<LoadingSkeleton count={6} height="h-16" /> <LoadingSkeleton count={6} height="h-16" />
) : !actions || actions.length === 0 ? ( ) : !actions || actions.length === 0 ? (
<GlassCard className="p-6"> <Card className={cn("p-6", "[--card-spacing:0px]", "rounded-2xl")}>
<EmptyState <EmptyState
icon={ShieldAlert} icon={ShieldAlert}
title="Belum ada aksi moderasi" title="Belum ada aksi moderasi"
description="Aksi auto- moderasi (delete, warn, kick, ban) akan muncul di sini." description="Aksi auto- moderasi (delete, warn, kick, ban) akan muncul di sini."
/> />
</GlassCard> </Card>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{actions.map((a) => ( {actions.map((a) => (
@@ -207,7 +207,7 @@ function SummaryCard({
hint?: string; hint?: string;
}) { }) {
return ( return (
<GlassCard className="p-4"> <Card className={cn("p-4", "[--card-spacing:0px]", "rounded-2xl")}>
<p className="text-[10px] uppercase tracking-wide text-text-secondary/50"> <p className="text-[10px] uppercase tracking-wide text-text-secondary/50">
{label} {label}
</p> </p>
@@ -217,7 +217,7 @@ function SummaryCard({
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span> <span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
)} )}
</p> </p>
</GlassCard> </Card>
); );
} }
@@ -251,7 +251,13 @@ function ActionRow({ action }: { action: ModerationAction }) {
const st = STATUS_META[action.status]; const st = STATUS_META[action.status];
const Icon = meta.Icon; const Icon = meta.Icon;
return ( return (
<GlassCard className="flex items-start gap-3 p-3"> <Card
className={cn(
"flex items-start gap-3 p-3",
"[--card-spacing:0px]",
"rounded-2xl",
)}
>
<span className={cn("mt-0.5 shrink-0", meta.className)}> <span className={cn("mt-0.5 shrink-0", meta.className)}>
<Icon className="size-4" /> <Icon className="size-4" />
</span> </span>
@@ -301,7 +307,7 @@ function ActionRow({ action }: { action: ModerationAction }) {
) : ( ) : (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-amber-500" /> <Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-amber-500" />
)} )}
</GlassCard> </Card>
); );
} }
@@ -2,9 +2,10 @@
import { Download, Loader2, Pause, Play } from "lucide-react"; import { Download, Loader2, Pause, Play } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types"; import type { VoiceRecording } from "@/lib/types";
import { cn } from "@/lib/utils";
interface RecordingCardProps { interface RecordingCardProps {
recording: VoiceRecording; recording: VoiceRecording;
@@ -55,13 +56,17 @@ export function RecordingCard({
}; };
return ( return (
<GlassCard <Card
variant="interactive" className={cn(
className={`p-4 transition-all ${ `p-4 transition-all ${
active active
? "ring-1 ring-primary/40 border-primary/30 animate-card-glow" ? "ring-1 ring-primary/40 border-primary/30 animate-card-glow"
: "hover:ring-1 hover:ring-border/60" : "hover:ring-1 hover:ring-border/60"
}`} }`,
"cursor-pointer transition-colors hover:ring-primary/40",
"[--card-spacing:0px]",
"rounded-2xl",
)}
onClick={() => onTogglePlay(recording.id)} onClick={() => onTogglePlay(recording.id)}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -151,6 +156,6 @@ export function RecordingCard({
)} )}
</div> </div>
</div> </div>
</GlassCard> </Card>
); );
} }
@@ -2,7 +2,8 @@
import { Loader2, Pause, Play, X } from "lucide-react"; import { Loader2, Pause, Play, X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GlassPanel } from "@/components/glass/panel"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface RecordingPlayerProps { interface RecordingPlayerProps {
url?: string; url?: string;
@@ -70,9 +71,12 @@ export function RecordingPlayer({
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0; const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0;
return ( return (
<GlassPanel <Card
dense className={cn(
className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5" "fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5",
"[--card-spacing:0px]",
"p-3",
)}
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<button <button
@@ -140,6 +144,6 @@ export function RecordingPlayer({
}} }}
className="hidden" className="hidden"
/> />
</GlassPanel> </Card>
); );
} }
@@ -2,7 +2,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react"; import { Inbox } from "lucide-react";
import { GlassPanel } from "@/components/glass/panel"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface EmptyStateProps { interface EmptyStateProps {
@@ -19,13 +19,16 @@ export function EmptyState({
className, className,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<GlassPanel <Card
dense className={cn(
className={cn("flex flex-col items-center gap-2 py-12", className)} "flex flex-col items-center gap-2 py-12",
className,
"[--card-spacing:0px]",
)}
> >
<Icon className="size-8 text-text-secondary/20" /> <Icon className="size-8 text-text-secondary/20" />
<p className="text-sm text-text-secondary/60">{title}</p> <p className="text-sm text-text-secondary/60">{title}</p>
<p className="text-xs text-text-secondary/40">{description}</p> <p className="text-xs text-text-secondary/40">{description}</p>
</GlassPanel> </Card>
); );
} }
@@ -2,7 +2,8 @@
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ReactNode } from "react"; import { Component, type ReactNode } from "react";
import { GlassCard } from "@/components/glass/card"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -24,9 +25,13 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
this.props.fallback || ( this.props.fallback || (
<GlassCard <Card
variant="danger" className={cn(
className="flex flex-col items-center gap-2 py-8" "flex flex-col items-center gap-2 py-8",
"border border-red-500/30 ring-red-500/20",
"[--card-spacing:0px]",
"rounded-2xl",
)}
> >
<AlertCircle className="size-6 text-destructive" /> <AlertCircle className="size-6 text-destructive" />
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
@@ -39,7 +44,7 @@ export class ErrorBoundary extends Component<Props, State> {
> >
<RefreshCw className="size-3" /> Try again <RefreshCw className="size-3" /> Try again
</button> </button>
</GlassCard> </Card>
) )
); );
} }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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(
"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,
}
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
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, AlertTitle, AlertDescription, AlertAction }
@@ -0,0 +1,22 @@
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 }
+20 -22
View File
@@ -1,16 +1,16 @@
"use client"; "use client"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; import * as React from "react"
import type * as React from "react"; import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
function Avatar({ function Avatar({
className, className,
size = "default", size = "default",
...props ...props
}: AvatarPrimitive.Root.Props & { }: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"; size?: "default" | "sm" | "lg"
}) { }) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
@@ -18,11 +18,11 @@ function Avatar({
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
@@ -31,13 +31,11 @@ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
data-slot="avatar-image" data-slot="avatar-image"
className={cn( className={cn(
"aspect-square size-full rounded-full object-cover", "aspect-square size-full rounded-full object-cover",
className, className
)} )}
loading="lazy"
decoding="async"
{...props} {...props}
/> />
); )
} }
function AvatarFallback({ function AvatarFallback({
@@ -49,11 +47,11 @@ function AvatarFallback({
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs", "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
@@ -65,11 +63,11 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,11 +76,11 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="avatar-group" data-slot="avatar-group"
className={cn( className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function AvatarGroupCount({ function AvatarGroupCount({
@@ -94,18 +92,18 @@ function AvatarGroupCount({
data-slot="avatar-group-count" data-slot="avatar-group-count"
className={cn( className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
export { export {
Avatar, Avatar,
AvatarBadge, AvatarImage,
AvatarFallback, AvatarFallback,
AvatarGroup, AvatarGroup,
AvatarGroupCount, AvatarGroupCount,
AvatarImage, AvatarBadge,
}; }
@@ -1,8 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props"; import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"; import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
@@ -24,8 +24,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
}, }
); )
function Badge({ function Badge({
className, className,
@@ -39,14 +39,14 @@ function Badge({
{ {
className: cn(badgeVariants({ variant }), className), className: cn(badgeVariants({ variant }), className),
}, },
props, props
), ),
render, render,
state: { state: {
slot: "badge", slot: "badge",
variant, variant,
}, },
}); })
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants }
@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
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<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", 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,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20 -20
View File
@@ -1,6 +1,6 @@
import type * as React from "react"; import * as React from "react"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
function Card({ function Card({
className, className,
@@ -13,11 +13,11 @@ function Card({
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -38,12 +38,12 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-title" data-slot="card-title"
className={cn( className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", "text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -53,7 +53,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
); )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -62,11 +62,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -76,7 +76,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-(--card-spacing)", className)} className={cn("px-(--card-spacing)", className)}
{...props} {...props}
/> />
); )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,19 +85,19 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
export { export {
Card, Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader, CardHeader,
CardFooter,
CardTitle, CardTitle,
}; CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
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<"div"> & 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,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</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<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
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 {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
"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, CollapsibleTrigger, CollapsibleContent }
File diff suppressed because one or more lines are too long
+28 -26
View File
@@ -1,25 +1,26 @@
"use client"; "use client"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import * as React from "react"
import { XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import type * as React from "react";
import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) { function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ ...props }: DialogPrimitive.Close.Props) { function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({
@@ -31,11 +32,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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", "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, className
)} )}
{...props} {...props}
/> />
); )
} }
function DialogContent({ function DialogContent({
@@ -44,7 +45,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: DialogPrimitive.Popup.Props & { }: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean; showCloseButton?: boolean
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -53,7 +54,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none 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", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none 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, className
)} )}
{...props} {...props}
> >
@@ -69,13 +70,14 @@ function DialogContent({
/> />
} }
> >
<XIcon /> <XIcon
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Popup> </DialogPrimitive.Popup>
</DialogPortal> </DialogPortal>
); )
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
); )
} }
function DialogFooter({ function DialogFooter({
@@ -94,14 +96,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean; showCloseButton?: boolean
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className, className
)} )}
{...props} {...props}
> >
@@ -112,7 +114,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
); )
} }
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
@@ -120,12 +122,12 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn(
"font-heading text-base leading-none font-medium", "text-base leading-none font-medium",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
function DialogDescription({ function DialogDescription({
@@ -137,11 +139,11 @@ function DialogDescription({
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className, className
)} )}
{...props} {...props}
/> />
); )
} }
export { export {
@@ -155,4 +157,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
}; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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