admin ui: started color centralization + made sidebar resizable

This commit is contained in:
dswbx
2025-03-18 10:56:39 +01:00
parent ea2aa7c76c
commit f6996b1953
13 changed files with 286 additions and 92 deletions

View File

@@ -18,13 +18,7 @@ const Base: React.FC<AlertProps> = ({
...props ...props
}) => }) =>
visible ? ( visible ? (
<div <div {...props} className={twMerge("flex flex-row items-center p-4", className)}>
{...props}
className={twMerge(
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
className,
)}
>
<p> <p>
{title && <b>{title}: </b>} {title && <b>{title}: </b>}
{message || children} {message || children}
@@ -33,19 +27,19 @@ const Base: React.FC<AlertProps> = ({
) : null; ) : null;
const Warning: React.FC<AlertProps> = ({ className, ...props }) => ( const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} /> <Base {...props} className={twMerge("bg-warning text-warning-foreground", className)} />
); );
const Exception: React.FC<AlertProps> = ({ className, ...props }) => ( const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-red-950 bg-red-100", className)} /> <Base {...props} className={twMerge("bg-error text-error-foreground", className)} />
); );
const Success: React.FC<AlertProps> = ({ className, ...props }) => ( const Success: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-green-950 bg-green-100", className)} /> <Base {...props} className={twMerge("bg-success text-success-foreground", className)} />
); );
const Info: React.FC<AlertProps> = ({ className, ...props }) => ( const Info: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-blue-950 bg-blue-100", className)} /> <Base {...props} className={twMerge("bg-info text-info-foreground", className)} />
); );
export const Alert = { export const Alert = {

View File

@@ -3,7 +3,6 @@ import { getBrowser } from "core/utils";
import type { Field } from "data"; import type { Field } from "data";
import { Switch as RadixSwitch } from "radix-ui"; import { Switch as RadixSwitch } from "radix-ui";
import { import {
type ChangeEventHandler,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ElementType, type ElementType,
forwardRef, forwardRef,
@@ -12,7 +11,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { TbCalendar, TbChevronDown, TbEye, TbEyeOff, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
@@ -89,7 +88,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
{...props} {...props}
ref={ref} ref={ref}
className={twMerge( className={twMerge(
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none", "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full",
disabledOrReadonly && "bg-muted/50 text-primary/50", disabledOrReadonly && "bg-muted/50 text-primary/50",
!disabledOrReadonly && !disabledOrReadonly &&
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
@@ -99,6 +98,40 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
); );
}); });
export const TypeAwareInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
if (props.type === "password") {
return <Password {...props} ref={ref} />;
}
return <Input {...props} ref={ref} />;
},
);
export const Password = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
const [visible, setVisible] = useState(false);
function handleToggle() {
setVisible((v) => !v);
}
return (
<div className="relative w-full">
<Input {...props} type={visible ? "text" : "password"} className="w-full" ref={ref} />
<div className="absolute right-3 top-0 bottom-0 flex items-center">
<IconButton
Icon={visible ? TbEyeOff : TbEye}
onClick={handleToggle}
variant="ghost"
className="opacity-70"
/>
</div>
</div>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>( export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
(props, ref) => { (props, ref) => {
return ( return (

View File

@@ -1,5 +1,5 @@
import type { JsonSchema } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react"; import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
@@ -13,6 +13,7 @@ export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
inputProps?: Partial<FieldComponentProps>;
} & Omit<FieldwrapperProps, "children" | "schema">; } & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = (props: FieldProps) => { export const Field = (props: FieldProps) => {
@@ -31,7 +32,14 @@ const fieldErrorBoundary =
</Pre> </Pre>
); );
const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props }: FieldProps) => { const FieldImpl = ({
name,
onChange,
placeholder,
required: _required,
inputProps,
...props
}: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const required = typeof _required === "boolean" ? _required : ctx.required; const required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema }); //console.log("Field", { name, path, schema });
@@ -64,6 +72,7 @@ const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props
return ( return (
<FieldWrapper name={name} required={required} schema={schema} {...props}> <FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldComponent <FieldComponent
{...inputProps}
schema={schema} schema={schema}
name={name} name={name}
required={required} required={required}
@@ -81,10 +90,12 @@ export const Pre = ({ children }) => (
</pre> </pre>
); );
export const FieldComponent = ({ export type FieldComponentProps = {
schema, schema: JsonSchema;
..._props render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => { } & ComponentPropsWithoutRef<"input">;
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
const { value } = useFormValue(_props.name!, { strict: true }); const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null; if (!isTypeSchema(schema)) return null;
const props = { const props = {
@@ -97,6 +108,8 @@ export const FieldComponent = ({
: "", : "",
}; };
if (render) return render({ schema, ...props });
if (schema.enum) { if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />; return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
} }
@@ -158,5 +171,7 @@ export const FieldComponent = ({
} }
} }
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />; return (
<Formy.TypeAwareInput id={props.name} {...props} value={props.value ?? ""} {...additional} />
);
}; };

View File

@@ -1,5 +1,6 @@
import { useClickOutside, useHotkeys } from "@mantine/hooks"; import { useClickOutside, useHotkeys } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clampNumber } from "core/utils/numbers";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import { ScrollArea } from "radix-ui"; import { ScrollArea } from "radix-ui";
import { import {
@@ -12,13 +13,20 @@ import {
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { appShellStore } from "ui/store";
import { useLocation } from "wouter";
export function Root({ children }) { export function Root({ children }: { children: React.ReactNode }) {
const sidebarWidth = appShellStore((store) => store.sidebarWidth);
return ( return (
<AppShellProvider> <AppShellProvider>
<div data-shell="root" className="flex flex-1 flex-col select-none h-dvh"> <div
id="app-shell"
data-shell="root"
className="flex flex-1 flex-col select-none h-dvh"
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
>
{children} {children}
</div> </div>
</AppShellProvider> </AppShellProvider>
@@ -80,7 +88,7 @@ export function Main({ children }) {
data-shell="main" data-shell="main"
className={twMerge( className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1", "flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "md:max-w-[calc(100%-350px)]", sidebar.open && "md:max-w-[calc(100%-var(--sidebar-width))]",
)} )}
> >
{children} {children}
@@ -89,47 +97,38 @@ export function Main({ children }) {
} }
export function Sidebar({ children }) { export function Sidebar({ children }) {
const ctx = useAppShell(); const open = appShellStore((store) => store.sidebarOpen);
const close = appShellStore((store) => store.closeSidebar);
const ref = useClickOutside(close, null, [document.getElementById("header")]);
const [location] = useLocation();
const ref = useClickOutside(ctx.sidebar?.handler?.close); const closeHandler = () => {
open && close();
};
const onClickBackdrop = useEvent((e: React.MouseEvent) => { // listen for window location change
e.preventDefault(); useEffect(closeHandler, [location]);
e.stopPropagation();
ctx?.sidebar?.handler.close();
});
const onEscape = useEvent(() => {
if (ctx?.sidebar?.open) {
ctx?.sidebar?.handler.close();
}
});
// @todo: potentially has to be added to the root, as modals could be opened // @todo: potentially has to be added to the root, as modals could be opened
useHotkeys([["Escape", onEscape]]); useHotkeys([["Escape", closeHandler]]);
if (!ctx) {
console.warn("AppShell.Sidebar: missing AppShellContext");
return null;
}
return ( return (
<> <>
<aside <aside
data-shell="sidebar" data-shell="sidebar"
className="hidden md:flex flex-col basis-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-muted/10" className="hidden md:flex flex-col basis-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full bg-muted/10"
> >
{children} {children}
</aside> </aside>
<SidebarResize />
<div <div
data-open={ctx?.sidebar?.open} data-open={open}
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm" className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
onClick={onClickBackdrop}
> >
<aside <aside
/*ref={ref}*/ ref={ref}
data-shell="sidebar" data-shell="sidebar"
className="flex-col w-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background" className="flex-col w-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background"
> >
{children} {children}
</aside> </aside>
@@ -138,6 +137,59 @@ export function Sidebar({ children }) {
); );
} }
const SidebarResize = () => {
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth);
const [isResizing, setIsResizing] = useState(false);
const [startX, setStartX] = useState(0);
const [startWidth, setStartWidth] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
setStartX(e.clientX);
setStartWidth(
Number.parseInt(
getComputedStyle(document.getElementById("app-shell")!)
.getPropertyValue("--sidebar-width")
.replace("px", ""),
),
);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const diff = e.clientX - startX;
const newWidth = clampNumber(startWidth + diff, 250, window.innerWidth * 0.5);
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, startX, startWidth]);
return (
<div
data-active={isResizing ? 1 : undefined}
className="w-px h-full hidden md:flex bg-muted after:transition-colors relative after:absolute after:inset-0 after:-left-px after:w-[2px] select-none data-[active]:after:bg-sky-400 data-[active]:cursor-col-resize hover:after:bg-sky-400 hover:cursor-col-resize after:z-2"
onMouseDown={handleMouseDown}
style={{ touchAction: "none" }}
/>
);
};
export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) { export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) {
return ( return (
<h2 <h2

View File

@@ -19,11 +19,11 @@ import { Logo } from "ui/components/display/Logo";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils"; import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -105,10 +105,9 @@ export function HeaderNavigation() {
} }
function SidebarToggler() { function SidebarToggler() {
const { sidebar } = useAppShell(); const toggle = appShellStore((store) => store.toggleSidebar);
return ( const open = appShellStore((store) => store.sidebarOpen);
<IconButton size="lg" Icon={sidebar.open ? TbX : TbMenu2} onClick={sidebar.handler.toggle} /> return <IconButton size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
);
} }
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
@@ -118,6 +117,7 @@ export function Header({ hasSidebar = true }) {
return ( return (
<header <header
id="header"
data-shell="header" data-shell="header"
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >

View File

@@ -4,17 +4,28 @@
#bknd-admin, #bknd-admin,
.bknd-admin { .bknd-admin {
--color-primary: rgb(9 9 11); /* zinc-950 */ --color-primary: var(--color-zinc-950);
--color-background: rgb(250 250 250); /* zinc-50 */ --color-background: var(--color-zinc-50);
--color-muted: rgb(228 228 231); /* ? */ --color-muted: var(--color-zinc-200);
--color-darkest: rgb(0 0 0); /* black */ --color-darkest: var(--color-black);
--color-lightest: rgb(255 255 255); /* white */ --color-lightest: var(--color-white);
--color-warning: var(--color-amber-100);
--color-warning-foreground: var(--color-amber-800);
--color-error: var(--color-red-100);
--color-error-foreground: var(--color-red-800);
--color-success: var(--color-green-100);
--color-success-foreground: var(--color-green-800);
--color-info: var(--color-blue-100);
--color-info-foreground: var(--color-blue-800);
--color-resize: var(--color-blue-300);
@mixin light { @mixin light {
--mantine-color-body: rgb(250 250 250); --mantine-color-body: var(--color-zinc-50);
} }
@mixin dark { @mixin dark {
--mantine-color-body: rgb(9 9 11); --mantine-color-body: var(--color-zinc-950);
} }
table { table {
@@ -26,11 +37,20 @@
.dark .bknd-admin /* currently used for elements, drop after making headless */, .dark .bknd-admin /* currently used for elements, drop after making headless */,
#bknd-admin.dark, #bknd-admin.dark,
.bknd-admin.dark { .bknd-admin.dark {
--color-primary: rgb(250 250 250); /* zinc-50 */ --color-primary: var(--color-zinc-50);
--color-background: rgb(30 31 34); --color-background: rgb(30 31 34);
--color-muted: rgb(47 47 52); --color-muted: rgb(47 47 52);
--color-darkest: rgb(255 255 255); /* white */ --color-darkest: var(--color-white);
--color-lightest: rgb(24 24 27); /* black */ --color-lightest: rgb(24 24 27);
--color-warning: var(--color-yellow-900);
--color-warning-foreground: var(--color-yellow-200);
--color-error: var(--color-red-950);
--color-error-foreground: var(--color-red-200);
--color-success: var(--color-green-950);
--color-success-foreground: var(--color-green-200);
--color-info: var(--color-blue-950);
--color-info-foreground: var(--color-blue-200);
} }
@theme { @theme {
@@ -39,6 +59,15 @@
--color-muted: var(--color-muted); --color-muted: var(--color-muted);
--color-darkest: var(--color-darkest); --color-darkest: var(--color-darkest);
--color-lightest: var(--color-lightest); --color-lightest: var(--color-lightest);
--color-warning: var(--color-warning);
--color-warning-foreground: var(--color-warning-foreground);
--color-error: var(--color-error);
--color-error-foreground: var(--color-error-foreground);
--color-success: var(--color-success);
--color-success-foreground: var(--color-success-foreground);
--color-info: var(--color-info);
--color-info-foreground: var(--color-info-foreground);
} }
#bknd-admin { #bknd-admin {

View File

@@ -85,8 +85,6 @@ export function DataSchemaCanvas() {
}, },
})); }));
console.log("-", data, { nodes, edges });
const nodeLayout = layoutWithDagre({ const nodeLayout = layoutWithDagre({
nodes: nodes.map((n) => ({ nodes: nodes.map((n) => ({
id: n.id, id: n.id,

View File

@@ -129,6 +129,7 @@ function AuthSettingsInternal() {
name="jwt.secret" name="jwt.secret"
description="The secret used to sign the JWT token. If not set, a random key will be generated after enabling authentication." description="The secret used to sign the JWT token. If not set, a random key will be generated after enabling authentication."
advanced="jwt" advanced="jwt"
inputProps={{ type: "password" }}
/> />
<AuthField name="jwt.alg" advanced="jwt" /> <AuthField name="jwt.alg" advanced="jwt" />
<AuthField name="jwt.expires" advanced="jwt" /> <AuthField name="jwt.expires" advanced="jwt" />

View File

@@ -0,0 +1,23 @@
import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
export const appShellStore = create(
persist(
combine(
{
sidebarOpen: false as boolean,
sidebarWidth: 350 as number,
},
(set) => ({
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
closeSidebar: () => set({ sidebarOpen: false }),
openSidebar: () => set({ sidebarOpen: true }),
setSidebarWidth: (width: number) => set({ sidebarWidth: width }),
resetSidebarWidth: () => set({ sidebarWidth: 350 }),
}),
),
{
name: "appshell",
},
),
);

View File

@@ -0,0 +1 @@
export { appShellStore } from "./appshell";

View File

View File

@@ -1,14 +1,15 @@
import { createContext, lazy, useEffect, useState, Suspense, Fragment } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { App } from "bknd"; import { App } from "bknd";
import { checksum, secureRandomString } from "bknd/utils"; import { checksum } from "bknd/utils";
import { boolean, em, entity, text } from "bknd/data"; import { boolean, em, entity, text } from "bknd/data";
import { SQLocalConnection } from "@bknd/sqlocal"; import { SQLocalConnection } from "@bknd/sqlocal";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import IndexPage from "~/routes/_index"; import IndexPage from "~/routes/_index";
const Admin = lazy(() => import("~/routes/admin"));
import { Center } from "~/components/Center"; import { Center } from "~/components/Center";
import { ClientProvider } from "bknd/client"; import { ClientProvider } from "bknd/client";
const Admin = lazy(() => import("~/routes/admin"));
export default function () { export default function () {
const [app, setApp] = useState<App | undefined>(undefined); const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>(""); const [hash, setHash] = useState<string>("");

View File

@@ -1,12 +1,15 @@
import { Center } from "~/components/Center"; import { Center } from "~/components/Center";
import type { App } from "bknd"; import type { App } from "bknd";
import { useEntityQuery } from "bknd/client"; import { useEntityQuery } from "bknd/client";
import type { SQLocalConnection } from "@bknd/sqlocal/src";
import { useEffect, useState } from "react";
export default function IndexPage({ app }: { app: App }) { export default function IndexPage({ app }: { app: App }) {
const user = app.getApi().getUser(); //const user = app.getApi().getUser();
const limit = 5; const limit = 5;
const { data: todos, ...$q } = useEntityQuery("todos", undefined, { const { data: todos, ...$q } = useEntityQuery("todos", undefined, {
limit, limit,
sort: "-id",
}); });
// @ts-ignore // @ts-ignore
const total = todos?.body.meta.total || 0; const total = todos?.body.meta.total || 0;
@@ -29,7 +32,8 @@ export default function IndexPage({ app }: { app: App }) {
</div> </div>
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{todos?.reverse().map((todo) => ( {todos &&
[...todos].reverse().map((todo) => (
<div className="flex flex-row" key={String(todo.id)}> <div className="flex flex-row" key={String(todo.id)}>
<div className="flex flex-row flex-grow items-center gap-3 ml-1"> <div className="flex flex-row flex-grow items-center gap-3 ml-1">
<input <input
@@ -87,6 +91,49 @@ export default function IndexPage({ app }: { app: App }) {
)} )}
</div>*/} </div>*/}
</div> </div>
<Debug app={app} />
</Center> </Center>
); );
} }
function Debug({ app }: { app: App }) {
const [info, setInfo] = useState<any>();
const connection = app.em.connection as SQLocalConnection;
useEffect(() => {
(async () => {
setInfo(await connection.client.getDatabaseInfo());
app.emgr.onAny(
async () => {
setInfo(await connection.client.getDatabaseInfo());
},
{ mode: "sync", id: "debug" },
);
})();
}, []);
async function download() {
const databaseFile = await connection.client.getDatabaseFile();
const fileUrl = URL.createObjectURL(databaseFile);
const a = document.createElement("a");
a.href = fileUrl;
a.download = "database.sqlite3";
a.click();
a.remove();
URL.revokeObjectURL(fileUrl);
}
return (
<div className="flex flex-col gap-2 items-center">
<button
className="bg-foreground/20 leading-none py-2 px-3.5 rounded-lg text-sm hover:bg-foreground/30 transition-colors cursor-pointer"
onClick={download}
>
Download Database
</button>
<pre className="text-xs">{JSON.stringify(info, null, 2)}</pre>
</div>
);
}