fix: multiple mobile and responsiveness issues (#537)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

* fix: multiple mobile and responsiveness issues

fix(mobile): scroll reset on snapshot selection

fix(mobile): layout improvements

refactor(volume-details): improve layout

refactor: better card breakpoints layouts in backps list

fix(ui): keep sidebar in the state it was before reloading

refactor(ui): keep the same grid size in all breakpoints

refactor: manual hotkey devpanel to tanstack hotkeys

* chore: pr feedback
This commit is contained in:
Nico 2026-02-17 18:44:22 +01:00 committed by GitHub
parent 16df0dc875
commit f059a23ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 175 additions and 209 deletions

View file

@ -4,6 +4,7 @@
- Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
## Project Overview

View file

@ -5,7 +5,16 @@
@custom-variant dark (&:is(.dark *));
@custom-variant @narrow (@container (min-width: 28rem));
@custom-variant @medium (@container (min-width: 40rem));
@custom-variant @wide (@container (min-width: 60rem));
@custom-variant @xwide (@container (min-width: 80rem));
@theme {
--container-narrow: 28rem;
--container-medium: 40rem;
--container-wide: 60rem;
--container-xwide: 80rem;
--breakpoint-xs: 32rem;
--font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
@ -17,10 +26,16 @@ body {
overflow-x: hidden;
width: 100%;
position: relative;
overscroll-behavior: none;
scrollbar-width: thin;
}
@media (min-width: 48rem) {
html,
body {
overscroll-behavior: none;
}
}
body {
@apply bg-[#131313];
min-height: 100dvh;

View file

@ -49,7 +49,7 @@ const items = [
];
export function AppSidebar() {
const { state } = useSidebar();
const { state, isMobile, setOpenMobile } = useSidebar();
const { updates, hasUpdate } = useUpdates();
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
@ -83,7 +83,7 @@ export function AppSidebar() {
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild>
<Link to={item.url}>
<Link to={item.url} onClick={() => isMobile && setOpenMobile(false)}>
{({ isActive }) => (
<>
<item.icon className={cn({ "text-strong-accent": isActive })} />

View file

@ -1,51 +1,18 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHotkey } from "@tanstack/react-hotkeys";
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { DevPanel } from "./dev-panel";
export function DevPanelListener() {
const [isOpen, setIsOpen] = useState(false);
const pressedKeysRef = useRef<Set<string>>(new Set());
const { data: devPanelStatus } = useQuery({
...getDevPanelOptions(),
});
const isEnabled = devPanelStatus?.enabled ?? false;
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isEnabled) return;
pressedKeysRef.current.add(e.key.toLowerCase());
const keys = pressedKeysRef.current;
if (keys.has("d") && keys.has("e") && keys.has("v")) {
setIsOpen(true);
pressedKeysRef.current.clear();
}
},
[isEnabled],
);
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
if (!isEnabled) return;
pressedKeysRef.current.delete(e.key.toLowerCase());
},
[isEnabled],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
if (!isEnabled) {
if (!devPanelStatus?.enabled) {
return null;
}

View file

@ -12,7 +12,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
<div
className={cn(
"relative min-h-full w-full",
"bg-size-[20px_20px] sm:bg-size-[40px_40px]",
"bg-size-[40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName,

View file

@ -31,9 +31,9 @@ export function Layout({ loaderData }: Props) {
};
return (
<SidebarProvider defaultOpen={true}>
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden">
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0">
@ -66,9 +66,9 @@ export function Layout({ loaderData }: Props) {
)}
</div>
</header>
<div className="main-content flex-1 overflow-y-auto">
<div className="main-content flex-1 md:overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto @container">
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>

View file

@ -14,7 +14,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "
import { Skeleton } from "~/client/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";

View file

@ -86,16 +86,20 @@ export const SnapshotFileBrowser = (props: Props) => {
<div className="space-y-4">
<Card className="h-150 flex flex-col">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<CardTitle>File Browser</CardTitle>
<CardDescription
className={cn({ hidden: !snapshot.time })}
>{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}</CardDescription>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap sm:flex-nowrap">
<Link
to={backupId ? "/backups/$backupId/$snapshotId/restore" : "/repositories/$repositoryId/$snapshotId/restore"}
to={
backupId
? "/backups/$backupId/$snapshotId/restore"
: "/repositories/$repositoryId/$snapshotId/restore"
}
params={
backupId
? { backupId, snapshotId: snapshot.short_id }

View file

@ -223,6 +223,7 @@ export function ScheduleDetailsPage(props: Props) {
void navigate({
to: ".",
search: () => ({ ...searchParams, snapshot: snapshotId }),
resetScroll: false,
});
};

View file

@ -97,10 +97,10 @@ export function BackupsPage() {
const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
return (
<div className="container mx-auto space-y-6">
<div className="container @container mx-auto space-y-6">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={rectSortingStrategy}>
<div className="grid gap-4 @md:grid-cols-1 @lg:grid-cols-2 @2xl:grid-cols-3 auto-rows-fr">
<div className="grid gap-4 @narrow:grid-cols-1 @medium:grid-cols-2 @wide:grid-cols-3 auto-rows-fr">
{items.map((id) => {
const schedule = scheduleMap.get(id);
if (!schedule) return null;

View file

@ -110,7 +110,7 @@ export function CreateBackupPage() {
}
return (
<div className="container mx-auto space-y-6">
<div className="container mx-auto space-y-4">
<Card>
<CardContent>
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>

View file

@ -96,12 +96,12 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
return (
<>
<Card className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<Card className="p-6 @container">
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4">
<div>
<span className="text-lg font-semibold mb-4">Repository Settings</span>
<span className="text-lg font-semibold">Repository Settings</span>
</div>
<div className="flex flex-wrap justify-end gap-2 sm:gap-4">
<div className="flex flex-col @xl:flex-row w-full @xl:w-auto gap-2">
<Button
type="button"
variant="outline"
@ -153,7 +153,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<div>
<h3 className="text-lg font-semibold mb-4">Current Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
@ -167,7 +167,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>

View file

@ -1,143 +1,27 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { useState } from "react";
import { Plug, Unplug } from "lucide-react";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { VolumeIcon } from "~/client/components/volume-icon";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import { VolumeInfoTabContent } from "../tabs/info";
import { FilesTabContent } from "../tabs/files";
import type { VolumeStatus } from "~/client/lib/types";
import {
deleteVolumeMutation,
getVolumeOptions,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router";
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
const statusMap = {
mounted: "success" as const,
unmounted: "neutral" as const,
error: "error" as const,
unknown: "warning" as const,
};
return statusMap[status];
};
export function VolumeDetails({ volumeId }: { volumeId: string }) {
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId" });
const activeTab = searchParams.tab || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useSuspenseQuery({
...getVolumeOptions({ path: { id: volumeId } }),
});
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
void navigate({ to: "/volumes" });
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
const mountVol = useMutation({
...mountVolumeMutation(),
onSuccess: () => {
toast.success("Volume mounted successfully");
},
onError: (error) => {
toast.error("Failed to mount volume", {
description: parseError(error)?.message,
});
},
});
const unmountVol = useMutation({
...unmountVolumeMutation(),
onSuccess: () => {
toast.success("Volume unmounted successfully");
},
onError: (error) => {
toast.error("Failed to unmount volume", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { id: volumeId } });
};
const { volume, statfs } = data;
return (
<>
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2">
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
&nbsp;
{volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon backend={volume?.config.backend} />
</div>
<div className="flex gap-4">
<Button
onClick={() => mountVol.mutate({ path: { id: volumeId } })}
loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })}
>
<Plug className="h-4 w-4 mr-2" />
Mount
</Button>
<Button
variant="secondary"
onClick={() => unmountVol.mutate({ path: { id: volumeId } })}
loading={unmountVol.isPending}
className={cn({ hidden: volume.status !== "mounted" })}
>
<Unplug className="h-4 w-4 mr-2" />
Unmount
</Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete
</Button>
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}
className="mt-4"
>
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
@ -149,29 +33,6 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
<FilesTabContent volume={volume} />
</TabsContent>
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
<br />
<br />
All backup schedules associated with this volume will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete volume
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,7 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Check } from "lucide-react";
import { Check, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import {
AlertDialog,
@ -13,13 +13,20 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart";
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
deleteVolumeMutation,
mountVolumeMutation,
unmountVolumeMutation,
updateVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
import { useNavigate } from "@tanstack/react-router";
import { parseError } from "~/client/lib/errors";
type Props = {
volume: Volume;
@ -47,8 +54,46 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
},
});
const mountVol = useMutation({
...mountVolumeMutation(),
onSuccess: () => {
toast.success("Volume mounted successfully");
},
onError: (error) => {
toast.error("Failed to mount volume", {
description: parseError(error)?.message,
});
},
});
const unmountVol = useMutation({
...unmountVolumeMutation(),
onSuccess: () => {
toast.success("Volume unmounted successfully");
},
onError: (error) => {
toast.error("Failed to unmount volume", {
description: parseError(error)?.message,
});
},
});
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
void navigate({ to: "/volumes" });
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
const [open, setOpen] = useState(false);
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleSubmit = (values: FormValues) => {
setPendingValues(values);
@ -64,10 +109,51 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
}
};
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { id: volume.shortId } });
};
return (
<>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
<Card className="p-6">
<Card className="p-6 @container">
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4 mb-6">
<div>
<span className="text-lg font-semibold">Volume Configuration</span>
</div>
<div className="flex flex-col @xl:flex-row w-full @xl:w-auto gap-2">
{volume.status !== "mounted" ? (
<Button
type="button"
onClick={() => mountVol.mutate({ path: { id: volume.shortId } })}
loading={mountVol.isPending}
>
<Plug className="h-4 w-4 mr-2" />
Mount
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() => unmountVol.mutate({ path: { id: volume.shortId } })}
loading={unmountVol.isPending}
>
<Unplug className="h-4 w-4 mr-2" />
Unmount
</Button>
)}
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteVol.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
<CreateVolumeForm
initialValues={{ ...volume, ...volume.config }}
onSubmit={handleSubmit}
@ -102,6 +188,30 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
<br />
<br />
All backup schedules associated with this volume will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteVol.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete volume
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View file

@ -10,4 +10,5 @@ type User = {
export type AppContext = {
user: User | null;
hasUsers: boolean;
sidebarOpen: boolean;
};

View file

@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
import { Layout } from "~/client/components/layout";
import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
import { authMiddleware } from "~/middleware/auth";
import { auth } from "~/server/lib/auth";
import { authService } from "~/server/modules/auth/auth.service";
@ -11,7 +12,10 @@ export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const session = await auth.api.getSession({ headers });
const hasUsers = await authService.hasUsers();
return { user: session?.user ?? null, hasUsers };
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
const sidebarOpen = sidebarCookie === null ? true : sidebarCookie === "true";
return { user: session?.user ?? null, hasUsers, sidebarOpen };
});
export const Route = createFileRoute("/(dashboard)")({

View file

@ -2,8 +2,6 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
import appCss from "../app.css?url";
import { apiClientMiddleware } from "~/middleware/api-client";
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "~/client/components/ui/sonner";
import { useServerEvents } from "~/client/hooks/use-server-events";
@ -52,8 +50,6 @@ function RootLayout() {
</head>
<body className="dark">
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" />
<Toaster />
<Scripts />
</body>

View file

@ -26,6 +26,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@scalar/hono-api-reference": "^0.9.41",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router": "^1.160.2",
@ -756,10 +757,14 @@
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/hotkeys": ["@tanstack/hotkeys@0.1.0", "", { "dependencies": { "@tanstack/store": "^0.8.0" } }, "sha512-2ypLO3C2vKxMbCjiOfA3RzDaUIUYsQtyLc+Qwl6oKzQvWxReGlQN7DOQZSeKyX9D395d3E65e2v7Fm14BzUGew=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="],
"@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.1.0", "", { "dependencies": { "@tanstack/hotkeys": "0.1.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-Hteo+hdPKdR2G7uAwTmScu1JUHM/f03lVihxVfR/A3XMyVJmbzi4SJieRk/kLgYKeXFRGIUx/xSI3mHeVc6DbQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="],

View file

@ -47,6 +47,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@scalar/hono-api-reference": "^0.9.41",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router": "^1.160.2",