Release v0.1.6
This commit is contained in:
26
webui/src/components/admin/AdminIconButton.tsx
Normal file
26
webui/src/components/admin/AdminIconButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
className="admin-icon-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
45
webui/src/components/admin/AdminPageLayout.tsx
Normal file
45
webui/src/components/admin/AdminPageLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import LoadingFrame from "../LoadingFrame";
|
||||
import PageTitle from "../PageTitle";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
error?: string;
|
||||
success?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminPageLayout({
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
loadingLabel = "Loading administration data...",
|
||||
error = "",
|
||||
success = "",
|
||||
actions,
|
||||
children,
|
||||
className = ""
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={`admin-section-page ${className}`.trim()}>
|
||||
<div className="page-heading split workspace-heading admin-page-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{title}</PageTitle>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="button-row compact-actions admin-page-actions">{actions}</div>}
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label={loadingLabel}>
|
||||
{children}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
webui/src/components/admin/AdminSelectionList.tsx
Normal file
44
webui/src/components/admin/AdminSelectionList.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function AdminSelectionList({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyText = "No options available."
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (!options.length) return <p className="muted small-note">{emptyText}</p>;
|
||||
const selectedSet = new Set(selected);
|
||||
return (
|
||||
<div className="admin-selection-list">
|
||||
{options.map((option) => (
|
||||
<label key={option.id} className={`admin-selection-item ${option.disabled ? "disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.id)}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (event.target.checked) next.add(option.id);
|
||||
else next.delete(option.id);
|
||||
onChange(Array.from(next));
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
webui/src/components/admin/adminUtils.ts
Normal file
27
webui/src/components/admin/adminUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
|
||||
|
||||
export function adminErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "The administrative operation failed.";
|
||||
const message = error.message;
|
||||
const jsonStart = message.indexOf("{");
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as { detail?: unknown };
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (Array.isArray(parsed.detail)) {
|
||||
return parsed.detail.map((item) => typeof item === "object" && item && "msg" in item ? String((item as { msg: unknown }).msg) : String(item)).join("; ");
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the original message.
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatAdminDateTime(value?: string | null): string {
|
||||
return formatPlatformDateTime(value, { fallback: "Never" });
|
||||
}
|
||||
|
||||
export function joinLabels(values: Array<{ name: string }>): string {
|
||||
return values.length ? values.map((value) => value.name).join(", ") : "—";
|
||||
}
|
||||
Reference in New Issue
Block a user