refactor: improve buttons and space with dropdowns (#738)

This commit is contained in:
Nico 2026-04-02 21:44:32 +02:00 committed by GitHub
parent 4bf1463406
commit e77723164b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 190 additions and 158 deletions

View file

@ -1,4 +1,4 @@
import { Check, Database, Eraser, HardDrive, Pencil, Play, Square, Trash2, X } from "lucide-react"; import { Check, ChevronDown, Database, Eraser, HardDrive, Pencil, Play, Square, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -12,6 +12,13 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import type { BackupSchedule } from "~/client/lib/types"; import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card"; import { BackupProgressCard } from "./backup-progress-card";
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
@ -46,9 +53,6 @@ export const ScheduleSummary = (props: Props) => {
const runForget = useMutation({ const runForget = useMutation({
...runForgetMutation(), ...runForgetMutation(),
onSuccess: () => {
toast.success("Retention policy applied successfully");
},
onError: (error) => { onError: (error) => {
handleRepositoryError("Failed to apply retention policy", error, schedule.repository.shortId); handleRepositoryError("Failed to apply retention policy", error, schedule.repository.shortId);
}, },
@ -83,7 +87,10 @@ export const ScheduleSummary = (props: Props) => {
const handleConfirmForget = () => { const handleConfirmForget = () => {
setShowForgetConfirm(false); setShowForgetConfirm(false);
runForget.mutate({ path: { shortId: schedule.shortId } }); toast.promise(runForget.mutateAsync({ path: { shortId: schedule.shortId } }), {
loading: "Running cleanup...",
success: "Retention policy applied successfully",
});
}; };
const handleConfirmStop = () => { const handleConfirmStop = () => {
@ -132,53 +139,45 @@ export const ScheduleSummary = (props: Props) => {
/> />
</div> </div>
</div> </div>
<div className="flex flex-col @wide:flex-row gap-2"> <div className="flex items-center gap-2">
{schedule.lastBackupStatus === "in_progress" ? ( {schedule.lastBackupStatus === "in_progress" ? (
<Button <Button variant="destructive" size="sm" onClick={() => setShowStopConfirm(true)}>
variant="destructive"
size="sm"
onClick={() => setShowStopConfirm(true)}
className="w-full @medium:w-auto"
>
<Square className="h-4 w-4 mr-2" /> <Square className="h-4 w-4 mr-2" />
<span>Stop backup</span> <span>Stop backup</span>
</Button> </Button>
) : ( ) : (
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full @medium:w-auto"> <Button variant="default" size="sm" onClick={handleRunBackupNow}>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
<span>Backup now</span> <span>Backup now</span>
</Button> </Button>
)} )}
{schedule.retentionPolicy && ( <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="outline" <Button variant="outline" size="sm">
size="sm" Actions
loading={runForget.isPending} <ChevronDown className="h-4 w-4 ml-1" />
onClick={() => setShowForgetConfirm(true)} </Button>
className="w-full @medium:w-auto" </DropdownMenuTrigger>
> <DropdownMenuContent align="end">
<Eraser className="h-4 w-4 mr-2" /> {schedule.retentionPolicy && (
<span>Run cleanup</span> <DropdownMenuItem onClick={() => setShowForgetConfirm(true)} disabled={runForget.isPending}>
</Button> <Eraser />
)} Run cleanup
<Button </DropdownMenuItem>
variant="outline" )}
size="sm" <DropdownMenuItem
onClick={() => navigate({ to: "/backups/$backupId/edit", params: { backupId: schedule.shortId } })} onClick={() => navigate({ to: "/backups/$backupId/edit", params: { backupId: schedule.shortId } })}
className="w-full @medium:w-auto" >
> <Pencil />
<Pencil className="h-4 w-4 mr-2" /> Edit schedule
<span>Edit schedule</span> </DropdownMenuItem>
</Button> <DropdownMenuSeparator />
<Button <DropdownMenuItem variant="destructive" onClick={() => setShowDeleteConfirm(true)}>
variant="outline" <Trash2 />
size="sm" Delete
onClick={() => setShowDeleteConfirm(true)} </DropdownMenuItem>
className="text-destructive hover:text-destructive w-full @medium:w-auto" </DropdownMenuContent>
> </DropdownMenu>
<Trash2 className="h-4 w-4 mr-2" />
<span>Delete</span>
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 grid-cols-1 @medium:grid-cols-2 @wide:grid-cols-4"> <CardContent className="grid gap-4 grid-cols-1 @medium:grid-cols-2 @wide:grid-cols-4">

View file

@ -18,10 +18,16 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, Save, TestTube2, Trash2 } from "lucide-react"; import { Bell, ChevronDown, Save, TestTube2, Trash2 } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
@ -105,7 +111,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
</span> </span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span> <span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={handleTest} onClick={handleTest}
disabled={testDestination.isPending || !data.enabled} disabled={testDestination.isPending || !data.enabled}
@ -115,14 +121,24 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<TestTube2 className="h-4 w-4 mr-2" /> <TestTube2 className="h-4 w-4 mr-2" />
Test Test
</Button> </Button>
<Button <DropdownMenu>
onClick={() => setShowDeleteConfirm(true)} <DropdownMenuTrigger asChild>
variant="destructive" <Button variant="outline">
loading={deleteDestination.isPending} Actions
> <ChevronDown className="h-4 w-4 ml-1" />
<Trash2 className="h-4 w-4 mr-2" /> </Button>
Delete </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteDestination.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@ import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { StatusDot } from "~/client/components/status-dot";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
@ -143,17 +144,14 @@ export function RepositoriesPage() {
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<span <StatusDot
className={cn( variant={
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", repository.status === "healthy" ? "success" : repository.status === "error" ? "error" : "warning"
{ }
"bg-success/10 text-success": repository.status === "healthy", label={
"bg-red-500/10 text-red-500": repository.status === "error", repository.status ? repository.status[0].toUpperCase() + repository.status.slice(1) : "Unknown"
}, }
)} />
>
{repository.status || "unknown"}
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View file

@ -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 { Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react"; import { ChevronDown, Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { import {
@ -13,6 +13,13 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen"; import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { import {
@ -84,14 +91,6 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
const unlockRepo = useMutation({ const unlockRepo = useMutation({
...unlockRepositoryMutation(), ...unlockRepositoryMutation(),
onSuccess: () => {
toast.success("Repository unlocked successfully");
},
onError: (error) => {
toast.error("Failed to unlock repository", {
description: parseError(error)?.message,
});
},
}); });
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
@ -117,53 +116,64 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
{repository.provisioningId && <ManagedBadge />} {repository.provisioningId && <ManagedBadge />}
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2">
<Button {isDoctorRunning ? (
type="button" <Button
variant="outline" type="button"
onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })} variant="destructive"
> loading={cancelDoctor.isPending}
<Pencil className="h-4 w-4 mr-2" /> onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
Edit >
</Button> <Square className="h-4 w-4 mr-2" />
<Button Cancel doctor
type="button" </Button>
variant="destructive" ) : (
className={cn({ hidden: !isDoctorRunning })} <Button
loading={cancelDoctor.isPending} type="button"
onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })} variant="outline"
> onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })}
<Square className="h-4 w-4 mr-2" /> disabled={startDoctor.isPending}
<span>Cancel doctor</span> >
</Button> <Stethoscope className="h-4 w-4 mr-2" />
<Button Run doctor
type="button" </Button>
variant="outline" )}
className={cn({ hidden: isDoctorRunning })} <DropdownMenu>
onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })} <DropdownMenuTrigger asChild>
disabled={startDoctor.isPending} <Button variant="outline">
> Actions
<Stethoscope className="h-4 w-4 mr-2" /> <ChevronDown className="h-4 w-4 ml-1" />
Run doctor </Button>
</Button> </DropdownMenuTrigger>
<Button <DropdownMenuContent align="end">
type="button" <DropdownMenuItem onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}>
variant="outline" <Pencil />
onClick={() => unlockRepo.mutate({ path: { shortId: repository.shortId } })} Edit
loading={unlockRepo.isPending} </DropdownMenuItem>
> <DropdownMenuItem
<Unlock className="h-4 w-4 mr-2" /> onClick={() =>
Unlock toast.promise(unlockRepo.mutateAsync({ path: { shortId: repository.shortId } }), {
</Button> loading: "Unlocking repo",
<Button success: "Repository unlocked successfully",
type="button" error: (e) => parseError(e)?.message || "Failed to unlock repository",
variant="destructive" })
onClick={() => setShowDeleteConfirm(true)} }
disabled={deleteRepo.isPending} disabled={unlockRepo.isPending}
> >
<Trash2 className="h-4 w-4 mr-2" /> <Unlock />
Delete Unlock
</Button> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>

