diff --git a/frontend/bun.lockb b/frontend/bun.lockb index e9442af..eea2418 100644 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index bd58e03..0cb2a08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,11 @@ }, "dependencies": { "@it-incubator/prettier-config": "^0.1.2", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", "@tanstack/react-query": "^5.50.1", "@tanstack/react-router": "^1.43.12", "@tanstack/react-table": "^8.19.2", @@ -26,7 +28,8 @@ "react-dom": "^18.3.1", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^4.5.4" }, "devDependencies": { "@biomejs/biome": "1.8.3", diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx index 973e11a..2e901e7 100644 --- a/frontend/src/components/db-table-view/data-table.tsx +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui"; import { cn } from "@/lib/utils"; import { useTableColumnsQuery, useTableDataQuery } from "@/services/db"; +import { useSettingsStore } from "@/state"; import { type ColumnDef, type OnChangeFn, @@ -55,6 +56,9 @@ export const DataTable = ({ onPageIndexChange: (pageIndex: number) => void; onPageSizeChange: (pageSize: number) => void; }) => { + const formatDates = useSettingsStore.use.formatDates(); + const showImagesPreview = useSettingsStore.use.showImagesPreview(); + const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -108,10 +112,10 @@ export const DataTable = ({ cell: ({ row }) => { const value = row.getValue(column_name) as any; let finalValue = value; - if (udt_name === "timestamp") { + if (formatDates && udt_name === "timestamp") { finalValue = new Date(value as string).toLocaleString(); } - if (typeof value === "string" && isUrl(value)) { + if (showImagesPreview && typeof value === "string" && isUrl(value)) { const isImage = isImageUrl(value); return ( -
+
{value} {isImage && ( []; - }, [details]); + }, [details, formatDates, showImagesPreview]); const table = useReactTable({ data: data?.data ?? [], diff --git a/frontend/src/components/settings-dialog.tsx b/frontend/src/components/settings-dialog.tsx new file mode 100644 index 0000000..844cd0d --- /dev/null +++ b/frontend/src/components/settings-dialog.tsx @@ -0,0 +1,132 @@ +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Switch, + Table, + TableBody, + TableCell, + TableRow, +} from "@/components/ui"; +import { useSettingsStore } from "@/state"; +import { Settings, Trash } from "lucide-react"; +import type { FormEventHandler } from "react"; + +export function SettingsDialog() { + const addPaginationOption = useSettingsStore.use.addPaginationOption(); + const removePaginationOption = useSettingsStore.use.removePaginationOption(); + const paginationOptions = useSettingsStore.use.paginationOptions(); + + const formatDates = useSettingsStore.use.formatDates(); + const setFormatDates = useSettingsStore.use.setFormatDates(); + + const showImagesPreview = useSettingsStore.use.showImagesPreview(); + const setShowImagesPreview = useSettingsStore.use.setShowImagesPreview(); + + const handleSubmit: FormEventHandler = (e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const stringValue = formData.get("option"); + if (!stringValue || typeof stringValue !== "string") { + return; + } + + const value = Number.parseInt(stringValue, 10); + if (Number.isNaN(value)) { + return; + } + + addPaginationOption(value); + e.currentTarget.reset(); + }; + + return ( + + + + + + + Global Settings + + Update global settings for the app. Changes will be applied + immediately and persisted to local storage. + + +
+
+

Pagination options

+

Add or remove options for the amount of rows per page

+ + + {paginationOptions.map((option) => ( + + + {option} + + + + + + ))} + +
+
+ + +
+
+
+

Automatically format dates

+

+ When turned on, will show timestamp cells in tables in a human + readable format +

+ +
+
+

Show previews for images

+

+ When turned on, will automatically detect image URL's in tables + and add a preview alongside. +

+

+ Might significantly increase load on you CDN, use with caution. +

+ +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/ui/data-table-pagination.tsx b/frontend/src/components/ui/data-table-pagination.tsx index 7633ca2..2ada205 100644 --- a/frontend/src/components/ui/data-table-pagination.tsx +++ b/frontend/src/components/ui/data-table-pagination.tsx @@ -6,6 +6,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui"; +import { useSettingsStore } from "@/state"; import type { Table } from "@tanstack/react-table"; import { ChevronLeftIcon, @@ -21,6 +22,7 @@ interface DataTablePaginationProps { export function DataTablePagination({ table, }: DataTablePaginationProps) { + const paginationOptions = useSettingsStore.use.paginationOptions(); return (
@@ -40,7 +42,7 @@ export function DataTablePagination({ - {[10, 20, 30, 40, 50, 1000].map((pageSize) => ( + {paginationOptions.map((pageSize) => ( {pageSize} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c23630e --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index de391d7..cc8ee6b 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,7 +1,9 @@ export * from "./button"; export * from "./data-table-pagination"; +export * from "./dialog"; export * from "./dropdown-menu"; +export * from "./input"; export * from "./mode-toggle"; export * from "./select"; +export * from "./switch"; export * from "./table"; -export * from "./theme-provider"; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/mode-toggle.tsx b/frontend/src/components/ui/mode-toggle.tsx index 75fd84b..6df9e2c 100644 --- a/frontend/src/components/ui/mode-toggle.tsx +++ b/frontend/src/components/ui/mode-toggle.tsx @@ -6,11 +6,31 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - useTheme, } from "@/components/ui"; +import { useUiStore } from "@/state"; +import { useLayoutEffect } from "react"; export function ModeToggle() { - const { setTheme } = useTheme(); + const theme = useUiStore.use.theme(); + const setTheme = useUiStore.use.setTheme(); + + useLayoutEffect(() => { + 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]); return ( diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..aa58baa --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/src/components/ui/theme-provider.tsx b/frontend/src/components/ui/theme-provider.tsx deleted file mode 100644 index 4847d98..0000000 --- a/frontend/src/components/ui/theme-provider.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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(initialState) - -export function ThemeProvider({ - children, - defaultTheme = 'system', - storageKey = 'vite-ui-theme', - ...props -}: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (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 ( - - {children} - - ) -} - -export const useTheme = () => { - const context = useContext(ThemeProviderContext) - - if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider') - - return context -} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8862324..0943dc8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,6 +3,9 @@ @tailwind utilities; @layer base { + input[type=number] { + -moz-appearance:textfield; + } :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; @@ -69,21 +72,28 @@ @layer base { :root { text-underline-position: under; + --sidebar-width: 264px; } + + .sidebar-closed { + --sidebar-width: 0; + } + .grid-rows-layout { grid-template-rows: 60px 1fr; } .grid-cols-layout { - grid-template-columns: 264px 1fr; + grid-template-columns: var(--sidebar-width) 1fr; } + .max-w-layout { - max-width: calc(100vw - 264px); + max-width: calc(100vw - var(--sidebar-width)); } .w-layout { - width: calc(100vw - 264px); + width: calc(100vw - var(--sidebar-width)); } .resizer { position: absolute; diff --git a/frontend/src/lib/create-selectors.ts b/frontend/src/lib/create-selectors.ts new file mode 100644 index 0000000..106e8af --- /dev/null +++ b/frontend/src/lib/create-selectors.ts @@ -0,0 +1,17 @@ +import type { StoreApi, UseBoundStore } from "zustand"; + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +export const createSelectors = >>( + _store: S, +) => { + const store = _store as WithSelectors; + store.use = {}; + for (const k of Object.keys(store.getState())) { + (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]); + } + + return store; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1fbb748..34064fa 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,6 @@ 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 @@ -25,15 +24,13 @@ if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - - - - + + + + , ); } diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index fc4e4fd..0a8ac22 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,4 +1,6 @@ +import { SettingsDialog } from "@/components/settings-dialog"; import { + Button, ModeToggle, Select, SelectContent, @@ -9,6 +11,7 @@ import { } from "@/components/ui"; import { cn } from "@/lib/utils"; import { useDatabasesListQuery, useTablesListQuery } from "@/services/db"; +import { useUiStore } from "@/state"; import { Link, Outlet, @@ -17,15 +20,23 @@ import { useParams, } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; -import { Database, Rows3, Table2 } from "lucide-react"; +import { + Database, + PanelLeft, + PanelLeftClose, + Rows3, + Table2, +} from "lucide-react"; export const Route = createRootRoute({ component: Root, }); function Root() { - const { data } = useDatabasesListQuery(); + const showSidebar = useUiStore.use.showSidebar(); + const toggleSidebar = useUiStore.use.toggleSidebar(); + const { data } = useDatabasesListQuery(); const params = useParams({ strict: false }); const dbName = params.dbName ?? ""; const navigate = useNavigate({ from: Route.fullPath }); @@ -38,81 +49,103 @@ function Root() { return ( <> -
-
- - - Home - +
+
+
+ + + Home + +
+
+ + +
+
diff --git a/frontend/src/state/index.ts b/frontend/src/state/index.ts new file mode 100644 index 0000000..95cd937 --- /dev/null +++ b/frontend/src/state/index.ts @@ -0,0 +1,2 @@ +export * from "./settings-store"; +export * from "./ui-store"; diff --git a/frontend/src/state/settings-store.ts b/frontend/src/state/settings-store.ts new file mode 100644 index 0000000..17dd5ed --- /dev/null +++ b/frontend/src/state/settings-store.ts @@ -0,0 +1,47 @@ +import { createSelectors } from "@/lib/create-selectors"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +type SettingsState = { + paginationOptions: Array; + addPaginationOption: (option: number) => void; + removePaginationOption: (option: number) => void; + formatDates: boolean; + setFormatDates: (value: boolean) => void; + showImagesPreview: boolean; + setShowImagesPreview: (value: boolean) => void; +}; + +const useSettingsStoreBase = create()( + persist( + (set) => ({ + paginationOptions: [10, 20, 50, 100], + addPaginationOption: (option) => + set((state) => { + if (state.paginationOptions.includes(option)) { + return state; + } + return { + paginationOptions: [...state.paginationOptions, option].sort( + (a, b) => a - b, + ), + }; + }), + removePaginationOption: (option) => + set((state) => ({ + paginationOptions: state.paginationOptions.filter( + (o) => o !== option, + ), + })), + formatDates: true, + setFormatDates: (value) => set({ formatDates: value }), + showImagesPreview: true, + setShowImagesPreview: (value) => set({ showImagesPreview: value }), + }), + { + name: "settings-storage", + }, + ), +); + +export const useSettingsStore = createSelectors(useSettingsStoreBase); diff --git a/frontend/src/state/ui-store.ts b/frontend/src/state/ui-store.ts new file mode 100644 index 0000000..46e70b2 --- /dev/null +++ b/frontend/src/state/ui-store.ts @@ -0,0 +1,32 @@ +import { createSelectors } from "@/lib/create-selectors"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +type Theme = "dark" | "light" | "system"; + +type UiState = { + showSidebar: boolean; + toggleSidebar: () => void; + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const useUiStoreBase = create()( + persist( + (set) => { + return { + showSidebar: false, + toggleSidebar: () => { + set((state) => ({ showSidebar: !state.showSidebar })); + }, + theme: "system", + setTheme: (theme) => set({ theme }), + }; + }, + { + name: "ui-storage", + }, + ), +); + +export const useUiStore = createSelectors(useUiStoreBase);