initial commit

This commit is contained in:
2024-07-07 00:48:39 +02:00
commit cc7d7d71b9
49 changed files with 3017 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
import {
DataTablePagination,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui";
import { cn } from "@/lib/utils";
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
import {
type ColumnDef,
type OnChangeFn,
type PaginationState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Rows3 } from "lucide-react";
import { useMemo } from "react";
import { z } from "zod";
function isUrl(value: string) {
return z.string().url().safeParse(value).success;
}
const imageUrlRegex = new RegExp(
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
);
function isImageUrl(value: string) {
return value.match(imageUrlRegex);
}
export const DataTable = ({
tableName,
dbName,
pageIndex,
pageSize,
onPageSizeChange,
onPageIndexChange,
}: {
tableName: string;
pageIndex: number;
dbName: string;
pageSize: number;
onPageIndexChange: (pageIndex: number) => void;
onPageSizeChange: (pageSize: number) => void;
}) => {
const { data: details } = useTableColumnsQuery({ name: tableName });
const { data } = useTableDataQuery({
tableName,
dbName,
perPage: pageSize,
page: pageIndex,
});
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
if (typeof args === "function") {
const newArgs = args({
pageIndex,
pageSize,
});
if (newArgs.pageSize !== pageSize) {
onPageSizeChange(newArgs.pageSize);
} else if (newArgs.pageIndex !== pageIndex) {
onPageIndexChange(newArgs.pageIndex);
}
} else {
onPageSizeChange(args.pageSize);
onPageIndexChange(args.pageIndex);
}
};
const columns = useMemo(() => {
if (!details) return [] as ColumnDef<any>[];
return details.map(({ column_name, udt_name, data_type }) => ({
accessorKey: column_name,
title: column_name,
size: 300,
header: () => {
return (
<div
className={cn(
"whitespace-nowrap",
data_type === "integer" && "text-right",
)}
>
{`${column_name} [${udt_name.toUpperCase()}]`}
</div>
);
},
sortable: true,
cell: ({ row }) => {
const value = row.getValue(column_name) as any;
let finalValue = value;
if (udt_name === "timestamp") {
finalValue = new Date(value as string).toLocaleString();
}
if (typeof value === "string" && isUrl(value)) {
const isImage = isImageUrl(value);
return (
<a
href={value}
target={"_blank"}
className={cn("hover:underline")}
rel="noreferrer"
>
<div className={"flex items-center break-all gap-4"}>
{value}
{isImage && (
<img
src={value}
alt={"preview"}
className="size-20 object-cover"
/>
)}
</div>
</a>
);
}
return (
<div
className={cn("break-all", data_type === "integer" && "text-right")}
>
{finalValue}
</div>
);
},
})) as ColumnDef<any>[];
}, [details]);
const table = useReactTable({
data: data?.data ?? [],
columns,
columnResizeMode: "onChange",
getCoreRowModel: getCoreRowModel(),
rowCount: data?.count ?? 0,
state: {
pagination: {
pageIndex,
pageSize,
},
},
onPaginationChange: paginationUpdater,
manualPagination: true,
});
return (
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
<div className={"flex gap-4 items-center justify-between"}>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Rows3 /> {tableName}
</h1>
<p>
Rows: <strong>{data?.count}</strong>
</p>
</div>
<div className="rounded-md border min-h-0 h-full overflow-auto w-full min-w-0">
<Table
className={"table-fixed min-w-full"}
{...{
style: {
width: table.getCenterTotalSize(),
},
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
className={"relative"}
key={header.id}
style={{
width: header.getSize(),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<div
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`,
}}
/>
</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}
style={{
width: cell.column.getSize(),
}}
>
{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>
<DataTablePagination table={table} />
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
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",
iconSm: "size-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = 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 };

View File

@@ -0,0 +1,96 @@
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui";
import type { Table } from "@tanstack/react-table";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50, 1000].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex text-nowrap items-center justify-end text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import {
type 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 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>
);
}

View File

@@ -0,0 +1,203 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
type ComponentPropsWithoutRef,
type ElementRef,
type HTMLAttributes,
forwardRef,
} from "react";
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubContent>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Content>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Item>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Label>,
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 = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Separator>,
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
}: 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,
};

View File

@@ -0,0 +1,7 @@
export * from "./button";
export * from "./data-table-pagination";
export * from "./dropdown-menu";
export * from "./mode-toggle";
export * from "./select";
export * from "./table";
export * from "./theme-provider";

View File

@@ -0,0 +1,37 @@
import { Moon, Sun } from "lucide-react";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
useTheme,
} from "@/components/ui";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,162 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRef<
ElementRef<typeof SelectPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = forwardRef<
ElementRef<typeof SelectPrimitive.ScrollUpButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = forwardRef<
ElementRef<typeof SelectPrimitive.ScrollDownButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = forwardRef<
ElementRef<typeof SelectPrimitive.Content>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = forwardRef<
ElementRef<typeof SelectPrimitive.Label>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = forwardRef<
ElementRef<typeof SelectPrimitive.Item>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none 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">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = forwardRef<
ElementRef<typeof SelectPrimitive.Separator>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,120 @@
import { cn } from "@/lib/utils";
import {
type HTMLAttributes,
type TdHTMLAttributes,
type ThHTMLAttributes,
forwardRef,
} from "react";
const Table = forwardRef<HTMLTableElement, 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 = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = forwardRef<
HTMLTableSectionElement,
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 = forwardRef<
HTMLTableRowElement,
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 = forwardRef<
HTMLTableCellElement,
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 = forwardRef<
HTMLTableCellElement,
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 = forwardRef<
HTMLTableCaptionElement,
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,
};

View File

@@ -0,0 +1,71 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light' | 'system'
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
return context
}

129
frontend/src/index.css Normal file
View File

@@ -0,0 +1,129 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
:root {
text-underline-position: under;
}
.grid-rows-layout {
grid-template-rows: 60px 1fr;
}
.grid-cols-layout {
grid-template-columns: 264px 1fr;
}
.max-w-layout {
max-width: calc(100vw - 264px);
}
.w-layout {
width: calc(100vw - 264px);
}
.resizer {
position: absolute;
top: 0;
height: 100%;
width: 5px;
background: rgba(100, 100, 100, 0.5);
cursor: col-resize;
user-select: none;
touch-action: none;
right: 0;
}
.resizer.isResizing {
background: rgba(100, 100, 100, 20);
opacity: 1;
}
tr {
width: fit-content;
}
@media (hover: hover) {
.resizer {
opacity: 0;
}
*:hover > .resizer {
opacity: 1;
}
}
.h-layout {
height: calc(100vh - 60px);
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

24
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type Valuable<T> = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] }
export function getValuable<T extends object, V = Valuable<T>>(obj: T): V {
return Object.fromEntries(
Object.entries(obj).filter(
([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')
)
) as V
}
export function prettyBytes(bytes: number): string {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const size = bytes / Math.pow(1024, i)
return `${size.toFixed(2)} ${units[i]}`
}

36
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { ThemeProvider } from "@/components/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Create a new router instance
const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const queryClient = new QueryClient();
// Render the app
const rootElement = document.getElementById("root");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<RouterProvider router={router} />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);
}

View File

@@ -0,0 +1,115 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as DbDbNameTablesIndexImport } from './routes/db/$dbName/tables/index'
import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index'
import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data'
// Create/Update Routes
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({
path: '/db/$dbName/tables/',
getParentRoute: () => rootRoute,
} as any)
const DbDbNameTablesTableNameIndexRoute =
DbDbNameTablesTableNameIndexImport.update({
path: '/db/$dbName/tables/$tableName/',
getParentRoute: () => rootRoute,
} as any)
const DbDbNameTablesTableNameDataRoute =
DbDbNameTablesTableNameDataImport.update({
path: '/db/$dbName/tables/$tableName/data',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/db/$dbName/tables/': {
id: '/db/$dbName/tables/'
path: '/db/$dbName/tables'
fullPath: '/db/$dbName/tables'
preLoaderRoute: typeof DbDbNameTablesIndexImport
parentRoute: typeof rootRoute
}
'/db/$dbName/tables/$tableName/data': {
id: '/db/$dbName/tables/$tableName/data'
path: '/db/$dbName/tables/$tableName/data'
fullPath: '/db/$dbName/tables/$tableName/data'
preLoaderRoute: typeof DbDbNameTablesTableNameDataImport
parentRoute: typeof rootRoute
}
'/db/$dbName/tables/$tableName/': {
id: '/db/$dbName/tables/$tableName/'
path: '/db/$dbName/tables/$tableName'
fullPath: '/db/$dbName/tables/$tableName'
preLoaderRoute: typeof DbDbNameTablesTableNameIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren({
IndexRoute,
DbDbNameTablesIndexRoute,
DbDbNameTablesTableNameDataRoute,
DbDbNameTablesTableNameIndexRoute,
})
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/db/$dbName/tables/",
"/db/$dbName/tables/$tableName/data",
"/db/$dbName/tables/$tableName/"
]
},
"/": {
"filePath": "index.tsx"
},
"/db/$dbName/tables/": {
"filePath": "db/$dbName/tables/index.tsx"
},
"/db/$dbName/tables/$tableName/data": {
"filePath": "db/$dbName/tables/$tableName/data.tsx"
},
"/db/$dbName/tables/$tableName/": {
"filePath": "db/$dbName/tables/$tableName/index.tsx"
}
}
}
ROUTE_MANIFEST_END */

View File

@@ -0,0 +1,128 @@
import {
ModeToggle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
buttonVariants,
} from "@/components/ui";
import { cn } from "@/lib/utils";
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
import {
Link,
Outlet,
createRootRoute,
useNavigate,
useParams,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { Database, Rows3, Table2 } from "lucide-react";
import { z } from "zod";
const searchSchema = z.object({
dbSchema: z.string().optional().catch(""),
});
export const Route = createRootRoute({
component: Root,
validateSearch: (search) => searchSchema.parse(search),
});
function Root() {
const { data } = useDatabasesListQuery();
const params = useParams({ strict: false });
const dbName = params.dbName ?? "";
const navigate = useNavigate({ from: Route.fullPath });
const handleSelectedSchema = (schema: string) => {
void navigate({ to: "/db/$dbName/tables", params: { dbName: schema } });
};
const { data: tables } = useTablesListQuery({ dbName });
return (
<>
<div className="h-screen grid grid-rows-layout grid-cols-layout">
<header className="p-2 flex gap-2 border-b items-center col-span-full">
<ModeToggle />
<Link to="/" className="[&.active]:font-bold">
Home
</Link>
</header>
<aside className={"p-3"}>
<Select value={dbName} onValueChange={handleSelectedSchema}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Database Schema" />
</SelectTrigger>
<SelectContent>
{data?.map((schema) => {
return (
<SelectItem value={schema} key={schema}>
{schema}
</SelectItem>
);
})}
</SelectContent>
</Select>
<nav className="flex flex-col gap-1 mt-4">
{dbName && (
<Link
to={"/db/$dbName/tables"}
params={{ dbName }}
activeOptions={{ exact: true }}
className={cn(
"flex items-center gap-2 rounded py-1.5 pl-1.5",
"hover:bg-muted",
"[&.active]:bg-muted [&.active]:font-semibold",
)}
>
<Database className={"size-4"} /> {dbName}
</Link>
)}
{tables?.map((table) => {
return (
<div
key={table.table_name}
className={cn(
"flex items-center gap-2 px-2.5 rounded py-1.5 justify-between w-full",
)}
>
<Link
className={cn(
"w-full flex gap-2 items-center",
"hover:underline",
"[&.active]:font-semibold",
)}
to={"/db/$dbName/tables/$tableName"}
params={{ tableName: table.table_name, dbName: dbName }}
>
<Table2 className={"size-4 shrink-0"} />
{table.table_name}
</Link>
<Link
className={cn(
"hover:underline shrink-0",
buttonVariants({ variant: "ghost", size: "iconSm" }),
"[&.active]:bg-muted",
)}
title={"Explore Data"}
aria-label={"Explore Data"}
to={"/db/$dbName/tables/$tableName/data"}
params={{ tableName: table.table_name, dbName: dbName }}
search={{ pageIndex: 0, pageSize: 10 }}
>
<Rows3 className={"size-4 shrink-0"} />
</Link>
</div>
);
})}
</nav>
</aside>
<Outlet />
</div>
<TanStackRouterDevtools />
</>
);
}

View File

@@ -0,0 +1,41 @@
import { DataTable } from "@/components/db-table-view/data-table";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { z } from "zod";
const tableSearchSchema = z.object({
pageSize: z.number().catch(10),
pageIndex: z.number().catch(0),
});
export const Route = createFileRoute("/db/$dbName/tables/$tableName/data")({
component: TableView,
validateSearch: (search) => tableSearchSchema.parse(search),
});
function TableView() {
const { tableName, dbName } = Route.useParams();
const { pageSize, pageIndex } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const updatePageSize = (value: number) => {
return void navigate({
search: (prev) => ({ ...prev, pageSize: value, pageIndex: 0 }),
});
};
const updatePageIndex = (pageIndex: number) => {
return void navigate({ search: (prev) => ({ ...prev, pageIndex }) });
};
return (
<div className="p-3 h-layout w-layout">
<DataTable
dbName={dbName}
tableName={tableName}
pageSize={pageSize}
pageIndex={pageIndex}
onPageIndexChange={updatePageIndex}
onPageSizeChange={updatePageSize}
/>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { DataTable } from "@/components/ui/data-table";
import { cn } from "@/lib/utils";
import {
type TableColumn,
type TableForeignKey,
type TableIndexEntry,
useTableColumnsQuery,
useTableForeignKeysQuery,
useTableIndexesQuery,
} from "@/services/db";
import { Link, createFileRoute } from "@tanstack/react-router";
import { createColumnHelper } from "@tanstack/react-table";
import { ArrowRight, Table2 } from "lucide-react";
export const Route = createFileRoute("/db/$dbName/tables/$tableName/")({
component: TableDetailsTable,
});
const columnHelper = createColumnHelper<TableColumn>();
const columns = [
columnHelper.accessor("column_name", {
header: "Column",
}),
columnHelper.accessor("data_type", {
header: "Type",
}),
columnHelper.accessor("udt_name", {
header: "UDT",
}),
columnHelper.accessor("column_comment", {
header: "Comment",
}),
];
const tableIndexesColumnHelper = createColumnHelper<TableIndexEntry>();
const tableIndexesColumns = [
tableIndexesColumnHelper.accessor("type", {
header: "Type",
}),
tableIndexesColumnHelper.accessor("columns", {
header: "Columns",
cell: (props) => props.getValue().join(", "),
}),
tableIndexesColumnHelper.accessor("key", {
header: "Key",
}),
];
const tableForeignKeysColumnHelper = createColumnHelper<TableForeignKey>();
const tableForeignKeysColumns = [
tableForeignKeysColumnHelper.accessor("source", {
header: "Source",
cell: (props) => props.getValue().join(", "),
}),
tableForeignKeysColumnHelper.accessor("target", {
header: "Target",
cell: (props) => {
const { table, target } = props.row.original;
return (
<Link
from={Route.fullPath}
to={"../$tableName"}
params={{ tableName: table }}
className={"hover:underline"}
>
{table} ({target.join(", ")})
</Link>
);
},
}),
tableForeignKeysColumnHelper.accessor("on_delete", {
header: "On Delete",
}),
tableForeignKeysColumnHelper.accessor("on_update", {
header: "On Update",
}),
];
function TableDetailsTable() {
const { tableName: name } = Route.useParams();
const { data: tableColumns } = useTableColumnsQuery({ name });
const { data: tableIndexes } = useTableIndexesQuery({ name });
const { data: tableForeignKeys } = useTableForeignKeysQuery({ name });
return (
<div className={"p-3 w-layout"}>
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
<div className={"flex gap-4 items-center justify-between"}>
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
<Table2 /> {name}
</h1>
<Link
from={Route.fullPath}
to={"./data"}
search={{
pageIndex: 0,
pageSize: 10,
}}
className={cn("flex gap-2 items-center", "hover:underline")}
>
Explore data <ArrowRight />
</Link>
</div>
<div className={"flex gap-4 items-center justify-between"}>
<h2 className={"text-xl font-bold flex items-center gap-2"}>
Columns
</h2>
</div>
<div
className={
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
}
>
<DataTable columns={columns} data={tableColumns ?? []} />
</div>
<div className={"flex gap-4 items-center justify-between"}>
<h2 className={"text-xl font-bold flex items-center gap-2"}>
Indexes
</h2>
</div>
<div
className={
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
}
>
{tableIndexes?.length ? (
<DataTable
columns={tableIndexesColumns}
data={tableIndexes ?? []}
/>
) : (
<div className="text-center p-4">No indexes.</div>
)}
</div>
<div className={"flex gap-4 items-center justify-between"}>
<h2 className={"text-xl font-bold flex items-center gap-2"}>
Foreign Keys
</h2>
</div>
<div
className={
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
}
>
{tableForeignKeys?.length ? (
<DataTable
columns={tableForeignKeysColumns}
data={tableForeignKeys ?? []}
/>
) : (
<div className="text-center p-4">No foreign keys.</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui";
import { cn, prettyBytes } from "@/lib/utils";
import { type TableInfo, useTablesListQuery } from "@/services/db";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
type SortingState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowUp, Database } from "lucide-react";
import { useMemo, useState } from "react";
export const Route = createFileRoute("/db/$dbName/tables/")({
component: Component,
});
const columnHelper = createColumnHelper<TableInfo>();
const createColumns = (dbName: string) => {
return [
columnHelper.accessor("table_name", {
header: "Name",
cell: (props) => {
const tableName = props.getValue();
return (
<div className={"flex w-full"}>
<Link
className={"hover:underline w-full"}
to={"/db/$dbName/tables/$tableName"}
params={{ dbName, tableName }}
>
{tableName}
</Link>
</div>
);
},
}),
columnHelper.accessor("table_size", {
header: "Table Size",
cell: (props) => prettyBytes(props.getValue()),
meta: {
className: "text-end",
},
}),
columnHelper.accessor("index_size", {
header: "Index Size",
cell: (props) => prettyBytes(props.getValue()),
meta: {
className: "text-end",
},
}),
columnHelper.accessor("total_size", {
header: "Total Size",
cell: (props) => prettyBytes(props.getValue()),
meta: {
className: "text-end",
},
}),
columnHelper.accessor("row_count", {
header: "Rows",
meta: {
className: "text-end",
},
}),
columnHelper.accessor("primary_key", {
header: "Primary key",
}),
columnHelper.accessor("indexes", {
header: "Indexes",
cell: (props) => {
const indexes = props.getValue();
if (!indexes) return null;
return (
<div>
{indexes?.split(",").map((index) => (
<div key={index}>{index}</div>
))}
</div>
);
},
}),
columnHelper.accessor("comments", {
header: "Comment",
}),
columnHelper.accessor("schema_name", {
header: "Schema",
}),
];
};
function Component() {
const { dbName } = Route.useParams();
const [sorting, setSorting] = useState<SortingState>([]);
const { data } = useTablesListQuery({
dbName,
sortDesc: sorting[0]?.desc,
sortField: sorting[0]?.id,
});
const details = useMemo(() => {
if (!data) {
return [];
}
return data;
}, [data]);
const columns = useMemo(() => createColumns(dbName), [dbName]);
const table = useReactTable({
data: details,
columns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
state: {
sorting,
},
onSortingChange: setSorting,
});
return (
<div className={"p-3 h-layout w-layout"}>
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
<div className={"flex gap-4 items-center justify-between"}>
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
<Database /> {dbName}
</h1>
<p>
Tables: <strong>{details?.length ?? 0}</strong>
</p>
</div>
<div
className={
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
}
>
<Table
className={"min-w-full"}
{...{
style: {
width: table.getCenterTotalSize(),
},
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted();
if (header.column.getCanSort()) {
return (
<TableHead
key={header.id}
className={cn(
"p-0",
header.column.columnDef.meta?.className,
)}
>
<Button
variant="ghost"
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getNextSortingOrder() === "asc"
? "Sort ascending"
: header.column.getNextSortingOrder() === "desc"
? "Sort descending"
: "Clear sort"
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<ArrowUp
className={cn(
"ml-2 size-4 opacity-0",
sorted && "opacity-100",
(sorted as string) === "desc" && "rotate-180",
)}
/>
</Button>
</TableHead>
);
}
return (
<TableHead
className={cn(
"text-nowrap",
header.column.columnDef.meta?.className,
)}
key={header.id}
style={{
width: header.getSize(),
}}
>
{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
className={cn(
"text-nowrap",
cell.column?.columnDef?.meta?.className,
)}
key={cell.id}
style={{
width: cell.column.getSize(),
}}
>
{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>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: Index,
})
function Index() {
const { dbSchema } = Route.useSearch()
if (!dbSchema) {
return (
<div className="p-2">
<h3>Welcome Home!</h3>
</div>
)
}
return <TableView dbSchema={dbSchema} />
}
function TableView({ dbSchema }: { dbSchema: string }) {
return (
<div className="p-2">
<h3>Table View</h3>
{dbSchema}
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { DB_QUERY_KEYS } from "./db.query-keys";
import { dbService } from "./db.service";
import type { GetTableDataArgs, GetTablesListArgs } from "./db.types";
export const useDatabasesListQuery = () => {
return useQuery({
queryKey: [DB_QUERY_KEYS.DATABASES.ALL],
queryFn: () => dbService.getDatabasesList(),
placeholderData: keepPreviousData,
});
};
export const useTablesListQuery = (args: GetTablesListArgs) => {
return useQuery({
queryKey: [DB_QUERY_KEYS.TABLES.ALL, args],
queryFn: () => dbService.getTablesList(args),
enabled: !!args.dbName,
placeholderData: keepPreviousData,
});
};
export const useTableDataQuery = (args: GetTableDataArgs) => {
return useQuery({
queryKey: [DB_QUERY_KEYS.TABLES.DATA, args],
queryFn: () => dbService.getTableData(args),
placeholderData: (previousData, previousQuery) => {
if (
typeof previousQuery?.queryKey[1] !== "string" &&
(previousQuery?.queryKey[1].dbName !== args.dbName ||
previousQuery?.queryKey[1].tableName !== args.tableName)
) {
return undefined;
}
return previousData;
},
enabled: !!args.tableName && !!args.dbName,
});
};
export const useTableColumnsQuery = (args: { name?: string }) => {
return useQuery({
queryKey: [DB_QUERY_KEYS.TABLES.COLUMNS, args],
queryFn: () => dbService.getTableColumns(args.name ?? ""),
placeholderData: keepPreviousData,
enabled: !!args.name,
});
};
export const useTableIndexesQuery = (args: { name?: string }) => {
return useQuery({
queryKey: [DB_QUERY_KEYS.TABLES.INDEXES, args],
queryFn: () => dbService.getTableIndexes(args.name ?? ""),
enabled: !!args.name,
});
};
export const useTableForeignKeysQuery = (args: { name?: string }) => {
return useQuery({
queryKey: [DB_QUERY_KEYS.TABLES.FOREIGN_KEYS, args],
queryFn: () => dbService.getTableForeignKeys(args.name ?? ""),
enabled: !!args.name,
});
};

View File

@@ -0,0 +1,5 @@
import ky from 'ky'
export const dbInstance = ky.create({
prefixUrl: 'http://localhost:3000'
})

View File

@@ -0,0 +1,12 @@
export const DB_QUERY_KEYS = {
DATABASES: {
ALL: "databases.all",
},
TABLES: {
ALL: "tables.all",
DATA: "tables.data",
COLUMNS: "tables.columns",
INDEXES: "tables.indexes",
FOREIGN_KEYS: "tables.foreign_keys",
},
} as const;

View File

@@ -0,0 +1,50 @@
import { getValuable } from "@/lib/utils";
import { dbInstance } from "@/services/db/db.instance";
import type {
DatabasesResponse,
GetTableDataArgs,
GetTableDataResponse,
GetTablesListArgs,
GetTablesListResponse,
TableColumns,
TableForeignKeys,
TableIndexes,
} from "@/services/db/db.types";
class DbService {
getDatabasesList() {
return dbInstance.get("api/databases").json<DatabasesResponse>();
}
getTablesList({ dbName, sortDesc, sortField }: GetTablesListArgs) {
return dbInstance
.get(`api/databases/${dbName}/tables`, {
searchParams: getValuable({ sortField, sortDesc }),
})
.json<GetTablesListResponse>();
}
getTableData({ dbName, tableName, page, perPage }: GetTableDataArgs) {
return dbInstance
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
searchParams: getValuable({ perPage, page }),
})
.json<GetTableDataResponse>();
}
getTableColumns(name: string) {
return dbInstance.get(`api/db/tables/${name}/columns`).json<TableColumns>();
}
getTableIndexes(name: string) {
return dbInstance.get(`api/db/tables/${name}/indexes`).json<TableIndexes>();
}
getTableForeignKeys(name: string) {
return dbInstance
.get(`api/db/tables/${name}/foreign-keys`)
.json<TableForeignKeys>();
}
}
export const dbService = new DbService();

View File

@@ -0,0 +1,64 @@
export type DatabasesResponse = Array<string>;
// Tables List
export type GetTablesListArgs = {
dbName: string;
sortField?: string;
sortDesc?: boolean;
};
export type TableInfo = {
comments: string;
index_size: number;
indexes: string;
owner: string;
primary_key: string;
row_count: number;
schema_name: string;
table_name: string;
table_size: number;
total_size: number;
};
export type GetTablesListResponse = TableInfo[];
// Table Data
export type GetTableDataArgs = {
tableName: string;
dbName: string;
perPage?: number;
page?: number;
};
export type GetTableDataResponse = {
count: number;
data: Array<Record<string, any>>;
};
export type TableColumn = {
column_name: string;
data_type: string;
udt_name: string;
column_comment?: any;
};
export type TableColumns = TableColumn[];
export type TableIndexEntry = {
key: string;
type: string;
columns: string[];
descs: any[];
lengths: any[];
};
export type TableIndexes = TableIndexEntry[];
export type TableForeignKey = {
conname: string;
deferrable: boolean;
definition: string;
source: string[];
ns: string;
table: string;
target: string[];
on_delete: string;
on_update: string;
};
export type TableForeignKeys = TableForeignKey[];

View File

@@ -0,0 +1,2 @@
export * from './db.hooks'
export * from './db.types'

View File

@@ -0,0 +1,8 @@
import { RowData } from '@tanstack/react-table'
declare module '@tanstack/table-core' {
// @ts-expect-error - this is a global module augmentation
interface ColumnMeta<TData extends RowData> {
className?: string
}
}

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />