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
}) =>
visible ? (
<div
{...props}
className={twMerge(
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
className,
)}
>
<div {...props} className={twMerge("flex flex-row items-center p-4", className)}>
<p>
{title && <b>{title}: </b>}
{message || children}
@@ -33,19 +27,19 @@ const Base: React.FC<AlertProps> = ({
) : null;
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 }) => (
<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 }) => (
<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 }) => (
<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 = {

View File

@@ -3,7 +3,6 @@ import { getBrowser } from "core/utils";
import type { Field } from "data";
import { Switch as RadixSwitch } from "radix-ui";
import {
type ChangeEventHandler,
type ComponentPropsWithoutRef,
type ElementType,
forwardRef,
@@ -12,7 +11,7 @@ import {
useRef,
useState,
} 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 { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
@@ -89,7 +88,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
{...props}
ref={ref}
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 &&
"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">>(
(props, ref) => {
return (

View File

@@ -1,5 +1,5 @@
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 * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
@@ -13,6 +13,7 @@ export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void;
placeholder?: string;
disabled?: boolean;
inputProps?: Partial<FieldComponentProps>;
} & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = (props: FieldProps) => {
@@ -31,7 +32,14 @@ const fieldErrorBoundary =
</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 required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema });
@@ -64,6 +72,7 @@ const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props
return (
<FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldComponent
{...inputProps}
schema={schema}
name={name}
required={required}
@@ -81,10 +90,12 @@ export const Pre = ({ children }) => (
</pre>
);
export const FieldComponent = ({
schema,
..._props
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
export type FieldComponentProps = {
schema: JsonSchema;
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
} & ComponentPropsWithoutRef<"input">;
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null;
const props = {
@@ -97,6 +108,8 @@ export const FieldComponent = ({
: "",
};
if (render) return render({ schema, ...props });
if (schema.enum) {
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 { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clampNumber } from "core/utils/numbers";
import { throttle } from "lodash-es";
import { ScrollArea } from "radix-ui";
import {
@@ -12,13 +13,20 @@ import {
import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
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 (
<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}
</div>
</AppShellProvider>
@@ -80,7 +88,7 @@ export function Main({ children }) {
data-shell="main"
className={twMerge(
"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}
@@ -89,47 +97,38 @@ export function Main({ 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) => {
e.preventDefault();
e.stopPropagation();
ctx?.sidebar?.handler.close();
});
const onEscape = useEvent(() => {
if (ctx?.sidebar?.open) {
ctx?.sidebar?.handler.close();
}
});
// listen for window location change
useEffect(closeHandler, [location]);
// @todo: potentially has to be added to the root, as modals could be opened
useHotkeys([["Escape", onEscape]]);
if (!ctx) {
console.warn("AppShell.Sidebar: missing AppShellContext");
return null;
}
useHotkeys([["Escape", closeHandler]]);
return (
<>
<aside
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}
</aside>
<SidebarResize />
<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"
onClick={onClickBackdrop}
>
<aside
/*ref={ref}*/
ref={ref}
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}
</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">) {
return (
<h2

View File

@@ -19,11 +19,11 @@ import { Logo } from "ui/components/display/Logo";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { Link } from "ui/components/wouter/Link";
import { useEvent } from "ui/hooks/use-event";
import { useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter";
import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store";
export function HeaderNavigation() {
const [location, navigate] = useLocation();
@@ -105,10 +105,9 @@ export function HeaderNavigation() {
}
function SidebarToggler() {
const { sidebar } = useAppShell();
return (
<IconButton size="lg" Icon={sidebar.open ? TbX : TbMenu2} onClick={sidebar.handler.toggle} />
);
const toggle = appShellStore((store) => store.toggleSidebar);
const open = appShellStore((store) => store.sidebarOpen);
return <IconButton size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
}
export function Header({ hasSidebar = true }) {
@@ -118,6 +117,7 @@ export function Header({ hasSidebar = true }) {
return (
<header
id="header"
data-shell="header"
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 {
--color-primary: rgb(9 9 11); /* zinc-950 */
--color-background: rgb(250 250 250); /* zinc-50 */
--color-muted: rgb(228 228 231); /* ? */
--color-darkest: rgb(0 0 0); /* black */
--color-lightest: rgb(255 255 255); /* white */
--color-primary: var(--color-zinc-950);
--color-background: var(--color-zinc-50);
--color-muted: var(--color-zinc-200);
--color-darkest: var(--color-black);
--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 {
--mantine-color-body: rgb(250 250 250);
--mantine-color-body: var(--color-zinc-50);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
--mantine-color-body: var(--color-zinc-950);
}
table {
@@ -26,11 +37,20 @@
.dark .bknd-admin /* currently used for elements, drop after making headless */,
#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-muted: rgb(47 47 52);
--color-darkest: rgb(255 255 255); /* white */
--color-lightest: rgb(24 24 27); /* black */
--color-darkest: var(--color-white);
--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 {
@@ -39,6 +59,15 @@
--color-muted: var(--color-muted);
--color-darkest: var(--color-darkest);
--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 {

View File

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

View File

@@ -129,6 +129,7 @@ function AuthSettingsInternal() {
name="jwt.secret"
description="The secret used to sign the JWT token. If not set, a random key will be generated after enabling authentication."
advanced="jwt"
inputProps={{ type: "password" }}
/>
<AuthField name="jwt.alg" 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 { checksum, secureRandomString } from "bknd/utils";
import { checksum } from "bknd/utils";
import { boolean, em, entity, text } from "bknd/data";
import { SQLocalConnection } from "@bknd/sqlocal";
import { Route, Router, Switch } from "wouter";
import IndexPage from "~/routes/_index";
const Admin = lazy(() => import("~/routes/admin"));
import { Center } from "~/components/Center";
import { ClientProvider } from "bknd/client";
const Admin = lazy(() => import("~/routes/admin"));
export default function () {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");

View File

@@ -1,12 +1,15 @@
import { Center } from "~/components/Center";
import type { App } from "bknd";
import { useEntityQuery } from "bknd/client";
import type { SQLocalConnection } from "@bknd/sqlocal/src";
import { useEffect, useState } from "react";
export default function IndexPage({ app }: { app: App }) {
const user = app.getApi().getUser();
//const user = app.getApi().getUser();
const limit = 5;
const { data: todos, ...$q } = useEntityQuery("todos", undefined, {
limit,
sort: "-id",
});
// @ts-ignore
const total = todos?.body.meta.total || 0;
@@ -29,30 +32,31 @@ export default function IndexPage({ app }: { app: App }) {
</div>
)}
<div className="flex flex-col gap-3">
{todos?.reverse().map((todo) => (
<div className="flex flex-row" key={String(todo.id)}>
<div className="flex flex-row flex-grow items-center gap-3 ml-1">
<input
type="checkbox"
className="flex-shrink-0 cursor-pointer"
defaultChecked={!!todo.done}
onChange={async () => {
await $q.update({ done: !todo.done }, todo.id);
{todos &&
[...todos].reverse().map((todo) => (
<div className="flex flex-row" key={String(todo.id)}>
<div className="flex flex-row flex-grow items-center gap-3 ml-1">
<input
type="checkbox"
className="flex-shrink-0 cursor-pointer"
defaultChecked={!!todo.done}
onChange={async () => {
await $q.update({ done: !todo.done }, todo.id);
}}
/>
<div className="text-foreground/90 leading-none">{todo.title}</div>
</div>
<button
type="button"
className="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs "
onClick={async () => {
await $q._delete(todo.id);
}}
/>
<div className="text-foreground/90 leading-none">{todo.title}</div>
>
</button>
</div>
<button
type="button"
className="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs "
onClick={async () => {
await $q._delete(todo.id);
}}
>
</button>
</div>
))}
))}
</div>
<form
className="flex flex-row w-full gap-3 mt-2"
@@ -87,6 +91,49 @@ export default function IndexPage({ app }: { app: App }) {
)}
</div>*/}
</div>
<Debug app={app} />
</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>
);
}