feat: improve UI contrast, dark mode, dialog sizing, color coherence, and add table sorting
- Fix dialog scrollability (flex layout + max-h-[90dvh]) and increase L4 dialog to lg width - Add styled enable card to L4 dialog matching proxy host pattern - Unify section colors across proxy host and L4 dialogs (cyan=LB, emerald=DNS, violet=upstream DNS, rose=geo, amber=mTLS) - Improve light mode contrast: muted-foreground oklch 0.552→0.502, remove opacity modifiers on secondary text - Improve dark mode: boost muted-foreground to 0.85, increase border opacity 10%→16%, input 15%→20% - Add bg-card to DataTable wrapper and bg-muted/40 to table headers for surface hierarchy - Add semantic badge variants (success, warning, info, muted) and StatusChip dark mode fix - Add server-side sortable columns to Proxy Hosts and L4 Proxy Hosts (name, upstream, status, protocol, listen) - Add sortKey to DataTable Column type with clickable sort headers (ArrowUp/Down indicators, URL param driven) - Fix E2E test selectors for shadcn UI (label associations, combobox roles, dropdown menus, mobile drawer) - Add htmlFor/id to proxy host form fields and aria-labels to select triggers for accessibility - Add sorting E2E tests for both proxy host pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { INITIAL_ACTION_STATE } from "@/lib/actions";
|
||||
import type { L4ProxyHost } from "@/lib/models/l4-proxy-hosts";
|
||||
import { AppDialog } from "@/components/ui/AppDialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Globe, Layers, MapPin, Network, Pin } from "lucide-react";
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
@@ -62,6 +65,7 @@ function L4HostForm({
|
||||
state: { status: string; message?: string };
|
||||
initialData?: L4ProxyHost | null;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(initialData?.enabled ?? true);
|
||||
const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp");
|
||||
const [matcherType, setMatcherType] = useState(
|
||||
initialData?.matcher_type ?? "none"
|
||||
@@ -90,13 +94,27 @@ function L4HostForm({
|
||||
)}
|
||||
|
||||
<input type="hidden" name="enabled_present" value="1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="hidden" name="enabled" value={enabled ? "on" : ""} />
|
||||
<div className={cn(
|
||||
"flex flex-row items-center justify-between p-4 rounded-lg border transition-all duration-200",
|
||||
enabled
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-background"
|
||||
)}>
|
||||
<div>
|
||||
<p className={cn("text-sm font-semibold", enabled ? "text-primary" : "text-foreground")}>
|
||||
{enabled ? "L4 Host Enabled" : "L4 Host Paused"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{enabled
|
||||
? "This host is active and proxying connections"
|
||||
: "This host is disabled and will not accept connections"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
defaultChecked={initialData?.enabled ?? true}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
<FormField label="Name" htmlFor="name">
|
||||
@@ -120,8 +138,18 @@ function L4HostForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="tcp">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">TCP</Badge>
|
||||
TCP
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="udp">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning" className="text-[10px] px-1.5 py-0">UDP</Badge>
|
||||
UDP
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -244,11 +272,16 @@ function L4HostForm({
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultLbAccordion}
|
||||
className="border rounded-md px-3"
|
||||
className="border-l-2 border-l-cyan-500 border rounded-md px-3"
|
||||
>
|
||||
<AccordionItem value="load-balancer" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
Load Balancer
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded border border-cyan-500/30 bg-cyan-500/10 text-cyan-500">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
Load Balancer
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
@@ -315,7 +348,7 @@ function L4HostForm({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<p className="text-xs text-muted-foreground font-medium mt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-2">
|
||||
Active Health Check
|
||||
</p>
|
||||
<input
|
||||
@@ -371,7 +404,7 @@ function L4HostForm({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<p className="text-xs text-muted-foreground font-medium mt-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-2">
|
||||
Passive Health Check
|
||||
</p>
|
||||
<input
|
||||
@@ -441,11 +474,16 @@ function L4HostForm({
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultDnsAccordion}
|
||||
className="border rounded-md px-3"
|
||||
className="border-l-2 border-l-emerald-500 border rounded-md px-3"
|
||||
>
|
||||
<AccordionItem value="dns-resolver" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
Custom DNS Resolvers
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-500">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
Custom DNS Resolvers
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
@@ -507,11 +545,16 @@ function L4HostForm({
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultGeoblockAccordion}
|
||||
className="border rounded-md px-3"
|
||||
className="border-l-2 border-l-rose-500 border rounded-md px-3"
|
||||
>
|
||||
<AccordionItem value="geoblock" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
Geo Blocking
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-500">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
Geo Blocking
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
@@ -543,7 +586,7 @@ function L4HostForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-1">
|
||||
Block Rules
|
||||
</p>
|
||||
<FormField
|
||||
@@ -604,7 +647,7 @@ function L4HostForm({
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-2">
|
||||
Allow Rules (override blocks)
|
||||
</p>
|
||||
<FormField
|
||||
@@ -680,11 +723,16 @@ function L4HostForm({
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultUpstreamDnsAccordion}
|
||||
className="border rounded-md px-3"
|
||||
className="border-l-2 border-l-violet-500 border rounded-md px-3"
|
||||
>
|
||||
<AccordionItem value="upstream-dns" className="border-b-0">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
Upstream DNS Pinning
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded border border-violet-500/30 bg-violet-500/10 text-violet-500">
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
Upstream DNS Pinning
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
@@ -779,7 +827,7 @@ export function CreateL4HostDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={initialData ? "Duplicate L4 Proxy Host" : "Create L4 Proxy Host"}
|
||||
maxWidth="sm"
|
||||
maxWidth="lg"
|
||||
submitLabel="Create"
|
||||
onSubmit={() => {
|
||||
(
|
||||
@@ -824,7 +872,7 @@ export function EditL4HostDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Edit L4 Proxy Host"
|
||||
maxWidth="sm"
|
||||
maxWidth="lg"
|
||||
submitLabel="Save Changes"
|
||||
onSubmit={() => {
|
||||
(
|
||||
@@ -867,7 +915,7 @@ export function DeleteL4HostDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Delete L4 Proxy Host"
|
||||
maxWidth="sm"
|
||||
maxWidth="lg"
|
||||
submitLabel="Delete"
|
||||
onSubmit={() => {
|
||||
(
|
||||
@@ -891,19 +939,21 @@ export function DeleteL4HostDialog({
|
||||
Are you sure you want to delete the L4 proxy host{" "}
|
||||
<strong>{host.name}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will remove the configuration for:
|
||||
</p>
|
||||
<div className="pl-4 flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Protocol: {host.protocol.toUpperCase()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Listen: {host.listen_address}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Upstreams: {host.upstreams.join(", ")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 rounded-md border bg-muted/30 px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 shrink-0">Protocol</span>
|
||||
<Badge variant={host.protocol === "tcp" ? "info" : "warning"} className="text-[10px] px-1.5 py-0">
|
||||
{host.protocol.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 shrink-0">Listen</span>
|
||||
<span className="font-mono text-xs">{host.listen_address}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-20 shrink-0">Upstreams</span>
|
||||
<span className="font-mono text-xs">{host.upstreams.join(", ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
This action cannot be undone.
|
||||
|
||||
@@ -15,7 +15,7 @@ export function DnsResolverFields({
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-500/60 bg-yellow-500/5 p-5">
|
||||
<div className="rounded-lg border border-emerald-500/60 bg-emerald-500/5 p-5">
|
||||
<input type="hidden" name="dns_present" value="1" />
|
||||
<input type="hidden" name="dns_enabled_present" value="1" />
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -573,13 +573,13 @@ export function GeoBlockFields({ initialValues, showModeSelector = true }: GeoBl
|
||||
const [mode, setMode] = useState<GeoBlockMode>(initialValues?.geoblock_mode ?? "merge");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-500/60 bg-yellow-500/5 p-4">
|
||||
<div className="rounded-lg border border-rose-500/60 bg-rose-500/5 p-4">
|
||||
<input type="hidden" name="geoblock_present" value="1" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-yellow-500 flex items-center justify-center shrink-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-rose-500 flex items-center justify-center shrink-0">
|
||||
<Globe className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CreateHostDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={initialData ? "Duplicate Proxy Host" : "Create Proxy Host"}
|
||||
maxWidth="md"
|
||||
maxWidth="lg"
|
||||
submitLabel="Create"
|
||||
onSubmit={() => {
|
||||
(document.getElementById("create-host-form") as HTMLFormElement)?.requestSubmit();
|
||||
@@ -76,8 +76,9 @@ export function CreateHostDialog({
|
||||
enabled={true}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Name</label>
|
||||
<label htmlFor="name" className="text-sm font-medium mb-1 block">Name</label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="My Service"
|
||||
defaultValue={initialData ? `${initialData.name} (Copy)` : ""}
|
||||
@@ -85,8 +86,9 @@ export function CreateHostDialog({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<label htmlFor="domains" className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<Textarea
|
||||
id="domains"
|
||||
name="domains"
|
||||
placeholder="app.example.com"
|
||||
defaultValue={initialData?.domains.join("\n") ?? ""}
|
||||
@@ -101,7 +103,7 @@ export function CreateHostDialog({
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "__none__")}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger aria-label="Certificate">
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -117,7 +119,7 @@ export function CreateHostDialog({
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "__none__")}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger aria-label="Access List">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -194,7 +196,7 @@ export function EditHostDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Edit Proxy Host"
|
||||
maxWidth="md"
|
||||
maxWidth="lg"
|
||||
submitLabel="Save Changes"
|
||||
onSubmit={() => {
|
||||
(document.getElementById("edit-host-form") as HTMLFormElement)?.requestSubmit();
|
||||
@@ -212,12 +214,13 @@ export function EditHostDialog({
|
||||
enabled={host.enabled}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Name</label>
|
||||
<Input name="name" defaultValue={host.name} required />
|
||||
<label htmlFor="name" className="text-sm font-medium mb-1 block">Name</label>
|
||||
<Input id="name" name="name" defaultValue={host.name} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<label htmlFor="domains" className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<Textarea
|
||||
id="domains"
|
||||
name="domains"
|
||||
defaultValue={host.domains.join("\n")}
|
||||
rows={2}
|
||||
@@ -230,7 +233,7 @@ export function EditHostDialog({
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "__none__")}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger aria-label="Certificate">
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -246,7 +249,7 @@ export function EditHostDialog({
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "__none__")}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger aria-label="Access List">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function LoadBalancerFields({
|
||||
const showCookieFields = policy === "cookie";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-500/5 p-5">
|
||||
<input type="hidden" name="lb_present" value="1" />
|
||||
<input type="hidden" name="lb_enabled_present" value="1" />
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -25,7 +25,7 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-4">
|
||||
<div className="rounded-lg border border-amber-500/60 bg-amber-500/5 p-4">
|
||||
<input type="hidden" name="mtls_present" value="1" />
|
||||
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
|
||||
{enabled && selectedIds.map(id => (
|
||||
@@ -35,7 +35,7 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-blue-500 flex items-center justify-center shrink-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-amber-500 flex items-center justify-center shrink-0">
|
||||
<LockKeyhole className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -6,8 +6,9 @@ type Props = { initialData?: RewriteConfig | null };
|
||||
export function RewriteFields({ initialData }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Path Prefix Rewrite</label>
|
||||
<label htmlFor="rewrite_path_prefix" className="text-sm font-medium mb-1 block">Path Prefix Rewrite</label>
|
||||
<Input
|
||||
id="rewrite_path_prefix"
|
||||
name="rewrite_path_prefix"
|
||||
placeholder="/recipes"
|
||||
defaultValue={initialData?.path_prefix ?? ""}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function UpstreamDnsResolutionFields({
|
||||
: `Override: ${currentMode === "inherit" ? "inherit mode" : currentMode}, ${currentFamily === "inherit" ? "inherit family" : currentFamily}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
|
||||
<div className="rounded-lg border border-violet-500/60 bg-violet-500/5 p-5">
|
||||
<input type="hidden" name="upstream_dns_resolution_present" value="1" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
|
||||
@@ -46,7 +46,7 @@ export function AppDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto py-4 px-1">{children}</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-4 px-1">{children}</div>
|
||||
|
||||
<DialogFooter>
|
||||
{actions ?? (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -20,6 +20,7 @@ export type Column<T> = {
|
||||
label: string;
|
||||
align?: "left" | "right" | "center";
|
||||
width?: string | number;
|
||||
sortKey?: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -30,11 +31,13 @@ type DataTableProps<T> = {
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
rowClassName?: (row: T) => string;
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
sort?: { sortBy: string; sortDir: "asc" | "desc" };
|
||||
mobileCard?: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -77,19 +80,51 @@ function PaginationBar({ page, perPage, total }: { page: number; perPage: number
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHeader({ col, sort }: { col: Column<unknown>; sort?: { sortBy: string; sortDir: "asc" | "desc" } }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
if (!col.sortKey) return <>{col.label}</>;
|
||||
|
||||
const isActive = sort?.sortBy === col.sortKey;
|
||||
const nextDir = isActive && sort?.sortDir === "asc" ? "desc" : "asc";
|
||||
|
||||
function handleSort() {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("sortBy", col.sortKey!);
|
||||
params.set("sortDir", nextDir);
|
||||
params.set("page", "1");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8 font-medium" onClick={handleSort}>
|
||||
{col.label}
|
||||
{isActive ? (
|
||||
sort?.sortDir === "asc" ? <ArrowUp className="ml-1 h-3.5 w-3.5" /> : <ArrowDown className="ml-1 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="ml-1 h-3.5 w-3.5 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopTable<T>({
|
||||
columns, data, keyField, emptyMessage, onRowClick, isEmpty, loading,
|
||||
columns, data, keyField, emptyMessage, onRowClick, rowClassName, isEmpty, loading, sort,
|
||||
}: {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
keyField: keyof T;
|
||||
emptyMessage: string;
|
||||
onRowClick?: (row: T) => void;
|
||||
rowClassName?: (row: T) => string;
|
||||
isEmpty: boolean;
|
||||
loading?: boolean;
|
||||
sort?: { sortBy: string; sortDir: "asc" | "desc" };
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="rounded-md border bg-card overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -99,7 +134,7 @@ function DesktopTable<T>({
|
||||
style={{ width: col.width }}
|
||||
className={col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : ""}
|
||||
>
|
||||
{col.label}
|
||||
<SortableHeader col={col as Column<unknown>} sort={sort} />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -126,7 +161,10 @@ function DesktopTable<T>({
|
||||
<TableRow
|
||||
key={String(row[keyField])}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-muted/50" : ""}
|
||||
className={[
|
||||
onRowClick ? "cursor-pointer hover:bg-muted/50" : "",
|
||||
rowClassName ? rowClassName(row) : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
@@ -152,7 +190,9 @@ export function DataTable<T>({
|
||||
emptyMessage = "No data available",
|
||||
loading = false,
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
pagination,
|
||||
sort,
|
||||
mobileCard,
|
||||
}: DataTableProps<T>) {
|
||||
const isEmpty = data.length === 0 && !loading;
|
||||
@@ -186,7 +226,8 @@ export function DataTable<T>({
|
||||
<DesktopTable
|
||||
columns={columns} data={data} keyField={keyField}
|
||||
emptyMessage={emptyMessage} onRowClick={onRowClick}
|
||||
isEmpty={isEmpty} loading={loading}
|
||||
rowClassName={rowClassName}
|
||||
isEmpty={isEmpty} loading={loading} sort={sort}
|
||||
/>
|
||||
{pagination && <PaginationBar {...pagination} />}
|
||||
</div>
|
||||
@@ -199,6 +240,7 @@ export function DataTable<T>({
|
||||
<DesktopTable
|
||||
columns={columns} data={data} keyField={keyField}
|
||||
emptyMessage={emptyMessage} onRowClick={onRowClick}
|
||||
rowClassName={rowClassName}
|
||||
isEmpty={isEmpty} loading={loading}
|
||||
/>
|
||||
{pagination && <PaginationBar {...pagination} />}
|
||||
|
||||
@@ -10,7 +10,7 @@ type StatusChipProps = {
|
||||
|
||||
const STATUS_CONFIG: Record<StatusType, { dot: string; text: string; label: string }> = {
|
||||
active: { dot: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]", text: "text-green-500", label: "Active" },
|
||||
inactive: { dot: "bg-zinc-500", text: "text-zinc-400", label: "Paused" },
|
||||
inactive: { dot: "bg-zinc-500", text: "text-zinc-600 dark:text-zinc-400", label: "Paused" },
|
||||
error: { dot: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]", text: "text-red-500", label: "Error" },
|
||||
warning: { dot: "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]", text: "text-amber-500", label: "Warning" },
|
||||
};
|
||||
|
||||
@@ -15,6 +15,14 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20",
|
||||
warning:
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20",
|
||||
info:
|
||||
"border-cyan-500/30 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20",
|
||||
muted:
|
||||
"border-zinc-500/30 bg-zinc-500/10 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-500/20",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 flex w-full max-w-lg max-h-[90dvh] flex-col translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b bg-muted/40", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user