From 3dd7d8f2d683a1559e9a5d17bb46cec7d6c6a5fd Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 29 Nov 2025 18:00:34 +0100 Subject: [PATCH] refactor: restore snapshot as a page --- app/client/components/path-selector.tsx | 1 - app/client/components/restore-form.tsx | 326 ++++++++++++++++++ .../components/restore-snapshot-dialog.tsx | 198 ----------- .../components/snapshot-file-browser.tsx | 93 +---- .../modules/backups/routes/backup-details.tsx | 2 +- .../backups/routes/restore-snapshot.tsx | 54 +++ .../repositories/routes/restore-snapshot.tsx | 45 +++ .../repositories/routes/snapshot-details.tsx | 49 +-- app/routes.ts | 2 + 9 files changed, 451 insertions(+), 319 deletions(-) create mode 100644 app/client/components/restore-form.tsx delete mode 100644 app/client/components/restore-snapshot-dialog.tsx create mode 100644 app/client/modules/backups/routes/restore-snapshot.tsx create mode 100644 app/client/modules/repositories/routes/restore-snapshot.tsx diff --git a/app/client/components/path-selector.tsx b/app/client/components/path-selector.tsx index bbd6292b..5d8132bc 100644 --- a/app/client/components/path-selector.tsx +++ b/app/client/components/path-selector.tsx @@ -28,7 +28,6 @@ export const PathSelector = ({ value, onChange }: Props) => { ); } - console.log("Rendering PathSelector with value:", value); return (
{value}
diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx new file mode 100644 index 00000000..9055de3d --- /dev/null +++ b/app/client/components/restore-form.tsx @@ -0,0 +1,326 @@ +import { useCallback, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react"; +import { Button } from "~/client/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { Checkbox } from "~/client/components/ui/checkbox"; +import { Input } from "~/client/components/ui/input"; +import { Label } from "~/client/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { PathSelector } from "~/client/components/path-selector"; +import { FileTree } from "~/client/components/file-tree"; +import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { useFileBrowser } from "~/client/hooks/use-file-browser"; +import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; +import type { Snapshot } from "~/client/lib/types"; + +type RestoreLocation = "original" | "custom"; + +interface RestoreFormProps { + snapshot: Snapshot; + repositoryName: string; + snapshotId: string; + /** Path to navigate to after cancel or successful restore */ + returnPath: string; +} + +export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; + + const [restoreLocation, setRestoreLocation] = useState("original"); + const [customTargetPath, setCustomTargetPath] = useState(""); + const [overwriteMode, setOverwriteMode] = useState("always"); + const [showAdvanced, setShowAdvanced] = useState(false); + const [excludeXattr, setExcludeXattr] = useState(""); + const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); + + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + + const { data: filesData, isLoading: filesLoading } = useQuery({ + ...listSnapshotFilesOptions({ + path: { name: repositoryName, snapshotId }, + query: { path: volumeBasePath }, + }), + enabled: !!repositoryName && !!snapshotId, + }); + + const stripBasePath = useCallback( + (path: string): string => { + if (!volumeBasePath) return path; + if (path === volumeBasePath) return "/"; + if (path.startsWith(`${volumeBasePath}/`)) { + const stripped = path.slice(volumeBasePath.length); + return stripped; + } + return path; + }, + [volumeBasePath], + ); + + const addBasePath = useCallback( + (displayPath: string): string => { + const vbp = volumeBasePath === "/" ? "" : volumeBasePath; + + if (!vbp) return displayPath; + if (displayPath === "/") return vbp; + return `${vbp}${displayPath}`; + }, + [volumeBasePath], + ); + + const fileBrowser = useFileBrowser({ + initialData: filesData, + isLoading: filesLoading, + fetchFolder: async (path) => { + return await queryClient.ensureQueryData( + listSnapshotFilesOptions({ + path: { name: repositoryName, snapshotId }, + query: { path }, + }), + ); + }, + prefetchFolder: (path) => { + queryClient.prefetchQuery( + listSnapshotFilesOptions({ + path: { name: repositoryName, snapshotId }, + query: { path }, + }), + ); + }, + pathTransform: { + strip: stripBasePath, + add: addBasePath, + }, + }); + + const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ + ...restoreSnapshotMutation(), + onSuccess: (data) => { + toast.success("Restore completed", { + description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`, + }); + navigate(returnPath); + }, + onError: (error) => { + toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); + }, + }); + + const handleRestore = useCallback(() => { + if (!repositoryName || !snapshotId) return; + + const excludeXattrArray = excludeXattr + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean); + + const isCustomLocation = restoreLocation === "custom"; + const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; + + const pathsArray = Array.from(selectedPaths); + const includePaths = pathsArray.map((path) => addBasePath(path)); + + restoreSnapshot({ + path: { name: repositoryName }, + body: { + snapshotId, + include: includePaths.length > 0 ? includePaths : undefined, + delete: deleteExtraFiles, + excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, + targetPath, + overwrite: overwriteMode, + }, + }); + }, [ + repositoryName, + snapshotId, + excludeXattr, + restoreLocation, + customTargetPath, + selectedPaths, + addBasePath, + deleteExtraFiles, + overwriteMode, + restoreSnapshot, + ]); + + const canRestore = restoreLocation === "original" || customTargetPath.trim(); + + return ( +
+
+
+

Restore Snapshot

+

+ {repositoryName} / {snapshotId} +

+
+
+ + +
+
+ +
+
+ + + Restore Location + Choose where to restore the files + + +
+ + +
+ {restoreLocation === "custom" && ( +
+ +

Files will be restored directly to this path

+
+ )} +
+
+ + + + Overwrite Mode + How to handle existing files + + + +

+ {overwriteMode === OVERWRITE_MODES.always && + "Existing files will always be replaced with the snapshot version."} + {overwriteMode === OVERWRITE_MODES.ifChanged && + "Files are only replaced if their content differs from the snapshot."} + {overwriteMode === OVERWRITE_MODES.ifNewer && + "Files are only replaced if the snapshot version has a newer modification time."} + {overwriteMode === OVERWRITE_MODES.never && + "Existing files will never be replaced, only missing files are restored."} +

+
+
+ + + setShowAdvanced(!showAdvanced)}> +
+ Advanced options + +
+
+ {showAdvanced && ( + +
+ + setExcludeXattr(e.target.value)} + /> +

+ Exclude specific extended attributes during restore (comma-separated) +

+
+
+ setDeleteExtraFiles(checked === true)} + /> + +
+
+ )} +
+
+ + + Select Files to Restore + + {selectedPaths.size > 0 + ? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected` + : "Select specific files or folders, or leave empty to restore everything"} + + + + {fileBrowser.isLoading && ( +
+

Loading files...

+
+ )} + + {fileBrowser.isEmpty && ( +
+ +

No files in this snapshot

+
+ )} + + {!fileBrowser.isLoading && !fileBrowser.isEmpty && ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/app/client/components/restore-snapshot-dialog.tsx b/app/client/components/restore-snapshot-dialog.tsx deleted file mode 100644 index 14ebb88d..00000000 --- a/app/client/components/restore-snapshot-dialog.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useCallback, useState } from "react"; -import { ChevronDown, FolderOpen, RotateCcw } from "lucide-react"; -import { Button } from "~/client/components/ui/button"; -import { Checkbox } from "~/client/components/ui/checkbox"; -import { Label } from "~/client/components/ui/label"; -import { Input } from "~/client/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "~/client/components/ui/alert-dialog"; -import { PathSelector } from "~/client/components/path-selector"; -import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; - -type RestoreLocation = "original" | "custom"; - -export interface RestoreSnapshotOptions { - include?: string[]; - delete?: boolean; - excludeXattr?: string[]; - targetPath?: string; - overwrite?: OverwriteMode; -} - -interface Props { - /** Number of selected items to restore (0 means restore everything) */ - selectedCount?: number; - /** Callback when restore is confirmed */ - onConfirm: (options: RestoreSnapshotOptions) => void; - /** Trigger element for the dialog */ - trigger: React.ReactNode; - /** Pre-selected paths to include in restore (already transformed) */ - includePaths?: string[]; -} - -export const RestoreSnapshotDialog = (props: Props) => { - const { selectedCount = 0, onConfirm, trigger, includePaths } = props; - - const [open, setOpen] = useState(false); - const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); - const [excludeXattr, setExcludeXattr] = useState(""); - const [restoreLocation, setRestoreLocation] = useState("original"); - const [customTargetPath, setCustomTargetPath] = useState(""); - const [overwriteMode, setOverwriteMode] = useState("always"); - - const handleConfirmRestore = useCallback(() => { - const excludeXattrArray = excludeXattr - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean); - - const isCustomLocation = restoreLocation === "custom"; - const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; - - onConfirm({ - include: includePaths && includePaths.length > 0 ? includePaths : undefined, - delete: deleteExtraFiles, - excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, - targetPath, - overwrite: overwriteMode, - }); - - setOpen(false); - }, [excludeXattr, restoreLocation, customTargetPath, includePaths, deleteExtraFiles, overwriteMode, onConfirm]); - - return ( - - {trigger} - - - Confirm Restore - - {selectedCount > 0 - ? `This will restore ${selectedCount} selected ${selectedCount === 1 ? "item" : "items"} from the snapshot.` - : "This will restore everything from the snapshot."} - - -
-
- -
- - -
- {restoreLocation === "custom" && ( -
- -

Files will be restored directly to this path

-
- )} -
- -
- - -

- {overwriteMode === OVERWRITE_MODES.always && - "Existing files will always be replaced with the snapshot version."} - {overwriteMode === OVERWRITE_MODES.ifChanged && - "Files are only replaced if their content differs from the snapshot."} - {overwriteMode === OVERWRITE_MODES.ifNewer && - "Files are only replaced if the snapshot version has a newer modification time."} - {overwriteMode === OVERWRITE_MODES.never && - "Existing files will never be replaced, only missing files are restored."} -

-
- -
- - - {showAdvanced && ( -
-
- - setExcludeXattr(e.target.value)} - /> -

- Exclude specific extended attributes during restore (comma-separated) -

-
-
- setDeleteExtraFiles(checked === true)} - /> - -
-
- )} -
-
- - Cancel - - Confirm Restore - - -
-
- ); -}; diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 6025a7b8..752ef71e 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,31 +1,27 @@ -import { useCallback, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { FileIcon } from "lucide-react"; +import { Link } from "react-router"; import { FileTree } from "~/client/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; -import { Button } from "~/client/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; -import { RestoreSnapshotDialog, type RestoreSnapshotOptions } from "~/client/components/restore-snapshot-dialog"; -import type { Snapshot, Volume } from "~/client/lib/types"; -import { toast } from "sonner"; -import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { Button, buttonVariants } from "~/client/components/ui/button"; +import type { Snapshot } from "~/client/lib/types"; +import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; interface Props { snapshot: Snapshot; repositoryName: string; - volume?: Volume; + /** If provided, restore link will use /backups/:backupId/:snapshotId/restore route */ + backupId?: string; onDeleteSnapshot?: (snapshotId: string) => void; isDeletingSnapshot?: boolean; } export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; - - const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; + const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props; const queryClient = useQueryClient(); - const [selectedPaths, setSelectedPaths] = useState>(new Set()); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; @@ -85,39 +81,6 @@ export const SnapshotFileBrowser = (props: Props) => { }, }); - const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ - ...restoreSnapshotMutation(), - onSuccess: (data) => { - toast.success("Restore completed", { - description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`, - }); - setSelectedPaths(new Set()); - }, - onError: (error) => { - toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); - }, - }); - - const handleConfirmRestore = useCallback( - (options: RestoreSnapshotOptions) => { - const pathsArray = Array.from(selectedPaths); - const includePaths = pathsArray.map((path) => addBasePath(path)); - - restoreSnapshot({ - path: { name: repositoryName }, - body: { - snapshotId: snapshot.short_id, - include: includePaths.length > 0 ? includePaths : undefined, - delete: options.delete, - excludeXattr: options.excludeXattr, - targetPath: options.targetPath, - overwrite: options.overwrite, - }, - }); - }, - [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot], - ); - return (
@@ -128,31 +91,16 @@ export const SnapshotFileBrowser = (props: Props) => { {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}
- {selectedPaths.size > 0 && ( - - - - - {isRestoring - ? "Restoring..." - : `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`} - - } - /> - - - {isReadOnly && ( - -

Volume is mounted as read-only.

-

Please remount with read-only disabled to restore files.

-
- )} -
- )} + + Restore + {onDeleteSnapshot && (
)} diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index c7c82889..09433c1b 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -240,7 +240,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon key={selectedSnapshot?.short_id} snapshot={selectedSnapshot} repositoryName={schedule.repository.name} - volume={schedule.volume} + backupId={schedule.id.toString()} onDeleteSnapshot={handleDeleteSnapshot} isDeletingSnapshot={deleteSnapshot.isPending} /> diff --git a/app/client/modules/backups/routes/restore-snapshot.tsx b/app/client/modules/backups/routes/restore-snapshot.tsx new file mode 100644 index 00000000..0a8ce526 --- /dev/null +++ b/app/client/modules/backups/routes/restore-snapshot.tsx @@ -0,0 +1,54 @@ +import { redirect } from "react-router"; +import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client"; +import { RestoreForm } from "~/client/components/restore-form"; +import type { Route } from "./+types/restore-snapshot"; + +export const handle = { + breadcrumb: (match: Route.MetaArgs) => [ + { label: "Backups", href: "/backups" }, + { label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` }, + { label: match.params.snapshotId }, + { label: "Restore" }, + ], +}; + +export function meta({ params }: Route.MetaArgs) { + return [ + { title: `Zerobyte - Restore Snapshot ${params.snapshotId}` }, + { + name: "description", + content: "Restore files from a backup snapshot.", + }, + ]; +} + +export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { + const schedule = await getBackupSchedule({ path: { scheduleId: params.id } }); + if (!schedule.data) return redirect("/backups"); + + const repositoryName = schedule.data.repository.name; + const snapshot = await getSnapshotDetails({ + path: { name: repositoryName, snapshotId: params.snapshotId }, + }); + if (!snapshot.data) return redirect(`/backups/${params.id}`); + + return { + snapshot: snapshot.data, + repositoryName, + snapshotId: params.snapshotId, + backupId: params.id, + }; +}; + +export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) { + const { snapshot, repositoryName, snapshotId, backupId } = loaderData; + + return ( + + ); +} diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx new file mode 100644 index 00000000..a1d70754 --- /dev/null +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -0,0 +1,45 @@ +import { redirect } from "react-router"; +import { getSnapshotDetails } from "~/client/api-client"; +import { RestoreForm } from "~/client/components/restore-form"; +import type { Route } from "./+types/restore-snapshot"; + +export const handle = { + breadcrumb: (match: Route.MetaArgs) => [ + { label: "Repositories", href: "/repositories" }, + { label: match.params.name, href: `/repositories/${match.params.name}` }, + { label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` }, + { label: "Restore" }, + ], +}; + +export function meta({ params }: Route.MetaArgs) { + return [ + { title: `Zerobyte - Restore Snapshot ${params.snapshotId}` }, + { + name: "description", + content: "Restore files from a backup snapshot.", + }, + ]; +} + +export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { + const snapshot = await getSnapshotDetails({ + path: { name: params.name, snapshotId: params.snapshotId }, + }); + if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId }; + + return redirect("/repositories"); +}; + +export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) { + const { snapshot, name, snapshotId } = loaderData; + + return ( + + ); +} diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 8f2c9f7f..51f4294e 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -1,13 +1,8 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { redirect, useParams } from "react-router"; -import { toast } from "sonner"; -import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; -import { Button } from "~/client/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; +import { Link, redirect, useParams } from "react-router"; +import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { buttonVariants } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; -import { - RestoreSnapshotDialog, - type RestoreSnapshotOptions, -} from "~/client/components/restore-snapshot-dialog"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { getSnapshotDetails } from "~/client/api-client"; import type { Route } from "./+types/snapshot-details"; @@ -53,34 +48,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps enabled: !!name && !!snapshotId, }); - const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ - ...restoreSnapshotMutation(), - onSuccess: (data) => { - toast.success("Restore completed", { - description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`, - }); - }, - onError: (error) => { - toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); - }, - }); - - const handleRestore = (options: RestoreSnapshotOptions) => { - if (!name || !snapshotId) return; - - restoreSnapshot({ - path: { name }, - body: { - snapshotId, - include: options.include, - delete: options.delete, - excludeXattr: options.excludeXattr, - targetPath: options.targetPath, - overwrite: options.overwrite, - }, - }); - }; - if (!name || !snapshotId) { return (
@@ -96,14 +63,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps

{name}

Snapshot: {snapshotId}

- - {isRestoring ? "Restoring..." : "Restore Snapshot"} - - } - />
diff --git a/app/routes.ts b/app/routes.ts index a7a2eb35..6880e2d5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -12,10 +12,12 @@ export default [ route("backups", "./client/modules/backups/routes/backups.tsx"), route("backups/create", "./client/modules/backups/routes/create-backup.tsx"), route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"), + route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"), route("repositories", "./client/modules/repositories/routes/repositories.tsx"), route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"), route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"), route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"), + route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"), route("notifications", "./client/modules/notifications/routes/notifications.tsx"), route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"), route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),