chore: format

This commit is contained in:
Stavros
2025-11-09 13:16:14 +02:00
parent ffca433a43
commit db0d153610
10 changed files with 728 additions and 810 deletions

View File

@@ -5,171 +5,171 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--breakpoint-xs: 32rem; --breakpoint-xs: 32rem;
--font-sans: --font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Noto Color Emoji";
} }
html, html,
body { body {
overflow-x: hidden; overflow-x: hidden;
width: 100%; width: 100%;
position: relative; position: relative;
overscroll-behavior: none; overscroll-behavior: none;
scrollbar-width: thin; scrollbar-width: thin;
} }
body { body {
@apply bg-[#131313]; @apply bg-[#131313];
min-height: 100dvh; min-height: 100dvh;
} }
.main-content { .main-content {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header); --color-card-header: var(--card-header);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-strong-accent: var(--strong-accent); --color-strong-accent: var(--strong-accent);
} }
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0); --card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--background: #131313; --background: #131313;
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: #131313; --card: #131313;
--card-header: #1b1b1b; --card-header: #1b1b1b;
/* --card: oklch(0.205 0 0); ORIGINAL */ /* --card: oklch(0.205 0 0); ORIGINAL */
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.1448 0 0); --secondary: oklch(0.1448 0 0);
/* --secondary: oklch(0.269 0 0); ORIGINAL */ /* --secondary: oklch(0.269 0 0); ORIGINAL */
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a; --destructive: #ff543a;
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@layer base { @layer base {
:root { :root {
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
} }
.dark { .dark {
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
} }
} }

View File

@@ -2,33 +2,33 @@ import { Mountain } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type AuthLayoutProps = { type AuthLayoutProps = {
title: string; title: string;
description: string; description: string;
children: ReactNode; children: ReactNode;
}; };
export function AuthLayout({ title, description, children }: AuthLayoutProps) { export function AuthLayout({ title, description, children }: AuthLayoutProps) {
return ( return (
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen"> <div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
<div className="flex flex-1 items-center justify-center bg-background p-8"> <div className="flex flex-1 items-center justify-center bg-background p-8">
<div className="w-full max-w-md space-y-8"> <div className="w-full max-w-md space-y-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Mountain className="size-5 text-strong-accent" /> <Mountain className="size-5 text-strong-accent" />
<span className="text-lg font-semibold">Ironmount</span> <span className="text-lg font-semibold">Ironmount</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1> <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
</div> </div>
{children} {children}
</div> </div>
</div> </div>
<div <div
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center" className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
style={{ backgroundImage: "url(/images/background.jpg)" }} style={{ backgroundImage: "url(/images/background.jpg)" }}
/> />
</div> </div>
); );
} }

View File

@@ -15,84 +15,73 @@ import { AppSidebar } from "./app-sidebar";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
export async function clientLoader({ context }: Route.LoaderArgs) { export async function clientLoader({ context }: Route.LoaderArgs) {
const ctx = context.get(appContext); const ctx = context.get(appContext);
if (ctx.user && !ctx.user.hasDownloadedResticPassword) { if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
throw redirect("/download-recovery-key"); throw redirect("/download-recovery-key");
} }
return ctx; return ctx;
} }
export default function Layout({ loaderData }: Route.ComponentProps) { export default function Layout({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const logout = useMutation({ const logout = useMutation({
...logoutMutation(), ...logoutMutation(),
onSuccess: async () => { onSuccess: async () => {
navigate("/login", { replace: true }); navigate("/login", { replace: true });
}, },
onError: (error) => { onError: (error) => {
console.error(error); console.error(error);
toast.error("Logout failed", { description: error.message }); toast.error("Logout failed", { description: error.message });
}, },
}); });
return ( return (
<SidebarProvider defaultOpen={true}> <SidebarProvider defaultOpen={true}>
<AppSidebar /> <AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden"> <div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0"> <header className="z-50 bg-card-header border-b border-border/50 flex-shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container"> <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<SidebarTrigger /> <SidebarTrigger />
<AppBreadcrumb /> <AppBreadcrumb />
</div> </div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex"> <span className="text-sm text-muted-foreground hidden md:inline-flex">
Welcome,&nbsp; Welcome,&nbsp;
<span className="text-strong-accent"> <span className="text-strong-accent">{loaderData.user?.username}</span>
{loaderData.user?.username} </span>
</span> <Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
</span> Logout
<Button </Button>
variant="default" <Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
size="sm" <a
onClick={() => logout.mutate({})} href="https://github.com/nicotsx/ironmount/issues/new"
loading={logout.isPending} target="_blank"
> rel="noreferrer"
Logout className="flex items-center gap-2"
</Button> >
<Button <span className="flex items-center gap-2">
variant="default" <LifeBuoy />
size="sm" <span>Report an issue</span>
className="relative overflow-hidden hidden lg:inline-flex" </span>
> </a>
<a </Button>
href="https://github.com/nicotsx/ironmount/issues/new" </div>
target="_blank" )}
rel="noreferrer" </div>
className="flex items-center gap-2" </header>
> <div className="main-content flex-1 overflow-y-auto">
<span className="flex items-center gap-2"> <GridBackground>
<LifeBuoy /> <main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<span>Report an issue</span> <Outlet />
</span> </main>
</a> </GridBackground>
</Button> </div>
</div> </div>
)} </SidebarProvider>
</div> );
</header>
<div className="main-content flex-1 overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>
</div>
</div>
</SidebarProvider>
);
} }

