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
This commit is contained in:
parent
16df0dc875
commit
1ca15f331f
19 changed files with 173 additions and 207 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
- Never create migration files manually. Always use the provided command to generate migrations
|
- 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
|
- 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
|
## Project Overview
|
||||||
|
|
||||||
|
|
|
||||||
17
app/app.css
17
app/app.css
|
|
@ -5,7 +5,16 @@
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme {
|
||||||
|
--container-narrow: 28rem;
|
||||||
|
--container-medium: 40rem;
|
||||||
|
--container-wide: 60rem;
|
||||||
|
--container-xwide: 80rem;
|
||||||
--breakpoint-xs: 32rem;
|
--breakpoint-xs: 32rem;
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"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;
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overscroll-behavior: none;
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-[#131313];
|
@apply bg-[#131313];
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const items = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { state } = useSidebar();
|
const { state, isMobile, setOpenMobile } = useSidebar();
|
||||||
const { updates, hasUpdate } = useUpdates();
|
const { updates, hasUpdate } = useUpdates();
|
||||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function AppSidebar() {
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<Link to={item.url}>
|
<Link to={item.url} onClick={() => isMobile && setOpenMobile(false)}>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,18 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||||
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { DevPanel } from "./dev-panel";
|
import { DevPanel } from "./dev-panel";
|
||||||
|
|
||||||
export function DevPanelListener() {
|
export function DevPanelListener() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const pressedKeysRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const { data: devPanelStatus } = useQuery({
|
const { data: devPanelStatus } = useQuery({
|
||||||
...getDevPanelOptions(),
|
...getDevPanelOptions(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnabled = devPanelStatus?.enabled ?? false;
|
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
if (!devPanelStatus?.enabled) {
|
||||||
(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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative min-h-full w-full",
|
"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)]",
|
"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)]",
|
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ export function Layout({ loaderData }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={true}>
|
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
||||||
<AppSidebar />
|
<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">
|
<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 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">
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
|
@ -66,9 +66,9 @@ export function Layout({ loaderData }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="main-content flex-1 overflow-y-auto">
|
<div className="main-content flex-1 md:overflow-y-auto">
|
||||||
<GridBackground>
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</GridBackground>
|
</GridBackground>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "
|
||||||
import { Skeleton } from "~/client/components/ui/skeleton";
|
import { Skeleton } from "~/client/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
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_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
const SIDEBAR_WIDTH = "14rem";
|
const SIDEBAR_WIDTH = "14rem";
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
|
|
|
||||||
|
|
@ -86,16 +86,20 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="h-150 flex flex-col">
|
<Card className="h-150 flex flex-col">
|
||||||
<CardHeader>
|
<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>
|
<div>
|
||||||
<CardTitle>File Browser</CardTitle>
|
<CardTitle>File Browser</CardTitle>
|
||||||
<CardDescription
|
<CardDescription
|
||||||
className={cn({ hidden: !snapshot.time })}
|
className={cn({ hidden: !snapshot.time })}
|
||||||
>{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}</CardDescription>
|
>{`Viewing snapshot from ${formatDateTime(snapshot?.time)}`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap sm:flex-nowrap">
|
||||||
<Link
|
<Link
|
||||||
to={backupId ? "/backups/$backupId/$snapshotId/restore" : "/repositories/$repositoryId/$snapshotId/restore"}
|
to={
|
||||||
|
backupId
|
||||||
|
? "/backups/$backupId/$snapshotId/restore"
|
||||||
|
: "/repositories/$repositoryId/$snapshotId/restore"
|
||||||
|
}
|
||||||
params={
|
params={
|
||||||
backupId
|
backupId
|
||||||
? { backupId, snapshotId: snapshot.short_id }
|
? { backupId, snapshotId: snapshot.short_id }
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: ".",
|
to: ".",
|
||||||
search: () => ({ ...searchParams, snapshot: snapshotId }),
|
search: () => ({ ...searchParams, snapshot: snapshotId }),
|
||||||
|
resetScroll: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,10 +97,10 @@ export function BackupsPage() {
|
||||||
const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
|
const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
|
||||||
|
|
||||||
return (
|
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}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
<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) => {
|
{items.map((id) => {
|
||||||
const schedule = scheduleMap.get(id);
|
const schedule = scheduleMap.get(id);
|
||||||
if (!schedule) return null;
|
if (!schedule) return null;
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function CreateBackupPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6">
|
<div className="container mx-auto space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>
|
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,12 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="p-6 space-y-6">
|
<Card className="p-6 @container">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
|
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-lg font-semibold mb-4">Repository Settings</span>
|
<span className="text-lg font-semibold">Repository Settings</span>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,27 @@
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useSearch } from "@tanstack/react-router";
|
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 { 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 { VolumeInfoTabContent } from "../tabs/info";
|
||||||
import { FilesTabContent } from "../tabs/files";
|
import { FilesTabContent } from "../tabs/files";
|
||||||
import type { VolumeStatus } from "~/client/lib/types";
|
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import {
|
|
||||||
deleteVolumeMutation,
|
|
||||||
getVolumeOptions,
|
|
||||||
mountVolumeMutation,
|
|
||||||
unmountVolumeMutation,
|
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
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 }) {
|
export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId" });
|
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId" });
|
||||||
|
|
||||||
const activeTab = searchParams.tab || "info";
|
const activeTab = searchParams.tab || "info";
|
||||||
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
|
|
||||||
const { data } = useSuspenseQuery({
|
const { data } = useSuspenseQuery({
|
||||||
...getVolumeOptions({ path: { id: volumeId } }),
|
...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;
|
const { volume, statfs } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}>
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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"
|
|
||||||
>
|
|
||||||
<TabsList className="mb-2">
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
<TabsTrigger value="files">Files</TabsTrigger>
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
|
|
@ -149,29 +33,6 @@ export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
||||||
<FilesTabContent volume={volume} />
|
<FilesTabContent volume={volume} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -13,13 +13,20 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card } from "~/client/components/ui/card";
|
import { Card } from "~/client/components/ui/card";
|
||||||
import type { StatFs, Volume } from "~/client/lib/types";
|
import type { StatFs, Volume } from "~/client/lib/types";
|
||||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||||
import { StorageChart } from "../components/storage-chart";
|
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 type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
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 [open, setOpen] = useState(false);
|
||||||
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
|
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (values: FormValues) => {
|
const handleSubmit = (values: FormValues) => {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
|
|
@ -64,10 +109,51 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
deleteVol.mutate({ path: { id: volume.shortId } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
|
<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
|
<CreateVolumeForm
|
||||||
initialValues={{ ...volume, ...volume.config }}
|
initialValues={{ ...volume, ...volume.config }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|
@ -102,6 +188,30 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ type User = {
|
||||||
export type AppContext = {
|
export type AppContext = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
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 { Layout } from "~/client/components/layout";
|
||||||
|
import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
|
||||||
import { authMiddleware } from "~/middleware/auth";
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
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 session = await auth.api.getSession({ headers });
|
||||||
const hasUsers = await authService.hasUsers();
|
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)")({
|
export const Route = createFileRoute("/(dashboard)")({
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanst
|
||||||
import appCss from "../app.css?url";
|
import appCss from "../app.css?url";
|
||||||
import { apiClientMiddleware } from "~/middleware/api-client";
|
import { apiClientMiddleware } from "~/middleware/api-client";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
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 { Toaster } from "~/client/components/ui/sonner";
|
||||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
|
|
||||||
|
|
@ -52,8 +50,6 @@ function RootLayout() {
|
||||||
</head>
|
</head>
|
||||||
<body className="dark">
|
<body className="dark">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
|
||||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -26,6 +26,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@scalar/hono-api-reference": "^0.9.41",
|
"@scalar/hono-api-reference": "^0.9.41",
|
||||||
|
"@tanstack/react-hotkeys": "^0.1.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router": "^1.160.2",
|
"@tanstack/react-router": "^1.160.2",
|
||||||
|
|
@ -756,10 +757,14 @@
|
||||||
|
|
||||||
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
"@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-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||||
|
|
||||||
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="],
|
"@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": ["@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=="],
|
"@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=="],
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@scalar/hono-api-reference": "^0.9.41",
|
"@scalar/hono-api-reference": "^0.9.41",
|
||||||
|
"@tanstack/react-hotkeys": "^0.1.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router": "^1.160.2",
|
"@tanstack/react-router": "^1.160.2",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue