From 92979fa920cdbe232da02f140d5dc69dd9aff974 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 05:47:17 +0200 Subject: [PATCH] refactor(ui): one rule for resettable, role key and display name; an honest reset label The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so. The resettable rule, the demo-first role key and the display-name fallback each lived in several components tied by comments; they are shared helpers now, and the menu reuses the card's own strings instead of duplicate keys. --- frontend/src/components/AccountPanel.tsx | 8 ++------ frontend/src/components/AdminUsers.tsx | 3 ++- frontend/src/components/ComfortPinMenu.tsx | 10 +++++----- .../src/components/FeedVideoMenu.dom.test.tsx | 12 ++++++------ frontend/src/components/NavSidebar.tsx | 15 +++++---------- frontend/src/components/SettingsPanel.tsx | 7 +++---- frontend/src/components/VideoCard.tsx | 6 +++--- frontend/src/i18n/locales/en/card.json | 6 +----- frontend/src/i18n/locales/hu/card.json | 6 +----- frontend/src/lib/api/account.ts | 11 +++++++++++ frontend/src/lib/api/feed.ts | 6 ++++++ 11 files changed, 45 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index b93377e4..fd4eff9e 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -5,6 +5,7 @@ import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } f import Modal from "./ui/Modal"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { api } from "../lib/api"; +import { displayName } from "../lib/api/account"; import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; @@ -101,12 +102,7 @@ export default function AccountPanel({ const a: AdminUserAccess | undefined = q.data; return ( - +
{user.email}
{q.isError && !a ? ( diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 27ee9011..1a9a5ee3 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -23,6 +23,7 @@ import { type Me, type PendingAccessRow, } from "../lib/api"; +import { displayName } from "../lib/api/account"; import { notify } from "../lib/notifications"; import { formatDateTime, relativeTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; @@ -234,7 +235,7 @@ function UsersRoles({ me }: { me: Me }) { className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1" >
- {u.display_name ?? u.email.split("@")[0]} + {displayName(u)} {self && · {t("users.roles.you")}}
{u.email}
diff --git a/frontend/src/components/ComfortPinMenu.tsx b/frontend/src/components/ComfortPinMenu.tsx index 43e62256..73b10394 100644 --- a/frontend/src/components/ComfortPinMenu.tsx +++ b/frontend/src/components/ComfortPinMenu.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react"; import { type PlexCard, type Video, type VideoStatus } from "../lib/api"; +import { isResettable } from "../lib/api/feed"; import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { ContextMenu } from "./ui/ContextMenu"; @@ -75,24 +76,23 @@ export function FeedVideoMenu({ } : { id: "watched", - label: t("card.menu.markWatched"), + label: t("card.markWatched"), icon: , onSelect: () => onState(v.id, "watched"), }, { id: "save", - label: v.saved ? t("card.menu.unsave") : t("card.menu.save"), + label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"), icon: v.saved ? : , onSelect: () => onToggleSave(v.id, !v.saved), }, { id: "hide", - label: v.status === "hidden" ? t("card.menu.unhide") : t("card.menu.hide"), + label: v.status === "hidden" ? t("card.unhide") : t("card.hide"), icon: v.status === "hidden" ? : , onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"), }, - // The card offers the reset only on a card that has something to reset; so does this. - ...(v.status !== "new" || v.position_seconds > 0 + ...(isResettable(v) ? [ { id: "reset", diff --git a/frontend/src/components/FeedVideoMenu.dom.test.tsx b/frontend/src/components/FeedVideoMenu.dom.test.tsx index 7fef6246..08fa90a2 100644 --- a/frontend/src/components/FeedVideoMenu.dom.test.tsx +++ b/frontend/src/components/FeedVideoMenu.dom.test.tsx @@ -98,12 +98,12 @@ describe("the feed card's menu", () => { it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => { const { handlers } = openMenu(video({})); expect(labels()).toEqual([ - "card.menu.markWatched", - "card.menu.save", - "card.menu.hide", + "card.markWatched", + "card.saveForLater", + "card.hide", "home.pin.add", ]); - fireEvent.click(item("card.menu.markWatched")); + fireEvent.click(item("card.markWatched")); await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched")); }); @@ -112,7 +112,7 @@ describe("the feed card's menu", () => { expect(labels()).toEqual([ "card.menu.unmarkWatched", "card.menu.unsave", - "card.menu.hide", + "card.hide", "card.menu.reset", "home.pin.add", ]); @@ -122,7 +122,7 @@ describe("the feed card's menu", () => { it("unhides a hidden one", async () => { const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 })); - fireEvent.click(item("card.menu.unhide")); + fireEvent.click(item("card.unhide")); await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new")); }); diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 94b816b6..02e2345c 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react"; import { api, type Me } from "../lib/api"; +import { displayName, roleKey } from "../lib/api/account"; import { pinOnNextPage } from "../lib/activeAccount"; import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; @@ -156,15 +157,9 @@ export default function NavSidebar({ const rowBase = "selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; - const name = me.display_name ?? me.email.split("@")[0]; - // Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a - // real role (it's a flag on a `user` row), so surface it first. - const roleKey: "admin" | "user" | "demo" = me.is_demo - ? "demo" - : me.role === "admin" - ? "admin" - : "user"; - const roleLabel = t(`nav.role.${roleKey}`); + const name = displayName(me); + // Role is surfaced as a plain title tooltip on the account row (no visual badge). + const roleLabel = t(`nav.role.${roleKey(me)}`); const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const active = page === p; @@ -323,7 +318,7 @@ export default function NavSidebar({ /> - {a.display_name ?? a.email.split("@")[0]} + {displayName(a)} {a.email} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index d70a9a3f..6da08fbd 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -8,6 +8,7 @@ import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from import DiagnosticsSettings from "./DiagnosticsSettings"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; +import { displayName, roleKey } from "../lib/api/account"; import { notifyActionError } from "../lib/actionErrors"; import { LS, useAccountPersistedState } from "../lib/storage"; import Avatar from "./ui/Avatar"; @@ -792,12 +793,10 @@ export function AccountHeader({ me }: { me: Me }) { className="w-12 h-12 rounded-full" />
-
{me.display_name ?? me.email.split("@")[0]}
+
{displayName(me)}
{me.email}
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */} -
- {t(`nav.role.${me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"}`)} -
+
{t(`nav.role.${roleKey(me)}`)}
{!me.is_demo && (
{me.last_login_at diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5bb5474c..32fb87e5 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -21,6 +21,7 @@ import VideoInfoCard from "./VideoInfoCard"; import { navItemAttrs } from "../lib/listNav"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; +import { isResettable } from "../lib/api/feed"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; @@ -46,9 +47,8 @@ function Actions({ e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; - // Pristine = never opened: default status and no resume position. The reset clears the - // whole state (incl. an in-progress position the un-watch toggle can't touch). - const resettable = video.status !== "new" || video.position_seconds > 0; + // The reset clears the whole state (incl. an in-progress position the un-watch toggle can't touch). + const resettable = isResettable(video); const toggleSave = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json index 5073321b..203b7a5b 100644 --- a/frontend/src/i18n/locales/en/card.json +++ b/frontend/src/i18n/locales/en/card.json @@ -22,12 +22,8 @@ "noDescription": "No description", "menu": { "label": "Video", - "markWatched": "Mark watched", "unmarkWatched": "Mark unwatched", - "save": "Save for later", "unsave": "Remove from saved", - "hide": "Hide", - "unhide": "Unhide", - "reset": "Clear watch progress" + "reset": "Reset (clear progress and status)" } } diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json index 8258609f..4c991ffd 100644 --- a/frontend/src/i18n/locales/hu/card.json +++ b/frontend/src/i18n/locales/hu/card.json @@ -22,12 +22,8 @@ "noDescription": "Nincs leírás", "menu": { "label": "Videó", - "markWatched": "Megnézettnek jelöl", "unmarkWatched": "Megnézés visszavonása", - "save": "Mentés későbbre", "unsave": "Törlés a mentettek közül", - "hide": "Elrejtés", - "unhide": "Megjelenítés", - "reset": "Nézési előzmény törlése" + "reset": "Alaphelyzet (előzmény és státusz törlése)" } } diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index df21f523..713711ef 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -35,6 +35,17 @@ export interface Me { diagnostics: DiagnosticsState; } +/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user` + * row), so it comes first. */ +export function roleKey(a: Pick): "admin" | "user" | "demo" { + return a.is_demo ? "demo" : a.role === "admin" ? "admin" : "user"; +} + +/** What an account is called on screen: its display name, else the local part of its email. */ +export function displayName(a: { display_name: string | null; email: string }): string { + return a.display_name ?? a.email.split("@")[0]!; +} + export interface Account { id: number; email: string; diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 0d19b24a..dcdf133d 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -28,6 +28,12 @@ export interface Video { watch_url: string; } +/** Whether a video has anything its reset would clear: pristine = never opened, the default status + * and no resume position. The card's reset button and the card menu's reset both ask this. */ +export function isResettable(v: Pick): boolean { + return v.status !== "new" || v.position_seconds > 0; +} + export interface VideoDetail { id: string; description: string | null;