diff --git a/app/client/modules/backups/components/sortable-backup-card.tsx b/app/client/modules/backups/components/sortable-backup-card.tsx
new file mode 100644
index 00000000..1b9fbfd7
--- /dev/null
+++ b/app/client/modules/backups/components/sortable-backup-card.tsx
@@ -0,0 +1,89 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { CalendarClock, Database, GripVertical, HardDrive } from "lucide-react";
+import { Link } from "react-router";
+import { BackupStatusDot } from "./backup-status-dot";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
+import type { ListBackupSchedulesResponse } from "~/client/api-client";
+
+type Schedule = ListBackupSchedulesResponse[number];
+
+interface SortableBackupCardProps {
+ schedule: Schedule;
+ isDragging?: boolean;
+}
+
+/**
+ * Render a draggable backup schedule card that displays name, status, volume/repository and schedule metadata.
+ *
+ * @param schedule - The backup schedule object to display (name, id, cronExpression, timestamps, volume and repository info, enabled/status fields).
+ * @param isDragging - Optional flag indicating the card is currently being dragged; used to adjust visual appearance.
+ * @returns The JSX element for the sortable backup schedule card.
+ */
+export function SortableBackupCard({ schedule, isDragging }: SortableBackupCardProps) {
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
+ id: schedule.id,
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {schedule.name}
+
+
+
+
+
+ {schedule.volume.name}
+ →
+
+ {schedule.repository.name}
+
+
+
+
+
+ Schedule
+ {schedule.cronExpression}
+
+
+ Last backup
+
+ {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
+
+
+
+ Next backup
+
+ {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx
index 651c86f4..cf0b704b 100644
--- a/app/client/modules/backups/routes/backups.tsx
+++ b/app/client/modules/backups/routes/backups.tsx
@@ -1,13 +1,46 @@
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { Link } from "react-router";
+import { useState, useEffect } from "react";
+import { SortableBackupCard } from "../components/sortable-backup-card";
import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/client/components/empty-state";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client";
-import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
+import { listBackupSchedulesOptions, listBackupSchedulesQueryKey } from "~/client/api-client/@tanstack/react-query.gen";
+
+/**
+ * Send a new ordering of backup schedule IDs to the server.
+ *
+ * @param scheduleIds - Array of schedule IDs in the desired order (first element becomes first).
+ * @returns The parsed JSON response from the server.
+ * @throws Error if the server responds with a non-OK status.
+ */
+async function reorderBackupSchedules(scheduleIds: number[]) {
+ const response = await fetch("/api/v1/backups/reorder", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ scheduleIds }),
+ });
+ if (!response.ok) {
+ throw new Error("Failed to reorder backup schedules");
+ }
+ return response.json();
+}
export const handle = {
breadcrumb: () => [{ label: "Backups" }],
@@ -29,12 +62,71 @@ export const clientLoader = async () => {
return [];
};
+/**
+ * Renders the Backups page with a draggable, re-orderable grid of backup schedule cards and a tile to create a new backup job.
+ *
+ * The component fetches backup schedules (using `loaderData` as initial data), keeps a local order state synchronized with fetched schedules, and persists reorder operations to the server. While loading it shows a loading message, and when there are no schedules it shows an empty state with a create button.
+ *
+ * @param loaderData - Initial list of backup schedules provided by the route loader; used as the query's initial data and to initialize the local item order
+ * @returns The page UI for listing and reordering backup schedules
+ */
export default function Backups({ loaderData }: Route.ComponentProps) {
+ const queryClient = useQueryClient();
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
initialData: loaderData,
});
+ const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []);
+
+ // Keep items in sync with schedules
+ useEffect(() => {
+ if (schedules) {
+ setItems(schedules.map((s) => s.id));
+ }
+ }, [schedules]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const reorderMutation = useMutation({
+ mutationFn: async (scheduleIds: number[]) => {
+ await reorderBackupSchedules(scheduleIds);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: listBackupSchedulesQueryKey() });
+ },
+ onError: () => {
+ // Revert the order or display error to user
+ queryClient.invalidateQueries({ queryKey: listBackupSchedulesQueryKey() });
+ },
+ });
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ setItems((items) => {
+ const oldIndex = items.indexOf(active.id as number);
+ const newIndex = items.indexOf(over.id as number);
+ const newItems = arrayMove(items, oldIndex, newIndex);
+
+ // Save the new order
+ reorderMutation.mutate(newItems);
+
+ return newItems;
+ });
+ }
+ };
+
if (isLoading) {
return (
@@ -61,64 +153,30 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
);
}
+ // Create a map for quick lookup
+ const scheduleMap = new Map(schedules.map((s) => [s.id, s]));
+
return (
-
- {schedules.map((schedule) => (
-
-
-
-
-
-
- {schedule.name}
-
-
-
-
-
- {schedule.volume.name}
- →
-
- {schedule.repository.name}
-
-
-
-
-
- Schedule
- {schedule.cronExpression}
-
-
- Last backup
-
- {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
-
-
-
- Next backup
-
- {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
-
-
-
-
-
-
- ))}
-
-
-
-
- Create a backup job
-
-
-
-
+
+
+
+ {items.map((id) => {
+ const schedule = scheduleMap.get(id);
+ if (!schedule) return null;
+ return
;
+ })}
+
+
+
+
+ Create a backup job
+
+
+
+
+
+
);
-}
+}
\ No newline at end of file