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:
parent
5fcd64c1e1
commit
53c75f69e2
6 changed files with 125 additions and 147 deletions
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -272,20 +273,4 @@ function Field({
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
85
frontend/src/components/ui/form.tsx
Normal file
85
frontend/src/components/ui/form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue