mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
This commit is contained in:
1 parent
00e8d05235
commit
5445bfc1f1
121 files changed
+832
-620
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* `<StatTile>` — the one label/value mini tile in the Admin console.
|
||||
*
|
||||
* Four hand-rolled variants of this box existed side by side: `StatusItem`
|
||||
* (`bg-muted/50 p-3`), the encryption section's local `Stat`
|
||||
* (`bg-muted/40 px-3 py-2`), the user-management inline panels
|
||||
* (`bg-muted/80 p-4`), and the backups upload box (`bg-muted/30 p-3`). Four
|
||||
* fills, three paddings, one role. This is the role.
|
||||
*
|
||||
* `icon` is optional — a tile with a glyph and one without still share the
|
||||
* frame, so a grid can mix them without a step in tile height.
|
||||
*/
|
||||
export function StatTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
/** Extra classes on the value line — a status colour, or `font-mono`. */
|
||||
valueClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="stat-tile"
|
||||
className={cn("bg-muted/50 space-y-1 rounded-lg p-3", className)}
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
|
||||
{label}
|
||||
</div>
|
||||
<p className={cn("text-sm font-semibold", valueClassName)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -253,21 +254,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
? t("admin.subtitle")
|
||||
: t(`admin.section.${activeSlug}.subtitle`);
|
||||
|
||||
// Same two-copy heading as the Settings shell (mobile above the chip strip,
|
||||
// desktop inside the grid row) and the same primitive, so the two consoles
|
||||
// cannot drift a step apart again.
|
||||
const headingBlock = (mobile = false) => (
|
||||
<div>
|
||||
{mobile ? (
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{headingTitle}
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
headingAs={mobile ? "div" : "h1"}
|
||||
title={headingTitle}
|
||||
description={headingSubtitle}
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
|
||||
@@ -76,7 +76,7 @@ export function AiQualitySection() {
|
||||
// resolves. Mirrors <CoachFeedbackSection> (header outside the
|
||||
// fetch-state branch).
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AiServerKeySection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-input">
|
||||
{t("admin.aiServerKey.keyLabel")}
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ApiTokenOverviewSection() {
|
||||
description={t("admin.apiTokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -116,7 +116,7 @@ export function AppLogPreviewSection() {
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
@@ -280,7 +280,7 @@ export function AppLogPreviewSection() {
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
|
||||
className="text-muted-foreground flex items-center justify-between text-xs"
|
||||
data-testid="app-log-preview-summary"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AssistantSection() {
|
||||
title={t("admin.assistant.title")}
|
||||
description={t("admin.assistant.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
|
||||
@@ -493,13 +493,8 @@ export function BackupsSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
|
||||
"Run now" button follows, and the explainer + docs link move
|
||||
into the header's description slot. Previously the description
|
||||
sat in a separate `mt-1 pl-7` paragraph that crowded the button
|
||||
directly above it; routing it through the header's `space-y-1`
|
||||
stack restores breathing room and left-aligns it under the
|
||||
title. */}
|
||||
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
|
||||
"Run now" button follows. */}
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.backups.title")}
|
||||
@@ -547,7 +542,7 @@ export function BackupsSection() {
|
||||
backup file independently of any existing rows. The visible
|
||||
button proxies a hidden file input so the layout stays clean
|
||||
while keyboard / screen-reader users keep a labelled control. */}
|
||||
<div className="bg-muted/30 border-border mt-4 flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="bg-muted/30 border-border flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.section.backups.uploadTitle")}
|
||||
@@ -596,7 +591,7 @@ export function BackupsSection() {
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.section.backups.loading")}
|
||||
@@ -605,7 +600,7 @@ export function BackupsSection() {
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.section.backups.loadError")}
|
||||
</div>
|
||||
@@ -614,7 +609,7 @@ export function BackupsSection() {
|
||||
// primitive. The header already exposes "Backup now" but a
|
||||
// brand-new admin lands inside the card and benefits from a
|
||||
// duplicate CTA right next to the explanation.
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={t("admin.section.backups.emptyTitle")}
|
||||
@@ -637,7 +632,7 @@ export function BackupsSection() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{/* Wide layout: the canonical table. Hidden below `md`, where the
|
||||
five columns push Download / Restore off-screen behind a
|
||||
horizontal scroll; the card list below takes over there. */}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function CentralCodexSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CoachFeedbackSection() {
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.coachFeedback.title")}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function DangerZoneSection() {
|
||||
/>
|
||||
{wipeMsg && (
|
||||
<p
|
||||
className={`mt-3 pl-7 text-sm ${
|
||||
className={`mt-3 text-sm ${
|
||||
wipeAllData.isError ? "text-destructive" : "text-success"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function EncryptionSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
@@ -163,7 +163,7 @@ export function EncryptionSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.section.encryption.rotationTitle")}
|
||||
@@ -230,7 +230,7 @@ export function EncryptionSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Per-column coverage table ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-3">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.columnsTitle")}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function GeneralSettingsSection() {
|
||||
title={t("admin.appSettings")}
|
||||
description={t("admin.appSettingsDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GlitchtipSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
@@ -130,7 +130,7 @@ export function GlitchtipSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -185,7 +185,7 @@ export function HostMetricsChart() {
|
||||
</h2>
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-3 rounded-md border px-3 py-2 text-xs"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
|
||||
>
|
||||
{t("admin.hostMetrics.loadError")}
|
||||
</p>
|
||||
|
||||
@@ -322,10 +322,10 @@ export function InviteTokensSection() {
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 pl-7 text-xs">
|
||||
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
|
||||
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("admin.invites.adminOnlyHint")}
|
||||
</p>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function LoginOverviewSection() {
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
admin "is something wrong" question — keep the one-tap shortcut. */}
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ModuleAvailabilitySection() {
|
||||
title={t("admin.modules.title")}
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{MODULE_KEYS
|
||||
// A module switched off in code (pending a rebuild) is hard-off
|
||||
// server-wide; drop its row so the operator toggle can't mislead.
|
||||
|
||||
@@ -70,7 +70,7 @@ export function RecentAuditPreview() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2
|
||||
|
||||
@@ -61,7 +61,7 @@ function NotificationHealthPanel() {
|
||||
description={t("admin.notificationHealth.description")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 pl-7">
|
||||
<div>
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
@@ -205,7 +205,7 @@ export function RemindersSection() {
|
||||
description={t("admin.medicationRemindersDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
@@ -260,7 +260,7 @@ export function RemindersSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -319,7 +319,7 @@ export function RemindersSection() {
|
||||
|
||||
{testNotification.data?.results &&
|
||||
testNotification.data.results.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<div className="space-y-1">
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
@@ -338,7 +338,7 @@ export function RemindersSection() {
|
||||
|
||||
{reminderCheck.data?.medications &&
|
||||
reminderCheck.data.medications.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="space-y-2">
|
||||
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
|
||||
sequential (parent admin SectionFrame ships <h1>). */}
|
||||
<h2 className="text-sm font-medium">
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ServicesSection() {
|
||||
title={t("admin.servicesGlobal")}
|
||||
description={t("admin.servicesGlobalDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function FailingJobsCard({
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.failingJobs.unavailable")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
@@ -91,12 +91,12 @@ export function FailingJobsCard({
|
||||
{failingJobs.queues.length === 0 ? (
|
||||
<p
|
||||
data-testid="failing-jobs-none"
|
||||
className="text-muted-foreground mt-4 pl-7 text-sm"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
|
||||
<ul data-testid="failing-jobs-list" className="space-y-3">
|
||||
{failingJobs.queues.map((queue) => (
|
||||
<li key={queue.queue} className="text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
@@ -146,7 +146,7 @@ export function SystemStatusSection() {
|
||||
// branches below it are a load error and a spinner, so the
|
||||
// marker cannot stand for a section still waiting on the read.
|
||||
data-slot="system-status-grid"
|
||||
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
@@ -275,7 +275,7 @@ export function SystemStatusSection() {
|
||||
// full page reload to recover.
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("admin.systemStatusLoadError")}</span>
|
||||
<Button
|
||||
@@ -296,7 +296,7 @@ export function SystemStatusSection() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SystemStatusSummary() {
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
@@ -120,12 +120,12 @@ export function SystemStatusSummary() {
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.overview.snapshotLoadError")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -81,7 +81,7 @@ export function UmamiSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
@@ -131,7 +131,7 @@ export function UmamiSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -306,7 +306,7 @@ export function UserManagementSection() {
|
||||
// rendered an empty `<tbody>` so an admin filter that returned
|
||||
// no rows produced a blank rectangle; the icon + filter-aware
|
||||
// copy + "Show all users" CTA make the state explicit.
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("admin.section.users.emptyTitle")}
|
||||
@@ -330,7 +330,7 @@ export function UserManagementSection() {
|
||||
`md:block hidden` swaps it out for the card-list below at
|
||||
`< md`. Hiding the table-only wrapper (instead of just
|
||||
the cells) saves DOM weight on mobile too. */}
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
@@ -399,7 +399,7 @@ export function UserManagementSection() {
|
||||
All four actions stay visible and tap-targetable; nothing
|
||||
is hidden behind a horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
className="space-y-2 md:hidden"
|
||||
data-slot="admin-user-rows"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
@@ -444,7 +444,7 @@ export function UserManagementSection() {
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingUsers")}
|
||||
@@ -454,7 +454,7 @@ export function UserManagementSection() {
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{editingUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<div className="bg-muted/80 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.editUserTitle", { name: editingUser.username })}
|
||||
</h3>
|
||||
@@ -567,7 +567,7 @@ export function UserManagementSection() {
|
||||
|
||||
{/* Password Reset Dialog */}
|
||||
{resetUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<div className="bg-muted/80 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.resetPasswordTitle", { name: resetUser.username })}
|
||||
</h3>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function VersionTileSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.versionTileVersion")}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function WebPushVapidSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
@@ -191,7 +191,7 @@ export function WebPushVapidSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user