View File

@@ -2,36 +2,30 @@ import { cn } from "~/lib/utils";
import { Switch } from "./ui/switch"; import { Switch } from "./ui/switch";
type Props = { type Props = {
isOn: boolean; isOn: boolean;
toggle: (v: boolean) => void; toggle: (v: boolean) => void;
enabledLabel: string; enabledLabel: string;
disabledLabel: string; disabledLabel: string;
disabled?: boolean; disabled?: boolean;
}; };
export const OnOff = ({ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: Props) => {
isOn, return (
toggle, <div
enabledLabel, className={cn(
disabledLabel, "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
disabled, isOn
}: Props) => { ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200"
return ( : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
<div )}
className={cn( >
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", <span>{isOn ? enabledLabel : disabledLabel}</span>
isOn <Switch
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200" disabled={disabled}
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10" checked={isOn}
)} onCheckedChange={toggle}
> aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
<span>{isOn ? enabledLabel : disabledLabel}</span> />
<Switch </div>
disabled={disabled} );
checked={isOn}
onCheckedChange={toggle}
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
/>
</div>
);
}; };

View File

@@ -3,53 +3,50 @@ import { cn } from "~/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const StatusDot = ({ status }: { status: VolumeStatus }) => { export const StatusDot = ({ status }: { status: VolumeStatus }) => {
const statusMapping = { const statusMapping = {
mounted: { mounted: {
color: "bg-green-500", color: "bg-green-500",
colorLight: "bg-emerald-400", colorLight: "bg-emerald-400",
animated: true, animated: true,
}, },
unmounted: { unmounted: {
color: "bg-gray-500", color: "bg-gray-500",
colorLight: "bg-gray-400", colorLight: "bg-gray-400",
animated: false, animated: false,
}, },
error: { error: {
color: "bg-red-500", color: "bg-red-500",
colorLight: "bg-amber-700", colorLight: "bg-amber-700",
animated: true, animated: true,
}, },
unknown: { unknown: {
color: "bg-yellow-500", color: "bg-yellow-500",
colorLight: "bg-yellow-400", colorLight: "bg-yellow-400",
animated: true, animated: true,
}, },
}[status]; }[status];
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<span className="relative flex size-3 mx-auto"> <span className="relative flex size-3 mx-auto">
{statusMapping.animated && ( {statusMapping.animated && (
<span <span
className={cn( className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75", "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
`${statusMapping.colorLight}` `${statusMapping.colorLight}`,
)} )}
/> />
)} )}
<span <span
aria-label={status} aria-label={status}
className={cn( className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
"relative inline-flex size-3 rounded-full", />
`${statusMapping.color}` </span>
)} </TooltipTrigger>
/> <TooltipContent>
</span> <p className="capitalize">{status}</p>
</TooltipTrigger> </TooltipContent>
<TooltipContent> </Tooltip>
<p className="capitalize">{status}</p> );
</TooltipContent>
</Tooltip>
);
}; };

View File