View file

@ -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 { Pencil, Plug, Trash2, Unplug } from "lucide-react"; import { ChevronDown, Pencil, Plug, Trash2, Unplug } from "lucide-react";
import { CreateVolumeForm } from "~/client/modules/volumes/components/create-volume-form"; import { CreateVolumeForm } from "~/client/modules/volumes/components/create-volume-form";
import { import {
AlertDialog, AlertDialog,
@ -15,6 +15,13 @@ import {
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/client/components/ui/dropdown-menu";
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";
@ -37,26 +44,10 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const mountVol = useMutation({ const mountVol = useMutation({
...mountVolumeMutation(), ...mountVolumeMutation(),
onSuccess: () => {
toast.success("Volume mounted successfully");
},
onError: (error) => {
toast.error("Failed to mount volume", {
description: parseError(error)?.message,
});
},
}); });
const unmountVol = useMutation({ const unmountVol = useMutation({
...unmountVolumeMutation(), ...unmountVolumeMutation(),
onSuccess: () => {
toast.success("Volume unmounted successfully");
},
onError: (error) => {
toast.error("Failed to unmount volume", {
description: parseError(error)?.message,
});
},
}); });
const deleteVol = useMutation({ const deleteVol = useMutation({
@ -89,23 +80,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4 mb-6"> <div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4 mb-6">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg font-semibold">Volume Configuration</span> <h2 className="text-lg font-semibold tracking-tight">Volume Configuration</h2>
{volume.provisioningId && <ManagedBadge />} {volume.provisioningId && <ManagedBadge />}
</div> </div>
</div> </div>
<div className="flex flex-col @xl:flex-row w-full @xl:w-auto gap-2"> <div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: `/volumes/${volume.shortId}/edit` })}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
{volume.status !== "mounted" ? ( {volume.status !== "mounted" ? (
<Button <Button
type="button" type="button"
onClick={() => mountVol.mutate({ path: { shortId: volume.shortId } })} onClick={() =>
toast.promise(mountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Mounting volume...",
success: "Volume mounted successfully",
error: (error) => parseError(error)?.message || "Failed to mount volume",
})
}
loading={mountVol.isPending} loading={mountVol.isPending}
> >
<Plug className="h-4 w-4 mr-2" /> <Plug className="h-4 w-4 mr-2" />
@ -115,22 +104,42 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => unmountVol.mutate({ path: { shortId: volume.shortId } })} onClick={() =>
toast.promise(unmountVol.mutateAsync({ path: { shortId: volume.shortId } }), {
loading: "Unmounting volume...",
success: "Volume unmounted successfully",
error: (error) => parseError(error)?.message || "Failed to unmount volume",
})
}
loading={unmountVol.isPending} loading={unmountVol.isPending}
> >
<Unplug className="h-4 w-4 mr-2" /> <Unplug className="h-4 w-4 mr-2" />
Unmount Unmount
</Button> </Button>
)} )}
<Button <DropdownMenu>
type="button" <DropdownMenuTrigger asChild>
variant="destructive" <Button variant="outline">
onClick={() => setShowDeleteConfirm(true)} Actions
disabled={deleteVol.isPending} <ChevronDown className="h-4 w-4 ml-1" />
> </Button>
<Trash2 className="h-4 w-4 mr-2" /> </DropdownMenuTrigger>
Delete <DropdownMenuContent align="end">
</Button> <DropdownMenuItem onClick={() => navigate({ to: `/volumes/${volume.shortId}/edit` })}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteVol.isPending}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<CreateVolumeForm <CreateVolumeForm