diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 5260163..a3b174e 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { useDismiss } from "../lib/useDismiss"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a // video's membership in the user's playlists, with inline "new playlist" creation. @@ -59,25 +60,13 @@ export default function AddToPlaylist({ setOpen((o) => !o); } + useDismiss(open, () => setOpen(false), [panelRef, triggerRef]); + // Keep the panel anchored to the trigger as the page resizes/scrolls while open. useEffect(() => { if (!open) return; - function onDoc(e: MouseEvent) { - if ( - !panelRef.current?.contains(e.target as Node) && - !triggerRef.current?.contains(e.target as Node) - ) - setOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 078ae9c..356b99d 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -6,6 +6,7 @@ import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; import { LS } from "../lib/storage"; import Tooltip from "./Tooltip"; +import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; 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 ( -
-
{title}
- {children} -
- ); -} - function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) { const cls = tone === "accent" @@ -138,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) { const rows = q.data ?? []; return ( -
+

{t("users.roles.intro")}

{rows.length === 0 ? (

{t("users.roles.empty")}

@@ -266,7 +258,7 @@ function AdminInvites() { const decided = list.filter((i) => i.status !== "pending"); return ( -
+
{pending.length === 0 ? (

{t("settings.invites.noPending")}

) : ( @@ -360,7 +352,7 @@ function AdminDemo() { const rows = list.data ?? []; return ( -
+

{t("settings.demo.intro")}

{rows.length > 0 && ( diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ddcdfa4..385448d 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -14,6 +14,7 @@ import { UserMinus, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; +import { useDismiss } from "../lib/useDismiss"; import { formatEta, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -616,19 +617,7 @@ function TagsCell({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + useDismiss(open, () => setOpen(false), ref); if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 1da0e7a..fad57be 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, RotateCcw, Save, Send } from "lucide-react"; +import { RotateCcw, Send } from "lucide-react"; import { api, type ConfigItem } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Tabs, { usePersistedTab } from "./Tabs"; +import { Switch } from "./ui/form"; +import { DraftSaveBar } from "./ui/DraftSaveBar"; // 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 @@ -150,40 +152,21 @@ export default function ConfigPanel() { )} - {(dirty || saveState !== "idle") && ( -
- - {saveState === "saved" ? ( - - - {t("config.saved")} - - ) : saveState === "error" ? ( - {t("config.saveFailed")} - ) : ( - t("config.unsaved") - )} - -
- - -
-
- )} + ); } @@ -241,7 +224,7 @@ function Field({
{isBool ? ( - onChange(v ? "true" : "false")} /> + onChange(v ? "true" : "false")} /> ) : (
); -} - -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ); -} +} \ No newline at end of file diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index cad982d..8b9404c 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; +import { useDismiss } from "../lib/useDismiss"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -121,21 +122,7 @@ export default function DataTable({ setPage(0); }, [resetFiltersToken]); - useEffect(() => { - if (!openFilter) return; - function onDown(e: MouseEvent) { - if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpenFilter(null); - } - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [openFilter]); + useDismiss(!!openFilter, () => setOpenFilter(null), popRef); const filtered = rows.filter((row) => columns.every((col) => { diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index cd72d7b..4d13622 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; +import { Bell, Monitor, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; import { LS, usePersistedState } from "../lib/storage"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; +import { Section, SettingRow, Switch } from "./ui/form"; +import { DraftSaveBar } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; // The Settings page edits server-persisted preferences as a draft: changes apply locally @@ -103,87 +105,22 @@ export default function SettingsPanel({ function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); - // Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears. - if (!prefs.dirty && prefs.saveState === "idle") return null; - const saving = prefs.saveState === "saving"; return ( -
- - {prefs.saveState === "saved" - ? {t("settings.save.saved")} - : prefs.saveState === "error" - ? {t("settings.save.failed")} - : t("settings.save.unsaved")} - -
- - -
-
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); -} - -function Row({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
- - - {label} - - - {children} -
- ); -} - -function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - + ); } @@ -212,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
- + setTheme({ ...theme, mode: v ? "dark" : "light" })} /> - - + + setView(v ? "list" : "grid")} /> - - + - - + - +
@@ -261,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) { return (
- update({ sound: v })} /> - - + update({ durationMs: v ? 6000 : 0 })} /> - + {autoOn && (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index df82d27..ecf64f0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,6 +36,7 @@ import { } from "../lib/sidebarLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; +import { Switch } from "./ui/form"; import TagManager from "./TagManager"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. @@ -622,19 +623,7 @@ function Toggle({ return ( ); } diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index ec14479..f1b6db6 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -7,6 +7,7 @@ import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; import { LS, usePersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; +import { Section, SettingRow } from "./ui/form"; const RANGES = [7, 30, 90] as const; 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 ( -
-
{title}
- {children} -
- ); -} - -function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - - {label} - - - {children} -
- ); -} // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. function Overview({ me }: { me: Me }) { @@ -107,29 +83,29 @@ function Overview({ me }: { me: Me }) {
{s ? (
- + {s.channels_total} - - + + {`${s.channels_recent_synced}/${s.channels_total}`} - - + + {`${s.channels_deep_done}/${s.channels_deep_requested}`} - + {s.deep_pending_count > 0 && ( - {formatEta(s.deep_eta_seconds)} - + )} - + {s.my_videos.toLocaleString()} - - + + {s.quota_remaining_today.toLocaleString()} - +
) : (
{t("stats.sync.loading")}
@@ -140,12 +116,12 @@ function Overview({ me }: { me: Me }) { {usage.data ? ( <>
- + {usage.data.today.toLocaleString()} - - {usage.data.last_7d.toLocaleString()} - {usage.data.last_30d.toLocaleString()} - {usage.data.all_time.toLocaleString()} + + {usage.data.last_7d.toLocaleString()} + {usage.data.last_30d.toLocaleString()} + {usage.data.all_time.toLocaleString()}
{Object.keys(usage.data.by_action).length > 0 && (
diff --git a/frontend/src/components/ui/DraftSaveBar.tsx b/frontend/src/components/ui/DraftSaveBar.tsx new file mode 100644 index 0000000..5e629af --- /dev/null +++ b/frontend/src/components/ui/DraftSaveBar.tsx @@ -0,0 +1,75 @@ +import { Check, RotateCcw, Save } from "lucide-react"; + +// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs, +// admin Configuration). Two visual variants share one state machine + markup: +// - "inline": a bar pinned to the bottom of a card (Settings panel) +// - "floating": a centered floating pill over the page (Configuration page) +// Renders nothing while clean and idle; stays mounted through the fading saved/error message. + +export type SaveState = "idle" | "saving" | "saved" | "error"; + +export interface DraftSaveBarLabels { + saved: string; + failed: string; + unsaved: string; + discard: string; + save: string; + saving: string; +} + +export function DraftSaveBar({ + dirty, + state, + onSave, + onDiscard, + labels, + variant = "inline", +}: { + dirty: boolean; + state: SaveState; + onSave: () => void; + onDiscard: () => void; + labels: DraftSaveBarLabels; + variant?: "inline" | "floating"; +}) { + if (!dirty && state === "idle") return null; + const saving = state === "saving"; + const wrap = + variant === "floating" + ? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]" + : "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40"; + return ( +
+ + {state === "saved" ? ( + + + {labels.saved} + + ) : state === "error" ? ( + {labels.failed} + ) : ( + labels.unsaved + )} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..c99fcd3 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -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 ( + + ); +} + +/** 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 ( +
+
+ {title} +
+ {children} +
+ ); +} + +/** 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 ( + + + {children} + + + ); +} + +/** 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 ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts new file mode 100644 index 0000000..58c7fbe --- /dev/null +++ b/frontend/src/lib/useDismiss.ts @@ -0,0 +1,32 @@ +import { useEffect, type RefObject } from "react"; + +/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given + * element(s). The reusable half of the popover/dropdown pattern that several components each + * hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref + * (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */ +export function useDismiss( + active: boolean, + onClose: () => void, + refs: RefObject | RefObject[], +): void { + useEffect(() => { + if (!active) return; + const list = Array.isArray(refs) ? refs : [refs]; + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (!list.some((r) => r.current?.contains(target))) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + // Matches the original effects: (re)subscribe only when open/closed toggles. Refs are + // stable and onClose is read fresh from this run's closure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); +}