@@ -3,19 +3,19 @@ import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
deleteRepositoryMutation, deleteRepositoryMutation,
getRepositoryOptions, getRepositoryOptions,
listSnapshotsOptions, listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/api-client/@tanstack/react-query.gen";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
import { getRepository } from "~/api-client/sdk.gen"; import { getRepository } from "~/api-client/sdk.gen";
@@ -26,137 +26,122 @@ import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: params.name },
{ {
name: "description", name: "description",
content: "View repository configuration, status, and snapshots.", content: "View repository configuration, status, and snapshots.",
}, },
]; ];
} }
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } }); const repository = await getRepository({ path: { name: params.name ?? "" } });
if (repository.data) return repository.data; if (repository.data) return repository.data;
}; };
export default function RepositoryDetailsPage({ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
loaderData, const { name } = useParams<{ name: string }>();
}: Route.ComponentProps) { const navigate = useNavigate();
const { name } = useParams<{ name: string }>(); const queryClient = useQueryClient();
const navigate = useNavigate(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info"; const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({ const { data } = useQuery({
...getRepositoryOptions({ path: { name: name ?? "" } }), ...getRepositoryOptions({ path: { name: name ?? "" } }),
initialData: loaderData, initialData: loaderData,
refetchInterval: 10000, refetchInterval: 10000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
useEffect(() => { useEffect(() => {
if (name) { if (name) {
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
} }
}, [name, queryClient]); }, [name, queryClient]);
const deleteRepo = useMutation({ const deleteRepo = useMutation({
...deleteRepositoryMutation(), ...deleteRepositoryMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Repository deleted successfully"); toast.success("Repository deleted successfully");
navigate("/repositories"); navigate("/repositories");
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to delete repository", { toast.error("Failed to delete repository", {
description: parseError(error)?.message, description: parseError(error)?.message,
}); });
}, },
}); });
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { name: name ?? "" } }); deleteRepo.mutate({ path: { name: name ?? "" } });
}; };
if (!name) { if (!name) {
return <div>Repository not found</div>; return <div>Repository not found</div>;
} }
if (!data) { if (!data) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
return ( return (
<> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2"> <div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span <span
className={cn( className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", "bg-green-500/10 text-green-500": data.status === "healthy",
{ "bg-red-500/10 text-red-500": data.status === "error",
"bg-green-500/10 text-green-500": data.status === "healthy", })}
"bg-red-500/10 text-red-500": data.status === "error", >
} {data.status || "unknown"}
)} </span>
> <span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
{data.status || "unknown"} </div>
</span> <div className="flex gap-4">
<span className="text-xs bg-primary/10 rounded-md px-2 py-1"> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
{data.type} Delete
</span> </Button>
</div> </div>
<div className="flex gap-4"> </div>
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
Delete
</Button>
</div>
</div>
<Tabs <Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
value={activeTab} <TabsList className="mb-2">
onValueChange={(value) => setSearchParams({ tab: value })} <TabsTrigger value="info">Configuration</TabsTrigger>
> <TabsTrigger value="snapshots">Snapshots</TabsTrigger>
<TabsList className="mb-2"> </TabsList>
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsContent value="info">
<TabsTrigger value="snapshots">Snapshots</TabsTrigger> <RepositoryInfoTabContent repository={data} />
</TabsList> </TabsContent>
<TabsContent value="info"> <TabsContent value="snapshots">
<RepositoryInfoTabContent repository={data} /> <RepositorySnapshotsTabContent repository={data} />
</TabsContent> </TabsContent>
<TabsContent value="snapshots"> </Tabs>
<RepositorySnapshotsTabContent repository={data} />
</TabsContent>
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle> <AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the repository{" "} Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
<strong>{name}</strong>? This action cannot be undone and will will remove all backup data.
remove all backup data. </AlertDialogDescription>
</AlertDialogDescription> </AlertDialogHeader>
</AlertDialogHeader> <div className="flex gap-3 justify-end">
<div className="flex gap-3 justify-end"> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction
<AlertDialogAction onClick={handleConfirmDelete}
onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" >
> Delete repository
Delete repository </AlertDialogAction>
</AlertDialogAction> </div>
</div> </AlertDialogContent>
</AlertDialogContent> </AlertDialog>
</AlertDialog> </>
</> );
);
} }

View File

