Make top navbar consistently scroll with the content on mobile and address a plathora of small mobile issues, e.g. the editor resizing.

This commit is contained in:
Sebastian Jeltsch
2025-11-06 16:15:23 +01:00
parent d213ca7197
commit 13e12c48fa
14 changed files with 176 additions and 159 deletions
+8 -11
View File
@@ -13,18 +13,18 @@ import { VerticalNavBar, HorizontalNavBar } from "@/components/NavBar";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { $user } from "@/lib/client";
import { createWindowWidth } from "@/lib/signals";
import { createIsMobile } from "@/lib/signals";
const queryClient = new QueryClient();
function LeftNav(props: RouteSectionProps) {
return (
<>
<div class="hide-scrollbars sticky h-dvh w-[58px] overflow-y-scroll">
<div class="hide-scrollbars sticky h-dvh w-[58px] overflow-hidden">
<VerticalNavBar location={props.location} />
</div>
<main class="absolute inset-0 left-[58px] h-dvh w-[calc(100vw-58px)] overflow-hidden">
<main class="absolute inset-0 left-[58px] h-dvh w-[calc(100vw-58px)] overflow-x-hidden overflow-y-auto">
<ErrorBoundary>{props.children}</ErrorBoundary>
</main>
</>
@@ -34,11 +34,9 @@ function LeftNav(props: RouteSectionProps) {
function TopNav(props: RouteSectionProps) {
return (
<>
<div class="hide-scrollbars sticky h-[48px] w-screen overflow-y-scroll">
<HorizontalNavBar location={props.location} />
</div>
<HorizontalNavBar height={48} location={props.location} />
<main class="absolute inset-0 top-[48px] h-[calc(100vh-48px)] w-screen overflow-hidden">
<main class="max-h-[calc(100vh-48px)] w-screen">
<ErrorBoundary>{props.children}</ErrorBoundary>
</main>
</>
@@ -46,16 +44,15 @@ function TopNav(props: RouteSectionProps) {
}
function WrapWithNav(props: RouteSectionProps) {
const width = createWindowWidth();
const showTopNav = () => width() < 680;
const isMobile = createIsMobile();
return (
<Switch>
<Match when={showTopNav()}>
<Match when={isMobile()}>
<TopNav {...props} />
</Match>
<Match when={!showTopNav()}>
<Match when={!isMobile()}>
<LeftNav {...props} />
</Match>
</Switch>
@@ -109,7 +109,7 @@ type Data = {
function FactCard(props: { title: string; content: string; href?: string }) {
const FCard = () => (
<Card class="grow">
<Card class="h-full">
<CardContent>
<CardTitle>{props.title}</CardTitle>
@@ -168,12 +168,12 @@ export function IndexPage() {
}));
return (
<div class="h-dvh overflow-y-auto">
<div class="h-full">
<Header title="TrailBase" />
<div class="prose m-4 flex grow flex-col gap-4">
{dashboardFetch.data && (
<div class="flex grow gap-4">
<div class="flex shrink gap-4">
<FactCard
title="Users"
content={`${dashboardFetch.data!.numUsers}`}
@@ -31,7 +31,7 @@ const options = [
[`${BASE}/settings/`, TbSettings, "Settings"],
] as const;
const iconSize = 22;
const iconSize = (horizontal: boolean) => (horizontal ? 18 : 22);
export const navBarIconStyle =
"rounded-full transition-all p-2 hover:bg-accent-200 hover:bg-opacity-50 active:scale-90";
export const navBarIconActiveStyle =
@@ -41,7 +41,7 @@ function NavBarItems(props: { location: Location; horizontal: boolean }) {
return (
<>
<a href={`${BASE}/`}>
<img src={logo} width={props.horizontal ? "38" : "42"} alt="Logo" />
<img src={logo} width={props.horizontal ? "34" : "42"} alt="Logo" />
</a>
<For each={options}>
@@ -55,7 +55,7 @@ function NavBarItems(props: { location: Location; horizontal: boolean }) {
<TooltipTrigger as="div">
<a href={pathname as string}>
<div class={style()}>
<Icon size={iconSize} />
<Icon size={iconSize(props.horizontal)} />
</div>
</a>
</TooltipTrigger>
@@ -74,7 +74,7 @@ function NavFooter(props: { horizontal: boolean }) {
return (
<div class="flex flex-col items-center">
<AuthButton iconSize={iconSize} />
<AuthButton iconSize={iconSize(props.horizontal)} />
<Show when={!props.horizontal}>
<div class="text-[9px]">
@@ -85,15 +85,19 @@ function NavFooter(props: { horizontal: boolean }) {
);
}
export function HorizontalNavBar(props: { location: Location }) {
export function HorizontalNavBar(props: {
height: number;
location: Location;
}) {
return (
<div class="flex h-full items-center justify-between gap-4 bg-gray-100 px-2">
<nav class="flex h-[36px] items-center gap-4">
<NavBarItems location={props.location} horizontal={true} />
</nav>
<nav
style={{ height: `${props.height}px` }}
class="flex w-screen items-center justify-between gap-4 bg-gray-100 p-2"
>
<NavBarItems location={props.location} horizontal={true} />
<NavFooter horizontal={true} />
</div>
</nav>
);
}
@@ -1,8 +1,8 @@
import type { JSXElement } from "solid-js";
import { Match, Switch, JSX } from "solid-js";
import { persistentAtom } from "@nanostores/persistent";
import { useStore } from "@nanostores/solid";
import { createWindowWidth } from "@/lib/signals";
import { createIsMobile } from "@/lib/signals";
import {
Resizable,
ResizablePanel,
@@ -28,13 +28,13 @@ function setSizes(next: number[]) {
}
export function SplitView(props: {
first: (props: { horizontal: boolean }) => JSXElement;
second: (props: { horizontal: boolean }) => JSXElement;
first: (props: { horizontal: boolean }) => JSX.Element;
second: (props: { horizontal: boolean }) => JSX.Element;
}) {
function VerticalSplit() {
return (
<div class="hide-scrollbars flex h-full flex-col overflow-x-hidden overflow-y-scroll">
<div>
<div class="w-screen">
<div class="overflow-x-auto">
<props.first horizontal={false} />
</div>
@@ -66,10 +66,17 @@ export function SplitView(props: {
);
}
const windowWidth = createWindowWidth();
const thresh = 5 * minSizePx;
const isMobile = createIsMobile();
return (
<>{windowWidth() < thresh ? <VerticalSplit /> : <HorizontalSplit />}</>
<Switch>
<Match when={isMobile()}>
<VerticalSplit />
</Match>
<Match when={!isMobile()}>
<HorizontalSplit />
</Match>
</Switch>
);
}
@@ -39,7 +39,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { createWindowWidth } from "@/lib/signals";
import { createIsMobile } from "@/lib/signals";
export function safeParseInt(v: string | undefined): number | undefined {
if (v !== undefined) {
@@ -224,7 +224,7 @@ export function DataTable<TData, TValue>(props: Props<TData, TValue>) {
) : (
<TableRow>
<TableCell colSpan={local.columns.length}>
<span>No results.</span>
<span>Empty</span>
</TableCell>
</TableRow>
)
@@ -311,7 +311,7 @@ function PaginationControl<TData>(props: {
);
const PaginationInfoText = () => {
const width = createWindowWidth();
const isMobile = createIsMobile();
const pageIndex = () => table().getState().pagination.pageIndex;
const pageCount = () => table().getPageCount();
@@ -319,7 +319,7 @@ function PaginationControl<TData>(props: {
return (
<>
{rowCount() && width() > 578
{rowCount() && !isMobile()
? `page ${pageIndex() + 1} of ${pageCount()} (${rowCount()} rows total)`
: `page ${pageIndex() + 1} of ${pageCount()}`}
</>
@@ -326,7 +326,7 @@ export function AccountsPage() {
const columns = () => buildColumns(setEditUser, refetch);
return (
<div class="h-dvh overflow-y-auto">
<div class="h-full">
<Header
title="Accounts"
left={
@@ -2,7 +2,6 @@ import {
ErrorBoundary,
For,
Match,
Show,
Switch,
createEffect,
createSignal,
@@ -32,11 +31,7 @@ import { sql, SQLConfig, SQLNamespace, SQLite } from "@codemirror/lang-sql";
import { iconButtonStyle, IconButton } from "@/components/IconButton";
import { Header } from "@/components/Header";
import { SplitView } from "@/components/SplitView";
import {
Resizable,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Separator } from "@/components/ui/separator";
import { Callout } from "@/components/ui/callout";
import { Button } from "@/components/ui/button";
import {
@@ -152,53 +147,51 @@ function ResultView(props: {
}
return (
<Show when={response()} fallback={<>No Data</>}>
<Switch>
<Match when={response()?.error}>
Error: {response()?.error?.message}
</Match>
<Switch>
<Match when={response()?.error}>
<div class="p-4">Error: {response()?.error?.message}</div>
</Match>
<Match when={(response()?.data?.columns?.length ?? 0) > 0}>
<ErrorBoundary
fallback={(err, _reset) => {
return (
<div class="m-4 flex flex-col gap-4">
<p>Failed to render query result: {`${err}`}</p>
<Match when={response()?.data === undefined}>
<div class="p-4">No Data</div>
</Match>
{isCached() && (
<p>
The view is trying to show cached data. Maybe the schema
has changed. Try to re-execute the query.
</p>
)}
</div>
);
}}
>
<div class="flex flex-col gap-2">
<div class="flex justify-end text-sm">
Last executed:{" "}
{new Date(response()?.timestamp ?? 0).toLocaleTimeString()}
<Match when={response()?.data !== undefined}>
<ErrorBoundary
fallback={(err, _reset) => {
return (
<div class="m-4 flex flex-col gap-4">
<p>Failed to render query result: {`${err}`}</p>
{isCached() && (
<p>
The view is trying to show cached data. Maybe the schema has
changed. Try to re-execute the query.
</p>
)}
</div>
{/* TODO: Enable pagination */}
<DataTable
columns={() => columnDefs(response()!.data!)}
data={() => response()!.data!.rows as ArrayRecord[]}
pagination={{
pageIndex: 0,
pageSize: 50,
}}
/>
);
}}
>
<div class="flex flex-col gap-2 p-4">
<div class="flex justify-end text-sm">
Last executed:{" "}
{new Date(response()?.timestamp ?? 0).toLocaleTimeString()}
</div>
</ErrorBoundary>
</Match>
<Match when={(response()?.data?.columns?.length ?? 0) == 0}>
No data returned by query
</Match>
</Switch>
</Show>
{/* TODO: Enable pagination */}
<DataTable
columns={() => columnDefs(response()!.data!)}
data={() => response()!.data!.rows as ArrayRecord[]}
pagination={{
pageIndex: 0,
pageSize: 50,
}}
/>
</div>
</ErrorBoundary>
</Match>
</Switch>
);
}
@@ -213,6 +206,7 @@ function SideBar(props: {
const addNewScript = () => props.setSelected(createNewScript());
const flexStyle = () => (props.horizontal ? "flex flex-col h-dvh" : "flex");
return (
<div class={`${flexStyle()} m-4 gap-2`}>
<Button class="flex gap-2" variant="secondary" onClick={addNewScript}>
@@ -503,61 +497,55 @@ function EditorPanel(props: {
message="Proceeding will discard any pending changes in the current buffer. Proceed with caution."
/>
<Resizable orientation="vertical" class="overflow-hidden">
<ResizablePanel class="flex flex-col">
<Header
title="Editor"
titleSelect={dirty() ? `${props.script.name}*` : props.script.name}
left={<LeftButtons />}
right={<HelpDialog />}
/>
<Header
title="Editor"
titleSelect={dirty() ? `${props.script.name}*` : props.script.name}
left={<LeftButtons />}
right={<HelpDialog />}
/>
<div class="mx-4 my-2 flex grow flex-col gap-2">
{showCallout() && (
<Callout
class="text-sm hover:opacity-[80%]"
onClick={() => setShowCallout(false)}
>
When changing schemas, consider using migrations to consistently
apply changes across environments. One-off alterations can
otherwise lead to skew. Alterations using the table browser will
produce migrations.
</Callout>
)}
<div class="mx-4 my-2 flex flex-col gap-2">
{showCallout() && (
<Callout
class="text-sm hover:opacity-[80%]"
onClick={() => setShowCallout(false)}
>
When changing schemas, consider using migrations to consistently
apply changes across environments. One-off alterations can otherwise
lead to skew. Alterations using the table browser will produce
migrations.
</Callout>
)}
{/* Editor */}
<div
class="max-h-[70dvh] grow overflow-y-scroll rounded outline"
ref={ref}
/>
{/* Editor */}
<div
class="max-h-[40dvh] shrink overflow-scroll rounded outline"
ref={ref}
/>
<div class="flex justify-end">
<Tooltip>
<TooltipTrigger as="div">
<Button variant="destructive" onClick={execute}>
Execute (Ctrl+Enter)
</Button>
</TooltipTrigger>
<div class="flex justify-end">
<Tooltip>
<TooltipTrigger as="div">
<Button variant="destructive" onClick={execute}>
Execute (Ctrl+Enter)
</Button>
</TooltipTrigger>
<TooltipContent>
Execute script on the server. No turning back.
</TooltipContent>
</Tooltip>
</div>
</div>
</ResizablePanel>
<TooltipContent>
Execute script on the server. No turning back.
</TooltipContent>
</Tooltip>
</div>
</div>
<ResizableHandle withHandle={true} />
<Separator />
<ResizablePanel class="hide-scrollbars overflow-y-scroll">
<div class="grow p-4">
<ResultView
script={props.script}
response={executionResult.data ?? undefined}
/>
</div>
</ResizablePanel>
</Resizable>
<div class="flex shrink flex-col">
<ResultView
script={props.script}
response={executionResult.data ?? undefined}
/>
</div>
</Dialog>
);
}
@@ -634,8 +622,6 @@ export function EditorPage() {
);
}
export default EditorPage;
const myTheme = EditorView.theme(
{
".cm-gutters": {
@@ -693,3 +679,6 @@ const $scripts = persistentAtom<Script[]>("scripts", [defaultScript], {
encode: JSON.stringify,
decode: JSON.parse,
});
// Needed for lazy load.
export default EditorPage;
@@ -3,6 +3,7 @@ import { Graph, Cell, Shape, Edge, Node } from "@antv/x6";
import { PortManager } from "@antv/x6/lib/model/port";
import { cn } from "@/lib/utils";
import { createIsMobile } from "@/lib/signals";
export const ER_NODE_NAME = "er-rect";
export const LINE_HEIGHT = 24;
@@ -125,6 +126,7 @@ export function ErdGraph(props: {
nodes: NodeMetadata[];
edges: EdgeMetadata[];
}) {
const isMobile = createIsMobile();
let ref: HTMLDivElement | undefined;
onMount(() => {
@@ -165,6 +167,7 @@ export function ErdGraph(props: {
// v0.3.25 results in "layout is not a function": https://github.com/antvis/X6/issues/4441
// v1.2 has completely in-compatible APIs. They'll probably need to overhaul x6 first.
const aspect = window.innerWidth / window.innerHeight;
const size = Math.ceil(Math.sqrt(props.nodes.length) * aspect);
const maxHeight = props.nodes.reduce((acc, node) => {
const ports = node.ports;
@@ -200,13 +203,17 @@ export function ErdGraph(props: {
graph.zoomToFit({ padding: 100, maxScale: 1 });
});
const style = () => {
if (isMobile()) {
return "h-[calc(100dvh-120px)] w-[calc(100dvw)] overflow-clip";
}
return "h-[calc(100dvh-65px)] w-[calc(100dvw-58px)] overflow-clip";
};
// NOTE: The double sytling is somehow needed otherwise it overflows on mobile. x6 may also apply some styles on top.
return (
<div
ref={ref}
class={cn(
"h-[calc(100dvh-66px)] w-[calc(100dvw-58px)] overflow-clip",
props.class,
)}
/>
<div class={style()}>
<div ref={ref} class={cn(style(), props.class)} />
</div>
);
}
@@ -172,6 +172,12 @@ function SchemaErdGraph(props: { schema: ListSchemasResponse }) {
return { nodes, edges };
});
{
/*
<ErdGraph nodes={nodesAndEdges().nodes} edges={nodesAndEdges().edges} />
*/
}
return (
<ErdGraph nodes={nodesAndEdges().nodes} edges={nodesAndEdges().edges} />
);
@@ -181,7 +187,7 @@ export function ErdPage() {
const schemaFetch = createTableSchemaQuery();
return (
<div class="h-dvh">
<div class="flex h-full flex-col">
<Header title="Schema" />
<Switch>
@@ -209,7 +209,7 @@ export function LogsPage() {
const [showGeoipDialog, setShowGeoipDialog] = createSignal(false);
return (
<div class="h-dvh overflow-y-auto">
<div class="h-full">
<Header
title="Logs"
left={
@@ -268,18 +268,18 @@ export function LogsPage() {
<Match when={logsFetch.data}>
{pagination().pageIndex === 0 && logsFetch.data!.stats && (
<div class="mb-4 flex h-[300px] w-full gap-4">
<div class={showMap() ? "w-1/2 grow" : "w-full"}>
<LogsChart stats={logsFetch.data!.stats!} />
</div>
{showMap() && logsFetch.data!.stats!.country_codes && (
<div class="flex w-1/2 max-w-[500px] items-center">
<div class="mb-4 flex w-full flex-col gap-4 md:h-[300px] md:flex-row">
<Show when={showMap() && logsFetch.data!.stats!.country_codes}>
<div class="flex items-center md:w-1/2 md:max-w-[500px]">
<WorldMap
country_codes={logsFetch.data!.stats!.country_codes!}
/>
</div>
)}
</Show>
<div class={showMap() ? "md:w-1/2" : "w-full"}>
<LogsChart stats={logsFetch.data!.stats!} />
</div>
</div>
)}
@@ -220,7 +220,7 @@ export function EmailSettings(props: {
>
{buildOptionalTextFormField({
label: textLabel("Username"),
autocomplete: "username",
autocomplete: "off",
})}
</form.Field>
@@ -232,7 +232,7 @@ export function EmailSettings(props: {
// NOTE: we're not using buildSecretFormField here because it doesn't support optional.
buildOptionalTextFormField({
type: "password",
autocomplete: "current-password",
autocomplete: "off",
label: textLabel("Password"),
})
}
@@ -579,7 +579,7 @@ function ArrayRecordTable(props: {
placeholder={`Filter Query, e.g. '(col0 > 5 && col0 < 20) || col1 = "val"'`}
/>
<div class="overflow-auto pt-4">
<div class="overflow-x-auto pt-4">
<DataTable
// NOTE: The formatting is done via the columnsDefs.
columns={columnDefs}
@@ -908,7 +908,7 @@ export function TablePane(props: {
/>
</SheetContent>
<div class="space-y-2.5 overflow-auto">
<div class="space-y-2.5 overflow-x-auto">
<DataTable
columns={() => indexColumns}
data={indexes}
@@ -82,7 +82,7 @@ function TablePickerPane(props: {
return (
<div
class={`${horizontal() ? "flex h-dvh flex-col" : "flex"} hide-scrollbars gap-2 overflow-scroll p-4`}
class={`${horizontal() ? "flex h-dvh flex-col" : "flex"} gap-2 overflow-scroll p-4`}
>
<SwitchToggle
class="flex items-center justify-center gap-2"
@@ -12,6 +12,13 @@ export function createWindowWidth(): Accessor<number> {
return width;
}
const mobileBreakpoint = 768;
export function createIsMobile(): Accessor<boolean> {
const width = createWindowWidth();
return () => width() < mobileBreakpoint;
}
export function createSetOnce<T>(initial: T): [
() => T,
(v: T) => void,