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