add site targets, client resources, and auto login

This commit is contained in:
miloschwartz
2025-08-14 18:24:21 -07:00
parent 67ba225003
commit 5c04b1e14a
80 changed files with 5651 additions and 2385 deletions

View File

@@ -43,35 +43,30 @@ import {
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
import { GetSiteResponse, Container } from "@server/routers/site";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { Container } from "@server/routers/site";
import { useTranslations } from "next-intl";
// Type definitions based on the JSON structure
import { FaDocker } from "react-icons/fa";
interface ContainerSelectorProps {
site: GetSiteResponse;
site: { siteId: number; name: string; type: string };
containers: Container[];
isAvailable: boolean;
onContainerSelect?: (hostname: string, port?: number) => void;
onRefresh?: () => void;
}
export const ContainersSelector: FC<ContainerSelectorProps> = ({
site,
onContainerSelect
containers,
isAvailable,
onContainerSelect,
onRefresh
}) => {
const [open, setOpen] = useState(false);
const t = useTranslations();
const { isAvailable, containers, fetchContainers } = useDockerSocket(site);
useEffect(() => {
console.log("DockerSocket isAvailable:", isAvailable);
if (isAvailable) {
fetchContainers();
}
}, [isAvailable]);
if (!site || !isAvailable) {
if (!site || !isAvailable || site.type !== "newt") {
return null;
}
@@ -84,13 +79,14 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
return (
<>
<a
<Button
type="button"
className="text-sm text-primary hover:underline cursor-pointer"
variant="outline"
onClick={() => setOpen(true)}
title={t("viewDockerContainers")}
>
{t("viewDockerContainers")}
</a>
<FaDocker size={15} />
</Button>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
<CredenzaHeader>
@@ -106,7 +102,7 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
<DockerContainersTable
containers={containers}
onContainerSelect={handleContainerSelect}
onRefresh={() => fetchContainers()}
onRefresh={onRefresh || (() => {})}
/>
</div>
</CredenzaBody>
@@ -263,7 +259,9 @@ const DockerContainersTable: FC<{
size="sm"
className="h-6 px-2 text-xs hover:bg-muted"
>
{t("containerLabelsCount", { count: labelEntries.length })}
{t("containerLabelsCount", {
count: labelEntries.length
})}
</Button>
</PopoverTrigger>
<PopoverContent side="top" align="start">
@@ -279,7 +277,10 @@ const DockerContainersTable: FC<{
{key}
</div>
<div className="font-mono text-muted-foreground pl-2 break-all">
{value || t("containerLabelEmpty")}
{value ||
t(
"containerLabelEmpty"
)}
</div>
</div>
))}
@@ -316,7 +317,9 @@ const DockerContainersTable: FC<{
<Popover>
<PopoverTrigger asChild>
<Button variant="link" size="sm">
{t("containerPortsMore", { count: ports.length - 2 })}
{t("containerPortsMore", {
count: ports.length - 2
})}
</Button>
</PopoverTrigger>
<PopoverContent
@@ -356,7 +359,9 @@ const DockerContainersTable: FC<{
<Button
variant="default"
size="sm"
onClick={() => onContainerSelect(row.original, ports[0])}
onClick={() =>
onContainerSelect(row.original, ports[0])
}
disabled={row.original.state !== "running"}
>
{t("select")}
@@ -415,9 +420,7 @@ const DockerContainersTable: FC<{
hideStoppedContainers) &&
containers.length > 0 ? (
<>
<p>
{t("noContainersMatchingFilters")}
</p>
<p>{t("noContainersMatchingFilters")}</p>
<div className="space-x-2">
{hideContainersWithoutPorts && (
<Button
@@ -446,9 +449,7 @@ const DockerContainersTable: FC<{
</div>
</>
) : (
<p>
{t("noContainersFound")}
</p>
<p>{t("noContainersFound")}</p>
)}
</div>
</div>
@@ -463,7 +464,9 @@ const DockerContainersTable: FC<{
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("searchContainersPlaceholder", { count: initialFilters.length })}
placeholder={t("searchContainersPlaceholder", {
count: initialFilters.length
})}
value={searchInput}
onChange={(event) =>
setSearchInput(event.target.value)
@@ -473,7 +476,10 @@ const DockerContainersTable: FC<{
{searchInput &&
table.getFilteredRowModel().rows.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })}
{t("searchResultsCount", {
count: table.getFilteredRowModel().rows
.length
})}
</div>
)}
</div>
@@ -644,7 +650,9 @@ const DockerContainersTable: FC<{
{t("searching")}
</div>
) : (
t("noContainersFoundMatching", { filter: globalFilter })
t("noContainersFoundMatching", {
filter: globalFilter
})
)}
</TableCell>
</TableRow>

View File

@@ -0,0 +1,422 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListSitesResponse } from "@server/routers/site";
import { cn } from "@app/lib/cn";
type Site = ListSitesResponse["sites"][0];
type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
sites: Site[];
onSuccess?: () => void;
};
export default function CreateInternalResourceDialog({
open,
setOpen,
orgId,
sites,
onSuccess
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
name: z
.string()
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z
.number()
.int()
.positive()
.min(1, t("createInternalResourceDialogProxyPortMin"))
.max(65535, t("createInternalResourceDialogProxyPortMax")),
destinationIp: z.string().ip(t("createInternalResourceDialogInvalidIPAddressFormat")),
destinationPort: z
.number()
.int()
.positive()
.min(1, t("createInternalResourceDialogDestinationPortMin"))
.max(65535, t("createInternalResourceDialogDestinationPortMax"))
});
type FormData = z.infer<typeof formSchema>;
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
siteId: availableSites[0]?.siteId || 0,
protocol: "tcp",
proxyPort: undefined,
destinationIp: "",
destinationPort: undefined
}
});
useEffect(() => {
if (open && availableSites.length > 0) {
form.reset({
name: "",
siteId: availableSites[0].siteId,
protocol: "tcp",
proxyPort: undefined,
destinationIp: "",
destinationPort: undefined
});
}
}, [open]);
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
await api.put(`/org/${orgId}/site/${data.siteId}/resource`, {
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort,
enabled: true
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
variant: "default"
});
onSuccess?.();
setOpen(false);
} catch (error) {
console.error("Error creating internal resource:", error);
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t("createInternalResourceDialogFailedToCreateInternalResource")
),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
};
if (availableSites.length === 0) {
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogNoSitesAvailableDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaFooter>
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogCreateClientResourceDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
id="create-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogResourceProperties")}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(site) => site.siteId === field.value
)?.name
: t("createInternalResourceDialogSelectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
<CommandList>
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
<CommandGroup>
{availableSites.map((site) => (
<CommandItem
key={site.siteId}
value={site.name}
onSelect={() => {
field.onChange(site.siteId);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogSitePortDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogTargetConfiguration")}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestinationIP")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationIPDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestinationPort")}
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationPortDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Separator } from "@app/components/ui/separator";
type InternalResourceData = {
id: number;
name: string;
orgId: string;
siteName: string;
protocol: string;
proxyPort: number | null;
siteId: number;
destinationIp?: string;
destinationPort?: number;
};
type EditInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
resource: InternalResourceData;
orgId: string;
onSuccess?: () => void;
};
export default function EditInternalResourceDialog({
open,
setOpen,
resource,
orgId,
onSuccess
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
destinationIp: z.string().ip(t("editInternalResourceDialogInvalidIPAddressFormat")),
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax"))
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
}
});
useEffect(() => {
if (open) {
form.reset({
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
});
}
}, [open, resource, form]);
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
// Update the site resource
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort
});
toast({
title: t("editInternalResourceDialogSuccess"),
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
variant: "default"
});
onSuccess?.();
setOpen(false);
} catch (error) {
console.error("Error updating internal resource:", error);
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
<CredenzaDescription>
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -70,7 +70,7 @@ export function LayoutSidebar({
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-4 pt-1">
<div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
<Link

View File

@@ -54,7 +54,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
role="combobox"
aria-expanded={open}
className={cn(
"shadow-xs",
"shadow-2xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
>

View File

@@ -150,7 +150,7 @@ export function SidebarNav({
{section.heading}
</div>
)}
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mt-1 pl-2">
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);

View File

@@ -9,7 +9,7 @@ const alertVariants = cva(
variants: {
variant: {
default: "bg-card border text-foreground",
neutral: "bg-card border text-foreground",
neutral: "bg-card bg-muted border text-foreground",
destructive:
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:

View File

@@ -30,7 +30,15 @@ import {
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -46,6 +54,8 @@ type DataTableProps<TData, TValue> = {
id: string;
desc: boolean;
};
tabs?: TabFilter[];
defaultTab?: string;
};
export function DataTable<TData, TValue>({
@@ -58,17 +68,36 @@ export function DataTable<TData, TValue>({
isRefreshing,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort
defaultSort,
tabs,
defaultTab
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const t = useTranslations();
// Apply tab filter to data
const filteredData = useMemo(() => {
if (!tabs || activeTab === "") {
return data;
}
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
if (!activeTabFilter) {
return data;
}
return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab]);
const table = useReactTable({
data,
data: filteredData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
@@ -90,20 +119,49 @@ export function DataTable<TData, TValue>({
}
});
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
table.setPageIndex(0);
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex items-center w-full sm:max-w-sm sm:mr-2 relative">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
table.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
table.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
{tabs && tabs.length > 0 && (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
>
{tab.label} (
{data.filter(tab.filterFn).length})
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (

View File

@@ -55,7 +55,7 @@ function InputOTPSlot({
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-2xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}

View File

@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs 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",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 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 shadow-2xs",
"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",
className
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs 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",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 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 shadow-2xs",
"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",
className

View File

@@ -36,7 +36,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 cursor-pointer 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 w-full",
"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 cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs 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 w-full",
className
)}
{...props}

View File

@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
className
)}
{...props}