From d3ce3593b85b4d53f72f2212ed340893d8bcfc5e Mon Sep 17 00:00:00 2001 From: AlliotTech <24980252+AlliotTech@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:58:07 +0800 Subject: [PATCH] feat: implement RequestState and RequestSkeleton components; refactor device and settings pages to use new components --- app/(dashboard)/page.tsx | 63 ++++++---------- app/device/device-detail-client.tsx | 79 +++++++++------------ app/settings/page.tsx | 21 +++--- components/dashboard/device-card.tsx | 9 ++- components/dashboard/device-list-mobile.tsx | 53 ++++++-------- components/dashboard/device-list.tsx | 60 +++++----------- components/device/device-summary.tsx | 5 +- components/ui/confirm-action-dialog.tsx | 52 ++++++++++++++ components/ui/request-skeleton.tsx | 19 +++++ components/ui/request-state.tsx | 27 +++++++ lib/format.ts | 6 ++ next-env.d.ts | 2 +- 12 files changed, 217 insertions(+), 179 deletions(-) create mode 100644 components/ui/confirm-action-dialog.tsx create mode 100644 components/ui/request-skeleton.tsx create mode 100644 components/ui/request-state.tsx diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index cd59088..43c4665 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -6,15 +6,16 @@ import { RefreshCcw } from "lucide-react"; import { SummaryCards } from "@/components/dashboard/summary-cards"; import { DeviceList } from "@/components/dashboard/device-list"; import { DeviceListMobile } from "@/components/dashboard/device-list-mobile"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Skeleton } from "@/components/ui/skeleton"; import { useI18n } from "@/lib/i18n"; import { useSettings, useSummary, useSummaryTemp } from "@/lib/hooks"; import { toast } from "sonner"; import { DurationKey } from "@/lib/constants"; import { TempChartSection } from "@/components/dashboard/temp-chart-section"; +import { RequestState } from "@/components/ui/request-state"; +import { RequestSkeleton } from "@/components/ui/request-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; export default function DashboardPage() { const { t } = useI18n(); @@ -49,30 +50,21 @@ export default function DashboardPage() { if (summary.error) { return ( - - - {t("dashboard.title")} - - -

{String(summary.error)}

- -
-
+ ); } if (!summary.data) { return ( -
- -
- - - - -
- -
+ ); } @@ -84,26 +76,15 @@ export default function DashboardPage() {

{t("dashboard.title")}

{t("dashboard.subtitle")}

- - - {t("dashboard.empty.title")} - - -

{t("dashboard.empty.body")}

-
- {t("dashboard.empty.command")} -
- -
-
+ +
+ {t("dashboard.empty.command")} +
); } diff --git a/app/device/device-detail-client.tsx b/app/device/device-detail-client.tsx index e38d0e4..afd7e4f 100644 --- a/app/device/device-detail-client.tsx +++ b/app/device/device-detail-client.tsx @@ -11,20 +11,15 @@ import { AttributeTable } from "@/components/device/attribute-table"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useI18n } from "@/lib/i18n"; import { useDeviceDetails, useSettings } from "@/lib/hooks"; import { deviceTitleWithFallback } from "@/lib/format"; import { archiveDevice, deleteDevice, unarchiveDevice } from "@/lib/api"; import { DurationKey, DURATION_KEYS } from "@/lib/constants"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; +import { RequestState } from "@/components/ui/request-state"; +import { RequestSkeleton } from "@/components/ui/request-skeleton"; const SmartChart = dynamic(() => import("@/components/device/smart-chart").then((mod) => mod.SmartChart), { ssr: false, @@ -96,22 +91,18 @@ export function DeviceDetailClient({ wwn }: DeviceDetailClientProps) { if (details.error) { return ( - - -

{String(details.error)}

- -
-
+ details.mutate()} + /> ); } if (!details.data) { return ( -
- - - -
+ ); } @@ -196,34 +187,28 @@ export function DeviceDetailClient({ wwn }: DeviceDetailClientProps) { settings={settings.data} /> - !open && setConfirmState(null)}> - - - - {confirmState === "delete" - ? t("device.actions.delete") - : confirmState === "archive" - ? t("device.actions.archive") - : t("device.actions.unarchive")} - - - {confirmState === "delete" - ? t("device.dialog.delete") - : confirmState === "archive" - ? t("device.dialog.archive") - : t("device.dialog.unarchive")} - - - - - - - - + setConfirmState(null)} + /> ); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1d7e1a4..f770669 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -7,8 +7,10 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Skeleton } from "@/components/ui/skeleton"; import { InfoTip } from "@/components/ui/info-tip"; +import { RequestState } from "@/components/ui/request-state"; +import { RequestSkeleton } from "@/components/ui/request-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; import { useI18n } from "@/lib/i18n"; import { useHealth, useSettings } from "@/lib/hooks"; import { saveSettings, sendTestNotification } from "@/lib/api"; @@ -114,21 +116,18 @@ export default function SettingsPage() { if (settings.error) { return ( - - -

{String(settings.error)}

