mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
🎨 style: establish console design system
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import type * as React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type AuthFormCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function AuthFormCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
}: AuthFormCardProps) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-md", className)}>
|
||||
<CardHeader className={cn("space-y-3", headerClassName)}>
|
||||
<CardTitle className="text-3xl tracking-[-0.035em]">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className={contentClassName}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ message }: { message?: string | null }) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-l-4 border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type BrandMarkProps = {
|
||||
className?: string;
|
||||
inverted?: boolean;
|
||||
};
|
||||
|
||||
export function BrandMark({ className, inverted = false }: BrandMarkProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid size-11 shrink-0 place-items-center border font-mono text-lg font-black",
|
||||
inverted
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-sidebar-primary bg-sidebar-primary text-sidebar-primary-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BrandLockupProps = BrandMarkProps & {
|
||||
subtitle: string;
|
||||
compact?: boolean;
|
||||
textClassName?: string;
|
||||
};
|
||||
|
||||
export function BrandLockup({ subtitle, compact, className, textClassName }: BrandLockupProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)}>
|
||||
<BrandMark className={compact ? "size-9 text-sm" : undefined} />
|
||||
<div className={textClassName}>
|
||||
<p className={cn("font-black uppercase tracking-[0.14em]", compact ? "text-base" : "text-lg")}>
|
||||
Minikura
|
||||
</p>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.2em] text-current/45">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { GitGraph, Loader2, LogOut, Network, Server, Settings, Users } from "lucide-react";
|
||||
import { GitGraph, LogOut, type LucideIcon, Network, Server, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { BrandLockup } from "@/components/brand";
|
||||
import { FullScreenLoader } from "@/components/page-layout";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -29,13 +30,30 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { signOut, useSession } from "@/lib/auth-client";
|
||||
|
||||
const menuItems = [
|
||||
{ href: "/dashboard/users", icon: Users, label: "Users" },
|
||||
{ href: "/dashboard/servers", icon: Server, label: "Servers" },
|
||||
{ href: "/dashboard/topology", icon: GitGraph, label: "Network" },
|
||||
];
|
||||
type NavigationGroup = {
|
||||
label: string;
|
||||
items: Array<{
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
context: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const k8sMenuItems = [{ href: "/dashboard/k8s", icon: Network, label: "Resources" }];
|
||||
const navigation: NavigationGroup[] = [
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ href: "/dashboard/users", icon: Users, label: "Users", context: "Identity" },
|
||||
{ href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" },
|
||||
{ href: "/dashboard/topology", icon: GitGraph, label: "Network", context: "Topology" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Kubernetes",
|
||||
items: [{ href: "/dashboard/k8s", icon: Network, label: "Resources", context: "Cluster" }],
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -48,21 +66,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isPending || !session?.user) return <FullScreenLoader />;
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
@@ -75,69 +79,73 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase() || "U";
|
||||
const currentPage = navigation
|
||||
.flatMap((group) => group.items)
|
||||
.find((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarHeader className="border-b px-6 py-4">
|
||||
<h2 className="text-lg font-semibold">Minikura</h2>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "17rem",
|
||||
"--sidebar-width-icon": "4rem",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Sidebar className="border-sidebar-border">
|
||||
<SidebarHeader className="border-b border-sidebar-border px-5 py-5">
|
||||
<BrandLockup
|
||||
subtitle="Control plane"
|
||||
compact
|
||||
textClassName="group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menuItems.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={pathname === item.href}>
|
||||
<Link href={item.href}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Kubernetes</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{k8sMenuItems.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={pathname === item.href}>
|
||||
<Link href={item.href}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarContent className="py-4">
|
||||
{navigation.map((group) => (
|
||||
<SidebarGroup key={group.label} className="px-3">
|
||||
<SidebarGroupLabel className="font-mono text-[9px] uppercase tracking-[0.2em]">
|
||||
{group.label}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === item.href || pathname.startsWith(`${item.href}/`)}
|
||||
className="h-11 rounded-sm px-3 text-sm font-bold data-[active=true]:border-l-2 data-[active=true]:border-sidebar-primary"
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<div className="border-t p-4">
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 px-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-3 overflow-hidden px-2 py-2 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-sm">
|
||||
<AvatarFallback>{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start text-sm">
|
||||
<span className="font-medium">{session?.user?.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{session?.user?.email}</span>
|
||||
<div className="flex min-w-0 flex-col items-start normal-case tracking-normal group-data-[collapsible=icon]:hidden">
|
||||
<span className="max-w-40 truncate text-xs font-bold">{session?.user?.name}</span>
|
||||
<span className="max-w-40 truncate font-mono text-[9px] text-sidebar-foreground/45">
|
||||
{session?.user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dashboard/settings">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
@@ -147,11 +155,14 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 items-center gap-4 border-b bg-background px-6">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1" />
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center gap-4 border-b bg-background/95 px-4 backdrop-blur-sm sm:px-6">
|
||||
<SidebarTrigger className="border border-border bg-card" />
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<span className="truncate font-mono text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
{currentPage ? `${currentPage.context} / ${currentPage.label}` : "Control plane"}
|
||||
</span>
|
||||
</header>
|
||||
<main className="flex-1 p-6 min-w-0 overflow-auto">{children}</main>
|
||||
<main className="min-w-0 flex-1 overflow-auto p-4 sm:p-6 lg:p-8">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export type DataTableColumn<T> = {
|
||||
id: string;
|
||||
header: ReactNode;
|
||||
cell: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
type DataTableProps<T> = {
|
||||
data: readonly T[];
|
||||
columns: readonly DataTableColumn<T>[];
|
||||
getRowKey: (item: T, index: number) => string | number;
|
||||
};
|
||||
|
||||
export function DataTable<T>({ data, columns, getRowKey }: DataTableProps<T>) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.id} className={column.headerClassName}>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item, index) => (
|
||||
<TableRow key={getRowKey(item, index)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.id} className={column.className}>
|
||||
{column.cell(item)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type PageHeaderProps = {
|
||||
eyebrow: string;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
leading?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageShell({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("page-shell", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
leading,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("page-heading", className)}>
|
||||
<div className={cn(leading && "flex items-center gap-4")}>
|
||||
{leading}
|
||||
<div>
|
||||
<span className="page-eyebrow">{eyebrow}</span>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{description && <p className="page-description">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type StatePanelProps = React.ComponentProps<"div"> & {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
tone?: "default" | "error";
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function StatePanel({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
tone = "default",
|
||||
loading = false,
|
||||
className,
|
||||
...props
|
||||
}: StatePanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-48 items-center justify-center border border-dashed bg-card/60 p-6 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex max-w-lg flex-col items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="mb-2 size-7 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
icon && <div className="mb-2 text-muted-foreground">{icon}</div>
|
||||
)}
|
||||
<p className={cn("font-bold", tone === "error" && "text-destructive")}>{title}</p>
|
||||
{description && <div className="text-sm text-muted-foreground">{description}</div>}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullScreenLoader({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-sidebar text-sidebar-foreground">
|
||||
<Loader2 className="size-8 animate-spin text-sidebar-primary" />
|
||||
{label && (
|
||||
<span className="font-mono text-[10px] font-bold uppercase tracking-widest text-sidebar-foreground/50">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type * as React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type SectionCardProps = React.ComponentProps<typeof Card> & {
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
headerAction?: React.ReactNode;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
headerAction,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
...props
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Card className={className} {...props}>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className={contentClassName}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type ResourceSectionProps = SectionCardProps & {
|
||||
count: number;
|
||||
emptyIcon: React.ReactNode;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
};
|
||||
|
||||
export function ResourceSection({
|
||||
count,
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
children,
|
||||
...props
|
||||
}: ResourceSectionProps) {
|
||||
return (
|
||||
<SectionCard
|
||||
{...props}
|
||||
headerAction={
|
||||
<span className="font-mono text-xs font-bold">{String(count).padStart(2, "0")}</span>
|
||||
}
|
||||
>
|
||||
{count === 0 ? (
|
||||
<div className="flex h-36 flex-col items-center justify-center border border-dashed bg-muted/30 text-center">
|
||||
<div className="mb-3 text-muted-foreground">{emptyIcon}</div>
|
||||
<p className="font-bold uppercase">{emptyTitle}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{emptyDescription}</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex items-center justify-end gap-2", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type StatItem = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon?: LucideIcon;
|
||||
tone?: "default" | "positive" | "warning";
|
||||
};
|
||||
|
||||
export function StatStrip({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: readonly StatItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid divide-x divide-border border bg-card shadow-[2px_2px_0_color-mix(in_oklch,var(--foreground)_8%,transparent)]",
|
||||
className
|
||||
)}
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{items.map(({ label, value, icon: Icon, tone = "default" }) => (
|
||||
<div key={label} className="min-w-20 px-3 py-2.5 sm:min-w-28 sm:px-4">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
tone === "positive" && "text-success",
|
||||
tone === "warning" && "text-warning"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate font-mono text-[9px] font-bold uppercase tracking-[0.12em]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<strong className="block text-xl leading-none tabular-nums sm:text-2xl">{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AlertCircle, CheckCircle2, CircleDashed, XCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const statusConfig = {
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
className: "border-success/35 bg-success/12 text-success-foreground",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertCircle,
|
||||
className: "border-warning/40 bg-warning/14 text-warning-foreground",
|
||||
},
|
||||
error: { icon: XCircle, className: "border-destructive/35 bg-destructive/10 text-destructive" },
|
||||
neutral: { icon: CircleDashed, className: "border-border bg-muted/60 text-muted-foreground" },
|
||||
} as const;
|
||||
|
||||
export type StatusTone = keyof typeof statusConfig;
|
||||
|
||||
export function StatusBadge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
pulse = false,
|
||||
className,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
children: ReactNode;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { icon: Icon, className: toneClassName } = statusConfig[tone];
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(toneClassName, className)}>
|
||||
<Icon className={cn(pulse && "animate-pulse")} />
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
"flex flex-1 items-center justify-between py-4 font-mono text-[10px] font-bold uppercase tracking-[0.1em] transition-colors hover:text-primary [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,11 +5,11 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-sm border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-[0.08em] whitespace-nowrap transition-[color,box-shadow] [&>svg]:size-3 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
default: "border-foreground/20 bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
|
||||
@@ -5,24 +5,24 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-sm border border-transparent text-sm font-bold tracking-[-0.01em] transition-[background-color,color,border-color,transform,box-shadow] disabled:pointer-events-none disabled:opacity-50 active:translate-y-px [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:ring-ring/40 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default:
|
||||
"border-foreground bg-primary text-primary-foreground shadow-[3px_3px_0_var(--foreground)] hover:-translate-y-0.5 hover:shadow-[4px_4px_0_var(--foreground)]",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"border-destructive bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20",
|
||||
outline: "border-border bg-card text-foreground hover:border-foreground hover:bg-accent",
|
||||
secondary: "border-border bg-secondary text-secondary-foreground hover:border-foreground",
|
||||
ghost: "text-current hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-12 px-6 has-[>svg]:px-4",
|
||||
icon: "size-10",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-5 rounded-sm border py-5 shadow-[3px_3px_0_color-mix(in_oklch,var(--foreground)_8%,transparent)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-5 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-5 sm:px-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn("text-base font-black leading-none tracking-[-0.015em]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -59,7 +59,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
return <div data-slot="card-content" className={cn("px-5 sm:px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -72,4 +72,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -52,7 +52,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-sm border-2 border-foreground p-6 shadow-[8px_8px_0_color-mix(in_oklch,var(--foreground)_22%,transparent)] duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -96,7 +96,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn("text-xl font-black uppercase leading-none tracking-[-0.02em]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input h-10 w-full min-w-0 rounded-sm border bg-card px-3 py-1 font-mono text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-foreground focus-visible:shadow-[inset_3px_0_0_var(--primary)]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimiti
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
"flex items-center gap-2 font-mono text-[10px] font-bold uppercase leading-none tracking-[0.08em] select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -31,7 +31,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-foreground aria-invalid:ring-destructive/20 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-sm border bg-card px-3 py-2 font-mono text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:shadow-[inset_3px_0_0_var(--primary)] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -56,7 +56,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-sm border shadow-[4px_4px_0_color-mix(in_oklch,var(--foreground)_15%,transparent)]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -18,8 +18,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
@@ -70,8 +68,6 @@ function SidebarProvider({
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
"hover:bg-primary/8 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -58,7 +58,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"h-10 px-3 text-left align-middle font-mono text-[10px] font-bold uppercase tracking-[0.14em] text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +71,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"h-14 px-3 py-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
"text-muted-foreground inline-flex h-auto w-fit items-center justify-start gap-1 overflow-x-auto border-b-2 border-foreground bg-transparent p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -33,7 +33,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[state=active]:bg-foreground data-[state=active]:text-background focus-visible:ring-ring/50 text-muted-foreground inline-flex h-9 flex-none items-center justify-center gap-1.5 rounded-t-sm border border-b-0 border-transparent px-3 py-1 font-mono text-[10px] font-bold uppercase tracking-[0.1em] whitespace-nowrap transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-sm border border-input bg-card px-3 py-2 font-mono text-sm placeholder:text-muted-foreground focus-visible:border-foreground focus-visible:shadow-[inset_3px_0_0_var(--primary)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user