From 4d5a4842b94dded4dbefbf315e3da0d2ec3bdec5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:09:14 +0100 Subject: [PATCH] feat: add shared admin UI components (StatusBadge, RefreshableCard, ConfirmDeleteDialog) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/ConfirmDeleteDialog.module.css | 103 ++++++++++++++++++ .../components/admin/ConfirmDeleteDialog.tsx | 70 ++++++++++++ .../admin/RefreshableCard.module.css | 96 ++++++++++++++++ ui/src/components/admin/RefreshableCard.tsx | 70 ++++++++++++ .../components/admin/StatusBadge.module.css | 34 ++++++ ui/src/components/admin/StatusBadge.tsx | 17 +++ 6 files changed, 390 insertions(+) create mode 100644 ui/src/components/admin/ConfirmDeleteDialog.module.css create mode 100644 ui/src/components/admin/ConfirmDeleteDialog.tsx create mode 100644 ui/src/components/admin/RefreshableCard.module.css create mode 100644 ui/src/components/admin/RefreshableCard.tsx create mode 100644 ui/src/components/admin/StatusBadge.module.css create mode 100644 ui/src/components/admin/StatusBadge.tsx diff --git a/ui/src/components/admin/ConfirmDeleteDialog.module.css b/ui/src/components/admin/ConfirmDeleteDialog.module.css new file mode 100644 index 00000000..481aea74 --- /dev/null +++ b/ui/src/components/admin/ConfirmDeleteDialog.module.css @@ -0,0 +1,103 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.dialog { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 24px; + width: 420px; + max-width: 90vw; +} + +.title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 12px; +} + +.message { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; + line-height: 1.5; +} + +.label { + display: block; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.input { + width: 100%; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.input:focus { + border-color: var(--amber-dim); + box-shadow: 0 0 0 3px var(--amber-glow); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.btnCancel { + padding: 8px 20px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-family: var(--font-body); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} + +.btnCancel:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.btnDelete { + padding: 8px 20px; + border-radius: var(--radius-sm); + background: transparent; + border: 1px solid var(--rose-dim); + color: var(--rose); + font-family: var(--font-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btnDelete:hover:not(:disabled) { + background: var(--rose-glow); +} + +.btnDelete:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/ui/src/components/admin/ConfirmDeleteDialog.tsx b/ui/src/components/admin/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..f81d2d92 --- /dev/null +++ b/ui/src/components/admin/ConfirmDeleteDialog.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import styles from './ConfirmDeleteDialog.module.css'; + +interface ConfirmDeleteDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + resourceName: string; + resourceType: string; +} + +export function ConfirmDeleteDialog({ + isOpen, + onClose, + onConfirm, + resourceName, + resourceType, +}: ConfirmDeleteDialogProps) { + const [confirmText, setConfirmText] = useState(''); + + if (!isOpen) return null; + + const canDelete = confirmText === resourceName; + + function handleClose() { + setConfirmText(''); + onClose(); + } + + function handleConfirm() { + if (!canDelete) return; + setConfirmText(''); + onConfirm(); + } + + return ( +
+
e.stopPropagation()}> +

Confirm Deletion

+

+ Delete {resourceType} ‘{resourceName}’? This cannot be undone. +

+ + setConfirmText(e.target.value)} + placeholder={resourceName} + autoFocus + /> +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/admin/RefreshableCard.module.css b/ui/src/components/admin/RefreshableCard.module.css new file mode 100644 index 00000000..15bf02a9 --- /dev/null +++ b/ui/src/components/admin/RefreshableCard.module.css @@ -0,0 +1,96 @@ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-bottom: 16px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.headerClickable { + cursor: pointer; + user-select: none; +} + +.headerClickable:hover { + background: var(--bg-hover); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.titleRow { + display: flex; + align-items: center; + gap: 8px; +} + +.chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.autoIndicator { + font-size: 10px; + color: var(--text-muted); + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 99px; + padding: 1px 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.refreshBtn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 16px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; +} + +.refreshBtn:hover { + border-color: var(--amber-dim); + color: var(--text-primary); +} + +.refreshBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.refreshing { + animation: spin 1s linear infinite; +} + +.body { + padding: 20px; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/ui/src/components/admin/RefreshableCard.tsx b/ui/src/components/admin/RefreshableCard.tsx new file mode 100644 index 00000000..10e9ba80 --- /dev/null +++ b/ui/src/components/admin/RefreshableCard.tsx @@ -0,0 +1,70 @@ +import { type ReactNode, useState } from 'react'; +import styles from './RefreshableCard.module.css'; + +interface RefreshableCardProps { + title: string; + onRefresh?: () => void; + isRefreshing?: boolean; + autoRefresh?: boolean; + collapsible?: boolean; + defaultCollapsed?: boolean; + children: ReactNode; +} + +export function RefreshableCard({ + title, + onRefresh, + isRefreshing, + autoRefresh, + collapsible, + defaultCollapsed, + children, +}: RefreshableCardProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); + + const headerProps = collapsible + ? { + onClick: () => setCollapsed((c) => !c), + className: `${styles.header} ${styles.headerClickable}`, + role: 'button' as const, + tabIndex: 0, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setCollapsed((c) => !c); + } + }, + } + : { className: styles.header }; + + return ( +
+
+
+ {collapsible && ( + + ▶ + + )} +

{title}

+ {autoRefresh && auto} +
+ {onRefresh && ( + + )} +
+ {!collapsed &&
{children}
} +
+ ); +} diff --git a/ui/src/components/admin/StatusBadge.module.css b/ui/src/components/admin/StatusBadge.module.css new file mode 100644 index 00000000..e2a4c9a6 --- /dev/null +++ b/ui/src/components/admin/StatusBadge.module.css @@ -0,0 +1,34 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.healthy { + background: #22c55e; +} + +.warning { + background: #eab308; +} + +.critical { + background: #ef4444; +} + +.unknown { + background: #6b7280; +} + +.label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} diff --git a/ui/src/components/admin/StatusBadge.tsx b/ui/src/components/admin/StatusBadge.tsx new file mode 100644 index 00000000..9d92f1ad --- /dev/null +++ b/ui/src/components/admin/StatusBadge.tsx @@ -0,0 +1,17 @@ +import styles from './StatusBadge.module.css'; + +export type Status = 'healthy' | 'warning' | 'critical' | 'unknown'; + +interface StatusBadgeProps { + status: Status; + label?: string; +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + return ( + + + {label && {label}} + + ); +}