refactor(ui): shared form primitives (Switch, Section, SettingRow, HintLabel)

components/ui/form.tsx replaces the per-panel copies:
- Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) +
  Sidebar (inline labeled toggle).
- Section (with a card variant) — was in SettingsPanel + Stats (plain) and
  AdminUsers (glass card).
- SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats.
No visual change intended (Sidebar's toggle gains the standard knob shadow).
This commit is contained in:
npeter83 2026-06-29 00:04:45 +02:00
parent 5fcd64c1e1
commit 53c75f69e2
6 changed files with 125 additions and 147 deletions

View file

@ -6,6 +6,7 @@ import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
@ -41,15 +42,6 @@ export default function AdminUsers({ me }: { me: Me }) {
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
{children}
</div>
);
}
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) { function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
const cls = const cls =
tone === "accent" tone === "accent"
@ -138,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) {
const rows = q.data ?? []; const rows = q.data ?? [];
return ( return (
<Section title={t("users.roles.title")}> <Section card title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p> <p className="text-sm text-muted">{t("users.roles.empty")}</p>
@ -266,7 +258,7 @@ function AdminInvites() {
const decided = list.filter((i) => i.status !== "pending"); const decided = list.filter((i) => i.status !== "pending");
return ( return (
<Section title={t("settings.invites.title")}> <Section card title={t("settings.invites.title")}>
{pending.length === 0 ? ( {pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p> <p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : ( ) : (
@ -360,7 +352,7 @@ function AdminDemo() {
const rows = list.data ?? []; const rows = list.data ?? [];
return ( return (
<Section title={t("settings.demo.title")}> <Section card title={t("settings.demo.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
{rows.length > 0 && ( {rows.length > 0 && (

View file

@ -6,6 +6,7 @@ import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -241,7 +242,7 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? ( {isBool ? (
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} /> <Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
) : ( ) : (
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
@ -273,19 +274,3 @@ function Field({
</div> </div>
); );
} }
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}

View file

@ -8,6 +8,7 @@ import { LS, usePersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // The Settings page edits server-persisted preferences as a draft: changes apply locally
@ -137,56 +138,6 @@ function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
function Appearance({ prefs }: { prefs: PrefsController }) { function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only // Controlled by App's prefs draft: each change applies locally for preview but is only
@ -212,27 +163,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</Section> </Section>
<Section title={t("settings.appearance.display")}> <Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}> <SettingRow label={t("settings.appearance.darkMode")}>
<Switch <Switch
checked={theme.mode === "dark"} checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/> />
</Row> </SettingRow>
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}> <SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} /> <Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.appearance.performanceMode")} label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")} hint={t("settings.appearance.performanceModeHint")}
> >
<Switch checked={perf} onChange={setPerf} /> <Switch checked={perf} onChange={setPerf} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.appearance.showHints")} label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")} hint={t("settings.appearance.showHintsHint")}
> >
<Switch checked={hints} onChange={setHints} /> <Switch checked={hints} onChange={setHints} />
</Row> </SettingRow>
</Section> </Section>
<Section title={t("settings.appearance.textSize")}> <Section title={t("settings.appearance.textSize")}>
@ -261,18 +212,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
return ( return (
<Section title={t("settings.notifications.title")}> <Section title={t("settings.notifications.title")}>
<Row <SettingRow
label={t("settings.notifications.sound")} label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")} hint={t("settings.notifications.soundHint")}
> >
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} /> <Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.notifications.autoDismiss")} label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")} hint={t("settings.notifications.autoDismissHint")}
> >
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} /> <Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row> </SettingRow>
{autoOn && ( {autoOn && (
<div className="py-1.5 text-sm"> <div className="py-1.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View file

@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout"; } from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { Switch } from "./ui/form";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
@ -622,19 +623,7 @@ function Toggle({
return ( return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm"> <label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span> <span>{label}</span>
<button <Switch checked={checked} onChange={onChange} />
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative ${
checked ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
</label> </label>
); );
} }

View file

@ -7,6 +7,7 @@ import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage"; import { LS, usePersistedState } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
const RANGES = [7, 30, 90] as const; const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system"; type StatsTab = "overview" | "system";
@ -47,31 +48,6 @@ export default function Stats({ me }: { me: Me }) {
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) { function Overview({ me }: { me: Me }) {
@ -107,29 +83,29 @@ function Overview({ me }: { me: Me }) {
<Section title={t("stats.sync.myStatus")}> <Section title={t("stats.sync.myStatus")}>
{s ? ( {s ? (
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}> <SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total} {s.channels_total}
</Row> </SettingRow>
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}> <SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
{`${s.channels_recent_synced}/${s.channels_total}`} {`${s.channels_recent_synced}/${s.channels_total}`}
</Row> </SettingRow>
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}> <SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`} {`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row> </SettingRow>
{s.deep_pending_count > 0 && ( {s.deep_pending_count > 0 && (
<Row <SettingRow
label={t("stats.sync.fullHistoryEta")} label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })} hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
> >
{formatEta(s.deep_eta_seconds)} {formatEta(s.deep_eta_seconds)}
</Row> </SettingRow>
)} )}
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}> <SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{s.my_videos.toLocaleString()} {s.my_videos.toLocaleString()}
</Row> </SettingRow>
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}> <SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
</Row> </SettingRow>
</div> </div>
) : ( ) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div> <div className="text-muted text-sm">{t("stats.sync.loading")}</div>
@ -140,12 +116,12 @@ function Overview({ me }: { me: Me }) {
{usage.data ? ( {usage.data ? (
<> <>
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}> <SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{usage.data.today.toLocaleString()} {usage.data.today.toLocaleString()}
</Row> </SettingRow>
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row> <SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row> <SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row> <SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
</div> </div>
{Object.keys(usage.data.by_action).length > 0 && ( {Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted"> <div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">

View file

@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import Tooltip from "../Tooltip";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. */
export function Switch({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
/** A titled settings block. Default = a plain block with an uppercase caption; `card` wraps it
* in a glass card (the admin pages' style). */
export function Section({
title,
card,
children,
}: {
title: string;
card?: boolean;
children: ReactNode;
}) {
return (
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
{title}
</div>
{children}
</div>
);
}
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
* No-op styling when there's no hint, so it's safe to use unconditionally. */
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={
hint
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
: ""
}
>
{children}
</span>
</Tooltip>
);
}
/** A settings row: a (hint-able) label on the left, a control on the right. */
export function SettingRow({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<HintLabel hint={hint}>{label}</HintLabel>
{children}
</div>
);
}