From 6cd4319fcf540ef70f74cc2f10d0d4297ee7b788 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Thu, 11 Apr 2024 16:59:01 +0100 Subject: feat(gui): add base structure (#1935) * initial * ui things * cargo * update, add history refresh button * history page a bit better, add initial dotfiles page * re-org layout * bye squigglies * add dotfiles ui, show aliases * add default shell detection * put stats in a little drawer, alias import changes * use new table for aliases, add alias deleting * support adding aliases * close drawer when added, no alias autocomplete * clippy, format * attempt to ensure gdk is installed ok * sudo * no linux things on mac ffs * I forgot we build for windows too... end of day * remove tauri backend from workspace --- ui/src/components/Drawer.tsx | 26 +++++ ui/src/components/HistoryList.tsx | 75 +++++++++++++ ui/src/components/HistorySearch.tsx | 56 ++++++++++ ui/src/components/dotfiles/Aliases.tsx | 191 +++++++++++++++++++++++++++++++ ui/src/components/history/Stats.tsx | 143 ++++++++++++++++++++++++ ui/src/components/ui/button.tsx | 56 ++++++++++ ui/src/components/ui/data-table.tsx | 80 +++++++++++++ ui/src/components/ui/dropdown-menu.tsx | 198 +++++++++++++++++++++++++++++++++ ui/src/components/ui/table.tsx | 117 +++++++++++++++++++ 9 files changed, 942 insertions(+) create mode 100644 ui/src/components/Drawer.tsx create mode 100644 ui/src/components/HistoryList.tsx create mode 100644 ui/src/components/HistorySearch.tsx create mode 100644 ui/src/components/dotfiles/Aliases.tsx create mode 100644 ui/src/components/history/Stats.tsx create mode 100644 ui/src/components/ui/button.tsx create mode 100644 ui/src/components/ui/data-table.tsx create mode 100644 ui/src/components/ui/dropdown-menu.tsx create mode 100644 ui/src/components/ui/table.tsx (limited to 'ui/src/components') 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 ( + + {trigger} + + + + {children} + + + + ); +} 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 ( + + + ); +} 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 ( +
+
{ + e.preventDefault(); + }} + > + +
+ ); +} 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>, +) { + invoke("aliases").then((aliases: any) => { + setAliases(aliases); + }); +} + +type Alias = { + name: string; + value: string; +}; + +function deleteAlias( + name: string, + setAliases: React.Dispatch>, +) { + 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 ( +
+

+ Add alias +

+

Add a new alias to your shell

+ +
{ + e.preventDefault(); + + invoke("set_alias", { name: name, value: value }) + .then(() => { + console.log("Added alias"); + + if (onAdd) onAdd(); + }) + .catch(() => { + console.error("Failed to add alias"); + }); + }} + > + setName(e.target.value)} + placeholder="Alias name" + /> + + setValue(e.target.value)} + placeholder="Alias value" + /> + + +
+
+ ); +} + +export default function Aliases() { + let [aliases, setAliases] = useState([]); + let [aliasDrawerOpen, setAliasDrawerOpen] = useState(false); + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "value", + header: "Value", + }, + { + id: "actions", + cell: ({ row }: any) => { + const alias = row.original; + + return ( + + + + + + Actions + deleteAlias(alias.name, setAliases)} + > + Delete + + + + ); + }, + }, + ]; + + useEffect(() => { + loadAliases(setAliases); + }, []); + + return ( +
+
+
+

+ Aliases +

+

+ Aliases allow you to condense long commands into short, + easy-to-remember commands. +

+
+
+ + Add + + } + > + { + loadAliases(setAliases); + setAliasDrawerOpen(false); + }} + /> + +
+
+
+
+
+ +
+
+
+
+ ); +} 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() { +
+ +
; +} + +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 ( +
+
+
+ {stats.map((item) => ( +
+
+ {item.name} +
+
+ {item.stat} +
+
+ ))} +
+
+ +
+
+ {/* Use an "onChange" listener to redirect the user to the selected tab URL. */} + +
+
+ +
+ +
+ + + + + + + + +
+
+
+ ); +} 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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +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 { + columns: ColumnDef[]; + data: TData[]; +} + +export default function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} 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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +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 +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} -- cgit v1.3.1