aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/Drawer.tsx26
-rw-r--r--ui/src/components/HistoryList.tsx75
-rw-r--r--ui/src/components/HistorySearch.tsx56
-rw-r--r--ui/src/components/dotfiles/Aliases.tsx191
-rw-r--r--ui/src/components/history/Stats.tsx143
-rw-r--r--ui/src/components/ui/button.tsx56
-rw-r--r--ui/src/components/ui/data-table.tsx80
-rw-r--r--ui/src/components/ui/dropdown-menu.tsx198
-rw-r--r--ui/src/components/ui/table.tsx117
9 files changed, 942 insertions, 0 deletions
diff --git a/ui/src/components/Drawer.tsx b/ui/src/components/Drawer.tsx
new file mode 100644
index 00000000..65bb5ab4
--- /dev/null
+++ b/ui/src/components/Drawer.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+
+import { Drawer as VDrawer } from "vaul";
+
+export default function Drawer({
+ trigger,
+ children,
+ width,
+ open,
+ onOpenChange,
+}: any) {
+ return (
+ <VDrawer.Root direction="right" open={open} onOpenChange={onOpenChange}>
+ <VDrawer.Trigger asChild>{trigger}</VDrawer.Trigger>
+ <VDrawer.Portal>
+ <VDrawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
+ <VDrawer.Content
+ style={{ width: width || "400px" }}
+ className={`bg-white flex flex-col z-50 h-full mt-24 fixed bottom-0 right-0`}
+ >
+ {children}
+ </VDrawer.Content>
+ </VDrawer.Portal>
+ </VDrawer.Root>
+ );
+}
diff --git a/ui/src/components/HistoryList.tsx b/ui/src/components/HistoryList.tsx
new file mode 100644
index 00000000..b31a4be4
--- /dev/null
+++ b/ui/src/components/HistoryList.tsx
@@ -0,0 +1,75 @@
+import { DateTime } from 'luxon';
+import { ChevronRightIcon } from '@heroicons/react/20/solid'
+
+function msToTime(ms) {
+ let milliseconds = (ms).toFixed(1);
+ let seconds = (ms / 1000).toFixed(1);
+ let minutes = (ms / (1000 * 60)).toFixed(1);
+ let hours = (ms / (1000 * 60 * 60)).toFixed(1);
+ let days = (ms / (1000 * 60 * 60 * 24)).toFixed(1);
+
+ if (milliseconds < 1000) return milliseconds + "ms";
+ else if (seconds < 60) return seconds + "s";
+ else if (minutes < 60) return minutes + "m";
+ else if (hours < 24) return hours + "hr";
+ else return days + " Days"
+}
+
+export default function HistoryList(props){
+ return (
+
+ <ul
+ role="list"
+ className="divide-y divide-gray-100 overflow-hidden bg-white shadow-sm ring-1 ring-gray-900/5"
+ >
+ {props.history.map((h) => (
+ <li key={h.id} className="relative flex justify-between gap-x-6 px-4 py-5 hover:bg-gray-50 sm:px-6">
+ <div className="flex min-w-0 gap-x-4">
+ <div className="flex flex-col justify-center">
+ <p className="flex text-xs text-gray-500 justify-center">{ DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(DateTime.TIME_WITH_SECONDS)}</p>
+ <p className="flex text-xs mt-1 text-gray-400 justify-center">{ DateTime.fromMillis(h.timestamp / 1000000).toLocaleString(DateTime.DATE_SHORT)}</p>
+ </div>
+ <div className="min-w-0 flex-col justify-center">
+ <pre className="whitespace-pre-wrap"><code className="text-sm">{h.command}</code></pre>
+ <p className="mt-1 flex text-xs leading-5 text-gray-500">
+ <span className="relative truncate ">
+ {h.user}
+ </span>
+
+ <span>&nbsp;on&nbsp;</span>
+
+ <span className="relative truncate ">
+ {h.host}
+ </span>
+
+ <span>&nbsp;in&nbsp;</span>
+
+ <span className="relative truncate ">
+ {h.cwd}
+ </span>
+ </p>
+ </div>
+ </div>
+ <div className="flex shrink-0 items-center gap-x-4">
+ <div className="hidden sm:flex sm:flex-col sm:items-end">
+ <p className="text-sm leading-6 text-gray-900">{h.exit}</p>
+ {h.duration ? (
+ <p className="mt-1 text-xs leading-5 text-gray-500">
+ <time dateTime={h.duration}>{msToTime(h.duration / 1000000)}</time>
+ </p>
+ ) : (
+ <div className="mt-1 flex items-center gap-x-1.5">
+ <div className="flex-none rounded-full bg-emerald-500/20 p-1">
+ <div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
+ </div>
+ <p className="text-xs leading-5 text-gray-500">Online</p>
+ </div>
+ )}
+ </div>
+ <ChevronRightIcon className="h-5 w-5 flex-none text-gray-400" aria-hidden="true" />
+ </div>
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/ui/src/components/HistorySearch.tsx b/ui/src/components/HistorySearch.tsx
new file mode 100644
index 00000000..08bed2a8
--- /dev/null
+++ b/ui/src/components/HistorySearch.tsx
@@ -0,0 +1,56 @@
+import { useState } from "react";
+import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
+
+interface HistorySearchProps {
+ refresh: (query: string) => void;
+}
+
+export default function HistorySearch(props: HistorySearchProps) {
+ let [searchQuery, setSearchQuery] = useState("");
+
+ return (
+ <div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
+ <form
+ className="relative flex flex-1"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <label htmlFor="search-field" className="sr-only">
+ Search
+ </label>
+ <MagnifyingGlassIcon
+ className="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400"
+ aria-hidden="true"
+ />
+ <input
+ id="search-field"
+ className="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm"
+ placeholder="Search..."
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ type="search"
+ name="search"
+ onChange={(query) => {
+ setSearchQuery(query.target.value);
+ props.refresh(query.target.value);
+ }}
+ />
+ </form>
+ <div className="flex items-center gap-x-4 lg:gap-x-6">
+ <button
+ type="button"
+ className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500"
+ onClick={() => {
+ props.refresh(searchQuery);
+ }}
+ >
+ <ArrowPathIcon className="h-6 w-6" aria-hidden="true" />
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/ui/src/components/dotfiles/Aliases.tsx b/ui/src/components/dotfiles/Aliases.tsx
new file mode 100644
index 00000000..4854e6b5
--- /dev/null
+++ b/ui/src/components/dotfiles/Aliases.tsx
@@ -0,0 +1,191 @@
+import React, { useEffect, useState } from "react";
+
+import DataTable from "@/components/ui/data-table";
+import { Button } from "@/components/ui/button";
+import { MoreHorizontal } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import { invoke } from "@tauri-apps/api/core";
+import Drawer from "@/components/Drawer";
+
+function loadAliases(
+ setAliases: React.Dispatch<React.SetStateAction<never[]>>,
+) {
+ invoke("aliases").then((aliases: any) => {
+ setAliases(aliases);
+ });
+}
+
+type Alias = {
+ name: string;
+ value: string;
+};
+
+function deleteAlias(
+ name: string,
+ setAliases: React.Dispatch<React.SetStateAction<never[]>>,
+) {
+ invoke("delete_alias", { name: name })
+ .then(() => {
+ console.log("Deleted alias");
+ loadAliases(setAliases);
+ })
+ .catch(() => {
+ console.error("Failed to delete alias");
+ });
+}
+
+function AddAlias({ onAdd: onAdd }: { onAdd?: () => void }) {
+ let [name, setName] = useState("");
+ let [value, setValue] = useState("");
+
+ // simple form to add aliases
+ return (
+ <div className="p-4">
+ <h2 className="text-xl font-semibold leading-6 text-gray-900">
+ Add alias
+ </h2>
+ <p className="mt-2">Add a new alias to your shell</p>
+
+ <form
+ className="mt-4"
+ onSubmit={(e) => {
+ e.preventDefault();
+
+ invoke("set_alias", { name: name, value: value })
+ .then(() => {
+ console.log("Added alias");
+
+ if (onAdd) onAdd();
+ })
+ .catch(() => {
+ console.error("Failed to add alias");
+ });
+ }}
+ >
+ <input
+ className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="Alias name"
+ />
+
+ <input
+ className="mt-4 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ type="text"
+ value={value}
+ onChange={(e) => setValue(e.target.value)}
+ placeholder="Alias value"
+ />
+
+ <input
+ type="submit"
+ className="block mt-4 rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
+ value="Add alias"
+ />
+ </form>
+ </div>
+ );
+}
+
+export default function Aliases() {
+ let [aliases, setAliases] = useState([]);
+ let [aliasDrawerOpen, setAliasDrawerOpen] = useState(false);
+
+ const columns: ColumnDef<Alias>[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ },
+ {
+ accessorKey: "value",
+ header: "Value",
+ },
+ {
+ id: "actions",
+ cell: ({ row }: any) => {
+ const alias = row.original;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0 float-right">
+ <span className="sr-only">Open menu</span>
+ <MoreHorizontal className="h-4 w-4 text-right" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>Actions</DropdownMenuLabel>
+ <DropdownMenuItem
+ onClick={() => deleteAlias(alias.name, setAliases)}
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ },
+ ];
+
+ useEffect(() => {
+ loadAliases(setAliases);
+ }, []);
+
+ return (
+ <div className="pt-10">
+ <div className="sm:flex sm:items-center">
+ <div className="sm:flex-auto">
+ <h1 className="text-base font-semibold leading-6 text-gray-900">
+ Aliases
+ </h1>
+ <p className="mt-2 text-sm text-gray-700">
+ Aliases allow you to condense long commands into short,
+ easy-to-remember commands.
+ </p>
+ </div>
+ <div className="mt-4 sm:ml-16 sm:mt-0 flex-row">
+ <Drawer
+ open={aliasDrawerOpen}
+ onOpenChange={setAliasDrawerOpen}
+ width="30%"
+ trigger={
+ <button
+ type="button"
+ className="block rounded-md bg-green-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
+ >
+ Add
+ </button>
+ }
+ >
+ <AddAlias
+ onAdd={() => {
+ loadAliases(setAliases);
+ setAliasDrawerOpen(false);
+ }}
+ />
+ </Drawer>
+ </div>
+ </div>
+ <div className="mt-8 flow-root">
+ <div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
+ <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ <DataTable columns={columns} data={aliases} />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/ui/src/components/history/Stats.tsx b/ui/src/components/history/Stats.tsx
new file mode 100644
index 00000000..afd9ed89
--- /dev/null
+++ b/ui/src/components/history/Stats.tsx
@@ -0,0 +1,143 @@
+import { useState, useEffect } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import PacmanLoader from "react-spinners/PacmanLoader";
+
+import {
+ BarChart,
+ Bar,
+ Rectangle,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from "recharts";
+
+const tabs = [
+ { name: "Daily", href: "#", current: true },
+ { name: "Weekly", href: "#", current: false },
+ { name: "Monthly", href: "#", current: false },
+];
+
+function classNames(...classes) {
+ return classes.filter(Boolean).join(" ");
+}
+
+function renderLoading() {
+ <div className="flex items-center justify-center h-full">
+ <PacmanLoader color="#26bd65" />
+ </div>;
+}
+
+export default function Stats() {
+ const [stats, setStats]: any = useState([]);
+ const [chart, setChart]: any = useState([]);
+
+ console.log("Stats mounted");
+
+ useEffect(() => {
+ if (stats.length != 0) return;
+
+ invoke("global_stats")
+ .then((s: any) => {
+ console.log(s.daily);
+
+ setStats([
+ {
+ name: "Total history",
+ stat: s.total_history.toLocaleString(),
+ },
+ {
+ name: "Last 1d",
+ stat: s.last_1d.toLocaleString(),
+ },
+ {
+ name: "Last 7d",
+ stat: s.last_7d.toLocaleString(),
+ },
+ {
+ name: "Last 30d",
+ stat: s.last_30d.toLocaleString(),
+ },
+ ]);
+
+ setChart(s.daily);
+ })
+ .catch((e) => {
+ console.log(e);
+ });
+ }, []);
+
+ if (stats.length == 0) {
+ return renderLoading();
+ }
+
+ return (
+ <div className="flex flex-col">
+ <div className="flexfull">
+ <dl className="grid grid-cols-1 sm:grid-cols-4 w-full">
+ {stats.map((item) => (
+ <div
+ key={item.name}
+ className="overflow-hidden bg-white px-4 py-5 shadow sm:p-6"
+ >
+ <dt className="truncate text-sm font-medium text-gray-500">
+ {item.name}
+ </dt>
+ <dd className="mt-1 text-3xl font-semibold tracking-tight text-gray-900">
+ {item.stat}
+ </dd>
+ </div>
+ ))}
+ </dl>
+ </div>
+
+ <div className="flex flex-col h-54 py-4 pl-5">
+ <div className="sm:hidden">
+ {/* Use an "onChange" listener to redirect the user to the selected tab URL. */}
+ <select
+ id="tabs"
+ name="tabs"
+ className="block w-full rounded-md border-gray-300 focus:border-green-500 focus:ring-green-500"
+ defaultValue={tabs.find((tab) => tab.current).name}
+ >
+ {tabs.map((tab) => (
+ <option key={tab.name}>{tab.name}</option>
+ ))}
+ </select>
+ </div>
+ <div className="hidden sm:block">
+ <nav className="flex space-x-4" aria-label="Tabs">
+ {tabs.map((tab) => (
+ <a
+ key={tab.name}
+ href={tab.href}
+ className={classNames(
+ tab.current
+ ? "bg-gray-100 text-gray-700"
+ : "text-gray-500 hover:text-gray-700",
+ "rounded-md px-3 py-2 text-sm font-medium",
+ )}
+ aria-current={tab.current ? "page" : undefined}
+ >
+ {tab.name}
+ </a>
+ ))}
+ </nav>
+ </div>
+
+ <div className="flex flex-col h-48 pt-5 pr-5">
+ <ResponsiveContainer width="100%" height="100%">
+ <BarChart width={500} height={300} data={chart}>
+ <XAxis dataKey="name" hide={true} />
+ <YAxis />
+ <Tooltip />
+ <Bar dataKey="value" fill="#26bd65" />
+ </BarChart>
+ </ResponsiveContainer>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
new file mode 100644
index 00000000..0ba42773
--- /dev/null
+++ b/ui/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+ <Comp
+ className={cn(buttonVariants({ variant, size, className }))}
+ ref={ref}
+ {...props}
+ />
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/ui/src/components/ui/data-table.tsx b/ui/src/components/ui/data-table.tsx
new file mode 100644
index 00000000..cf96b620
--- /dev/null
+++ b/ui/src/components/ui/data-table.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+interface DataTableProps<TData, TValue> {
+ columns: ColumnDef<TData, TValue>[];
+ data: TData[];
+}
+
+export default function DataTable<TData, TValue>({
+ columns,
+ data,
+}: DataTableProps<TData, TValue>) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ return (
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => {
+ return (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ </TableHead>
+ );
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}
diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..769ff7aa
--- /dev/null
+++ b/ui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRight className="ml-auto h-4 w-4" />
+ </DropdownMenuPrimitive.SubTrigger>
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
+ className
+ )}
+ {...props}
+ />
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ className={cn(
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
+ className
+ )}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Item
+ ref={ref}
+ className={cn(
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ />
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
+>(({ className, children, checked, ...props }, ref) => (
+ <DropdownMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Check className="h-4 w-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.CheckboxItem>
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
+>(({ className, children, ...props }, ref) => (
+ <DropdownMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Circle className="h-2 w-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-2 py-1.5 text-sm font-semibold",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ />
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.Separator
+ ref={ref}
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
+ {...props}
+ />
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
+ {...props}
+ />
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/ui/src/components/ui/table.tsx b/ui/src/components/ui/table.tsx
new file mode 100644
index 00000000..7f3502f8
--- /dev/null
+++ b/ui/src/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+ <div className="relative w-full overflow-auto">
+ <table
+ ref={ref}
+ className={cn("w-full caption-bottom text-sm", className)}
+ {...props}
+ />
+ </div>
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tbody
+ ref={ref}
+ className={cn("[&_tr:last-child]:border-0", className)}
+ {...props}
+ />
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tfoot
+ ref={ref}
+ className={cn(
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+ <tr
+ ref={ref}
+ className={cn(
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+ className
+ )}
+ {...props}
+ />
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <th
+ ref={ref}
+ className={cn(
+ "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <td
+ ref={ref}
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+ <caption
+ ref={ref}
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}