- -
-
+ settings.mutate()} + /> ); } if (!draft) { return ( -
- - -
+ ); } diff --git a/components/dashboard/device-card.tsx b/components/dashboard/device-card.tsx index 76414d9..ba90d37 100644 --- a/components/dashboard/device-card.tsx +++ b/components/dashboard/device-card.tsx @@ -18,6 +18,7 @@ import { formatDateTime, formatPowerOnHours, formatTemperature, + statusBarClass, summaryAgeClass, } from "@/lib/format"; import { AppConfig, DeviceSummaryModel, MetricsStatusThreshold } from "@/lib/types"; @@ -42,17 +43,20 @@ export function DeviceCard({ deviceSummary, settings, threshold, t, variant, onA ProtocolIcon, protocol, statusLabel, + status, title, } = getDeviceCardData(deviceSummary, settings, threshold, t); const temperatureUnit = settings?.temperature_unit ?? "celsius"; + const statusBar = statusBarClass(status); if (variant === "mobile") { const handleNavigate = () => onNavigate(deviceHref(deviceSummary.device.wwn)); return (
+
@@ -136,7 +140,8 @@ export function DeviceCard({ deviceSummary, settings, threshold, t, variant, onA } return ( - + +
{ if (!confirmState) return; @@ -89,28 +82,22 @@ export function DeviceListMobile({ summary, settings, showArchived, onAction }:
))} - setConfirmState(null)}> - - - {confirmState?.label} - - {confirmState?.action === "delete" - ? t("device.actions.delete_warning") - : confirmState?.action === "archive" - ? t("device.actions.archive_confirm") - : t("device.actions.unarchive_confirm")} - - - - - - - - + setConfirmState(null)} + />
); } diff --git a/components/dashboard/device-list.tsx b/components/dashboard/device-list.tsx index 35fc0ec..c36e945 100644 --- a/components/dashboard/device-list.tsx +++ b/components/dashboard/device-list.tsx @@ -3,15 +3,7 @@ import { toast } from "sonner"; import React from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { useI18n } from "@/lib/i18n"; import { performDeviceAction } from "@/lib/device-actions"; import { AppConfig, DeviceSummaryModel, MetricsStatusThreshold } from "@/lib/types"; @@ -91,40 +83,22 @@ export function DeviceList({ summary, settings, showArchived, onAction }: Device
))} - setConfirmState(null)}> - event.stopPropagation()}> - - {confirmState?.label} - - {confirmState?.action === "delete" - ? t("device.actions.delete_warning") - : confirmState?.action === "archive" - ? t("device.actions.archive_confirm") - : t("device.actions.unarchive_confirm")} - - - - - - - - + setConfirmState(null)} + />
); } diff --git a/components/device/device-summary.tsx b/components/device/device-summary.tsx index cd4447f..92f5f61 100644 --- a/components/device/device-summary.tsx +++ b/components/device/device-summary.tsx @@ -12,6 +12,7 @@ import { formatDateTime, formatPowerOnHours, formatTemperature, + statusBarClass, } from "@/lib/format"; interface DeviceSummaryProps { @@ -38,13 +39,15 @@ export function DeviceSummary({ device, smart, settings }: DeviceSummaryProps) { ); const statusLabel = t(`status.${status.replace(": ", "_")}`); const pillStatus = status === "passed" ? "passed" : status.startsWith("failed") ? "failed" : "unknown"; + const statusBar = statusBarClass(status); const isAta = device.device_protocol?.toUpperCase() === "ATA"; const protocol = device.device_protocol?.toUpperCase() ?? ""; const ProtocolIcon = protocol === "ATA" ? HardDrive : protocol === "NVME" ? Cpu : protocol === "SCSI" ? Server : HelpCircle; return ( - + +
diff --git a/components/ui/confirm-action-dialog.tsx b/components/ui/confirm-action-dialog.tsx new file mode 100644 index 0000000..8f745b3 --- /dev/null +++ b/components/ui/confirm-action-dialog.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ConfirmActionDialogProps { + open: boolean; + title: string; + description: string; + confirmLabel: string; + cancelLabel: string; + confirmVariant?: "default" | "destructive"; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmActionDialog({ + open, + title, + description, + confirmLabel, + cancelLabel, + confirmVariant = "default", + onConfirm, + onCancel, +}: ConfirmActionDialogProps) { + return ( + !next && onCancel()}> + + + {title} + {description} + + + + + + + + ); +} diff --git a/components/ui/request-skeleton.tsx b/components/ui/request-skeleton.tsx new file mode 100644 index 0000000..169abcc --- /dev/null +++ b/components/ui/request-skeleton.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; + +interface RequestSkeletonProps { + titleClassName?: string; + blocks: string[]; +} + +export function RequestSkeleton({ titleClassName = "h-8 w-1/3", blocks }: RequestSkeletonProps) { + return ( +
+ + {blocks.map((className, index) => ( + + ))} +
+ ); +} diff --git a/components/ui/request-state.tsx b/components/ui/request-state.tsx new file mode 100644 index 0000000..b1b6d3a --- /dev/null +++ b/components/ui/request-state.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +interface RequestStateProps { + title: string; + message: string; + actionLabel?: string; + onAction?: () => void; +} + +export function RequestState({ title, message, actionLabel, onAction }: RequestStateProps) { + return ( + + + {title} + + +

{message}

+ {actionLabel && onAction ? ( + + ) : null} +
+
+ ); +} diff --git a/lib/format.ts b/lib/format.ts index 58233fd..ab68461 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -208,3 +208,9 @@ export function deviceTitleWithFallback(device: DeviceModel, titleType: Dashboar if (preferred) titleParts.push(preferred); return titleParts.join(" - "); } + +export function statusBarClass(status: string) { + if (status === "passed") return "bg-emerald-500"; + if (status.startsWith("failed")) return "bg-rose-500"; + return "bg-amber-500"; +} diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.