@@ -8,102 +8,95 @@ import { getSnapshotDetails } from "~/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: `Snapshot ${params.snapshotId}` }, { title: `Snapshot ${params.snapshotId}` },
{ {
name: "description", name: "description",
content: "Browse and restore files from a backup snapshot.", content: "Browse and restore files from a backup snapshot.",
}, },
]; ];
} }
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({ const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId }, path: { name: params.name, snapshotId: params.snapshotId },
}); });
if (snapshot.data) return snapshot.data; if (snapshot.data) return snapshot.data;
return redirect("/repositories"); return redirect("/repositories");
}; };
export default function SnapshotDetailsPage({ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
loaderData, const { name, snapshotId } = useParams<{
}: Route.ComponentProps) { name: string;
const { name, snapshotId } = useParams<{ snapshotId: string;
name: string; }>();
snapshotId: string;
}>();
const { data } = useQuery({ const { data } = useQuery({
...listSnapshotFilesOptions({ ...listSnapshotFilesOptions({
path: { name: name ?? "", snapshotId: snapshotId ?? "" }, path: { name: name ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" }, query: { path: "/" },
}), }),
enabled: !!name && !!snapshotId, enabled: !!name && !!snapshotId,
}); });
if (!name || !snapshotId) { if (!name || !snapshotId) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p> <p className="text-destructive">Invalid snapshot reference</p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">{name}</h1> <h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
Snapshot: {snapshotId} </div>
</p> <RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div> </div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} /> <SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
{data?.snapshot && ( {data?.snapshot && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Snapshot Information</CardTitle> <CardTitle>Snapshot Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<span className="text-muted-foreground">Snapshot ID:</span> <span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono break-all">{data.snapshot.id}</p> <p className="font-mono break-all">{data.snapshot.id}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Short ID:</span> <span className="text-muted-foreground">Short ID:</span>
<p className="font-mono break-al">{data.snapshot.short_id}</p> <p className="font-mono break-al">{data.snapshot.short_id}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Hostname:</span> <span className="text-muted-foreground">Hostname:</span>
<p>{data.snapshot.hostname}</p> <p>{data.snapshot.hostname}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Time:</span> <span className="text-muted-foreground">Time:</span>
<p>{new Date(data.snapshot.time).toLocaleString()}</p> <p>{new Date(data.snapshot.time).toLocaleString()}</p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<span className="text-muted-foreground">Paths:</span> <span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1"> <div className="space-y-1 mt-1">
{data.snapshot.paths.map((path) => ( {data.snapshot.paths.map((path) => (
<p <p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
key={path} {path}
className="font-mono text-xs bg-muted px-2 py-1 rounded" </p>
> ))}
{path} </div>
</p> </div>
))} </div>
</div> </CardContent>
</div> </Card>
</div> )}
</CardContent> </div>
</Card> );
)}
</div>
);
} }

View File

