diff options
Diffstat (limited to 'ui/src/components')
| -rw-r--r-- | ui/src/components/Drawer.tsx | 26 | ||||
| -rw-r--r-- | ui/src/components/HistoryList.tsx | 75 | ||||
| -rw-r--r-- | ui/src/components/HistorySearch.tsx | 56 | ||||
| -rw-r--r-- | ui/src/components/dotfiles/Aliases.tsx | 191 | ||||
| -rw-r--r-- | ui/src/components/history/Stats.tsx | 143 | ||||
| -rw-r--r-- | ui/src/components/ui/button.tsx | 56 | ||||
| -rw-r--r-- | ui/src/components/ui/data-table.tsx | 80 | ||||
| -rw-r--r-- | ui/src/components/ui/dropdown-menu.tsx | 198 | ||||
| -rw-r--r-- | ui/src/components/ui/table.tsx | 117 |
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> on </span> + + <span className="relative truncate "> + {h.host} + </span> + + <span> in </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, +} |