@@ -3,23 +3,23 @@ import { useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState } from "react"; import { useState } from "react";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
getVolumeOptions, getVolumeOptions,
getSystemInfoOptions, getSystemInfoOptions,
mountVolumeMutation, mountVolumeMutation,
unmountVolumeMutation, unmountVolumeMutation,
} from "~/api-client/@tanstack/react-query.gen"; } from "~/api-client/@tanstack/react-query.gen";
import { StatusDot } from "~/components/status-dot"; import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { VolumeIcon } from "~/components/volume-icon"; import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
@@ -31,169 +31,159 @@ import { FilesTabContent } from "../tabs/files";
import { DockerTabContent } from "../tabs/docker"; import { DockerTabContent } from "../tabs/docker";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: params.name },
{ {
name: "description", name: "description",
content: "View and manage volume details, configuration, and files.", content: "View and manage volume details, configuration, and files.",
}, },
]; ];
} }
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name ?? "" } }); const volume = await getVolume({ path: { name: params.name ?? "" } });
if (volume.data) return volume.data; if (volume.data) return volume.data;
}; };
export default function VolumeDetails({ loaderData }: Route.ComponentProps) { export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info"; const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({ const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }), ...getVolumeOptions({ path: { name: name ?? "" } }),
initialData: loaderData, initialData: loaderData,
refetchInterval: 10000, refetchInterval: 10000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const { data: systemInfo } = useQuery({ const { data: systemInfo } = useQuery({
...getSystemInfoOptions(), ...getSystemInfoOptions(),
}); });
const deleteVol = useMutation({ const deleteVol = useMutation({
...deleteVolumeMutation(), ...deleteVolumeMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Volume deleted successfully"); toast.success("Volume deleted successfully");
navigate("/volumes"); navigate("/volumes");
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to delete volume", { toast.error("Failed to delete volume", {
description: parseError(error)?.message, description: parseError(error)?.message,
}); });
}, },
}); });
const mountVol = useMutation({ const mountVol = useMutation({
...mountVolumeMutation(), ...mountVolumeMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Volume mounted successfully"); toast.success("Volume mounted successfully");
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to mount volume", { toast.error("Failed to mount volume", {
description: parseError(error)?.message, description: parseError(error)?.message,
}); });
}, },
}); });
const unmountVol = useMutation({ const unmountVol = useMutation({
...unmountVolumeMutation(), ...unmountVolumeMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Volume unmounted successfully"); toast.success("Volume unmounted successfully");
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to unmount volume", { toast.error("Failed to unmount volume", {
description: parseError(error)?.message, description: parseError(error)?.message,
}); });
}, },
}); });
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name: name ?? "" } }); deleteVol.mutate({ path: { name: name ?? "" } });
}; };
if (!name) { if (!name) {
return <div>Volume not found</div>; return <div>Volume not found</div>;
} }
if (!data) { if (!data) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
const { volume, statfs } = data; const { volume, statfs } = data;
const dockerAvailable = systemInfo?.capabilities?.docker ?? false; const dockerAvailable = systemInfo?.capabilities?.docker ?? false;
return ( return (
<> <>
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between"> <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"> <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"> <span className="flex items-center gap-2">
<StatusDot status={volume.status} />{" "} <StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
{volume.status[0].toUpperCase() + volume.status.slice(1)} </span>
</span> <VolumeIcon size={14} backend={volume?.config.backend} />
<VolumeIcon size={14} backend={volume?.config.backend} /> </div>
</div> <div className="flex gap-4">
<div className="flex gap-4"> <Button
<Button onClick={() => mountVol.mutate({ path: { name } })}
onClick={() => mountVol.mutate({ path: { name } })} loading={mountVol.isPending}
loading={mountVol.isPending} className={cn({ hidden: volume.status === "mounted" })}
className={cn({ hidden: volume.status === "mounted" })} >
> Mount
Mount </Button>
</Button> <Button
<Button variant="secondary"
variant="secondary" onClick={() => unmountVol.mutate({ path: { name } })}
onClick={() => unmountVol.mutate({ path: { name } })} loading={unmountVol.isPending}
loading={unmountVol.isPending} className={cn({ hidden: volume.status !== "mounted" })}
className={cn({ hidden: volume.status !== "mounted" })} >
> Unmount
Unmount </Button>
</Button> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
<Button Delete
variant="destructive" </Button>
onClick={() => setShowDeleteConfirm(true)} </div>
disabled={deleteVol.isPending} </div>
> <Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
Delete <TabsList className="mb-2">
</Button> <TabsTrigger value="info">Configuration</TabsTrigger>
</div> <TabsTrigger value="files">Files</TabsTrigger>
</div> {dockerAvailable && <TabsTrigger value="docker">Docker</TabsTrigger>}
<Tabs </TabsList>
value={activeTab} <TabsContent value="info">
onValueChange={(value) => setSearchParams({ tab: value })} <VolumeInfoTabContent volume={volume} statfs={statfs} />
className="mt-4" </TabsContent>
> <TabsContent value="files">
<TabsList className="mb-2"> <FilesTabContent volume={volume} />
<TabsTrigger value="info">Configuration</TabsTrigger> </TabsContent>
<TabsTrigger value="files">Files</TabsTrigger> {dockerAvailable && (
{dockerAvailable && <TabsTrigger value="docker">Docker</TabsTrigger>} <TabsContent value="docker">
</TabsList> <DockerTabContent volume={volume} />
<TabsContent value="info"> </TabsContent>
<VolumeInfoTabContent volume={volume} statfs={statfs} /> )}
</TabsContent> </Tabs>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
{dockerAvailable && (
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
)}
</Tabs>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle> <AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the volume <strong>{name}</strong> Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
? This action cannot be undone. </AlertDialogDescription>
</AlertDialogDescription> </AlertDialogHeader>
</AlertDialogHeader> <div className="flex gap-3 justify-end">
<div className="flex gap-3 justify-end"> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction
<AlertDialogAction onClick={handleConfirmDelete}
onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" >
> Delete volume
Delete volume </AlertDialogAction>
</AlertDialogAction> </div>
</div> </AlertDialogContent>
</AlertDialogContent> </AlertDialog>
</AlertDialog> </>
</> );
);
} }

View File

@@ -1,16 +1,5 @@
import { import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
MutationCache, import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
@@ -19,108 +8,89 @@ import { client } from "./api-client/client.gen";
import { useServerEvents } from "./hooks/use-server-events"; import { useServerEvents } from "./hooks/use-server-events";
client.setConfig({ client.setConfig({
baseUrl: "/", baseUrl: "/",
}); });
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
rel: "preconnect", rel: "preconnect",
href: "https://fonts.gstatic.com", href: "https://fonts.gstatic.com",
crossOrigin: "anonymous", crossOrigin: "anonymous",
}, },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap", href: "https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap",
}, },
]; ];
const queryClient = new QueryClient({ const queryClient = new QueryClient({
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(); queryClient.invalidateQueries();
}, },
onError: (error) => { onError: (error) => {
console.error("Mutation error:", error); console.error("Mutation error:", error);
queryClient.invalidateQueries(); queryClient.invalidateQueries();
}, },
}), }),
}); });
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
name="viewport" <link rel="icon" type="image/png" href="/images/favicon/favicon-96x96.png" sizes="96x96" />
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" <link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
/> <link rel="shortcut icon" href="/images/favicon/favicon.ico" />
<link <link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
rel="icon" <meta name="apple-mobile-web-app-title" content="Ironmount" />
type="image/png" <link rel="manifest" href="/images/favicon/site.webmanifest" />
href="/images/favicon/favicon-96x96.png" <Meta />
sizes="96x96" <Links />
/> </head>
<link <QueryClientProvider client={queryClient}>
rel="icon" <body className="dark">
type="image/svg+xml" {children}
href="/images/favicon/favicon.svg" <Toaster />
/> <ScrollRestoration />
<link rel="shortcut icon" href="/images/favicon/favicon.ico" /> <Scripts />
<link </body>
rel="apple-touch-icon" </QueryClientProvider>
sizes="180x180" </html>
href="/images/favicon/apple-touch-icon.png" );
/>
<meta name="apple-mobile-web-app-title" content="Ironmount" />
<link rel="manifest" href="/images/favicon/site.webmanifest" />
<Meta />
<Links />
</head>
<QueryClientProvider client={queryClient}>
<body className="dark">
{children}
<Toaster />
<ScrollRestoration />
<Scripts />
</body>
</QueryClientProvider>
</html>
);
} }
export default function App() { export default function App() {
useServerEvents(); useServerEvents();
return <Outlet />; return <Outlet />;
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"; let message = "Oops!";
let details = "An unexpected error occurred."; let details = "An unexpected error occurred.";
let stack: string | undefined; let stack: string | undefined;
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? "404" : "Error";
details = details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
error.status === 404 } else if (import.meta.env.DEV && error && error instanceof Error) {
? "The requested page could not be found." details = error.message;
: error.statusText || details; stack = error.stack;
} else if (import.meta.env.DEV && error && error instanceof Error) { }
details = error.message;
stack = error.stack;
}
return ( return (
<main className="pt-4 p-4 container mx-auto"> <main className="pt-4 p-4 container mx-auto">
<h1>{message}</h1> <h1>{message}</h1>
<p>{details}</p> <p>{details}</p>
{stack && ( {stack && (
<pre className="w-full p-4 overflow-x-auto"> <pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code> <code>{stack}</code>
</pre> </pre>
)} )}
</main> </main>
); );
} }

View File

@@ -7,28 +7,28 @@ const alias = {};
const { NODE_ENV } = process.env; const { NODE_ENV } = process.env;
if (NODE_ENV === "production") { if (NODE_ENV === "production") {
// @ts-expect-error // @ts-expect-error
alias["react-dom/server"] = "react-dom/server.node"; alias["react-dom/server"] = "react-dom/server.node";
} }
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
resolve: { resolve: {
alias, alias,
}, },
build: { build: {
outDir: "dist", outDir: "dist",
// sourcemap: true, // sourcemap: true,
}, },
server: { server: {
host: true, host: true,
port: 4097, port: 4097,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:4096", target: "http://localhost:4096",
changeOrigin: true, changeOrigin: true,
}, },
}, },
allowedHosts: true, allowedHosts: true,
}, },
}); });