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.
This commit is contained in:
@@ -5,6 +5,7 @@ import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } f
|
|||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { displayName } from "../lib/api/account";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
@@ -101,12 +102,7 @@ export default function AccountPanel({
|
|||||||
const a: AdminUserAccess | undefined = q.data;
|
const a: AdminUserAccess | undefined = q.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
|
||||||
title={user.display_name ?? user.email.split("@")[0]}
|
|
||||||
onClose={onClose}
|
|
||||||
placement="drawer"
|
|
||||||
maxWidth="max-w-md"
|
|
||||||
>
|
|
||||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||||
|
|
||||||
{q.isError && !a ? (
|
{q.isError && !a ? (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type Me,
|
type Me,
|
||||||
type PendingAccessRow,
|
type PendingAccessRow,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import { displayName } from "../lib/api/account";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { formatDateTime, relativeTime } from "../lib/format";
|
import { formatDateTime, relativeTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
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"
|
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||||
>
|
>
|
||||||
<div className="text-sm truncate">
|
<div className="text-sm truncate">
|
||||||
{u.display_name ?? u.email.split("@")[0]}
|
{displayName(u)}
|
||||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
|
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 { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
@@ -75,24 +76,23 @@ export function FeedVideoMenu({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: "watched",
|
id: "watched",
|
||||||
label: t("card.menu.markWatched"),
|
label: t("card.markWatched"),
|
||||||
icon: <Check className={icon} />,
|
icon: <Check className={icon} />,
|
||||||
onSelect: () => onState(v.id, "watched"),
|
onSelect: () => onState(v.id, "watched"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "save",
|
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 ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
||||||
onSelect: () => onToggleSave(v.id, !v.saved),
|
onSelect: () => onToggleSave(v.id, !v.saved),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "hide",
|
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" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
||||||
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "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.
|
...(isResettable(v)
|
||||||
...(v.status !== "new" || v.position_seconds > 0
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "reset",
|
id: "reset",
|
||||||
|
|||||||
@@ -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 () => {
|
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
|
||||||
const { handlers } = openMenu(video({}));
|
const { handlers } = openMenu(video({}));
|
||||||
expect(labels()).toEqual([
|
expect(labels()).toEqual([
|
||||||
"card.menu.markWatched",
|
"card.markWatched",
|
||||||
"card.menu.save",
|
"card.saveForLater",
|
||||||
"card.menu.hide",
|
"card.hide",
|
||||||
"home.pin.add",
|
"home.pin.add",
|
||||||
]);
|
]);
|
||||||
fireEvent.click(item("card.menu.markWatched"));
|
fireEvent.click(item("card.markWatched"));
|
||||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
|
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ describe("the feed card's menu", () => {
|
|||||||
expect(labels()).toEqual([
|
expect(labels()).toEqual([
|
||||||
"card.menu.unmarkWatched",
|
"card.menu.unmarkWatched",
|
||||||
"card.menu.unsave",
|
"card.menu.unsave",
|
||||||
"card.menu.hide",
|
"card.hide",
|
||||||
"card.menu.reset",
|
"card.menu.reset",
|
||||||
"home.pin.add",
|
"home.pin.add",
|
||||||
]);
|
]);
|
||||||
@@ -122,7 +122,7 @@ describe("the feed card's menu", () => {
|
|||||||
|
|
||||||
it("unhides a hidden one", async () => {
|
it("unhides a hidden one", async () => {
|
||||||
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
|
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"));
|
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { pinOnNextPage } from "../lib/activeAccount";
|
import { pinOnNextPage } from "../lib/activeAccount";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
@@ -156,15 +157,9 @@ export default function NavSidebar({
|
|||||||
|
|
||||||
const rowBase =
|
const rowBase =
|
||||||
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
"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];
|
const name = displayName(me);
|
||||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
|
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
|
||||||
// real role (it's a flag on a `user` row), so surface it first.
|
const roleLabel = t(`nav.role.${roleKey(me)}`);
|
||||||
const roleKey: "admin" | "user" | "demo" = me.is_demo
|
|
||||||
? "demo"
|
|
||||||
: me.role === "admin"
|
|
||||||
? "admin"
|
|
||||||
: "user";
|
|
||||||
const roleLabel = t(`nav.role.${roleKey}`);
|
|
||||||
|
|
||||||
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
|
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
|
||||||
const active = page === p;
|
const active = page === p;
|
||||||
@@ -323,7 +318,7 @@ export default function NavSidebar({
|
|||||||
/>
|
/>
|
||||||
<span className="min-w-0 text-left">
|
<span className="min-w-0 text-left">
|
||||||
<span className="block text-[0.8125rem] text-fg truncate">
|
<span className="block text-[0.8125rem] text-fg truncate">
|
||||||
{a.display_name ?? a.email.split("@")[0]}
|
{displayName(a)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from
|
|||||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
@@ -792,12 +793,10 @@ export function AccountHeader({ me }: { me: Me }) {
|
|||||||
className="w-12 h-12 rounded-full"
|
className="w-12 h-12 rounded-full"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
|
<div className="font-semibold truncate">{displayName(me)}</div>
|
||||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||||
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
|
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
|
||||||
{t(`nav.role.${me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"}`)}
|
|
||||||
</div>
|
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||||
{me.last_login_at
|
{me.last_login_at
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import VideoInfoCard from "./VideoInfoCard";
|
|||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video, VideoStatus } from "../lib/api";
|
import type { Video, VideoStatus } from "../lib/api";
|
||||||
|
import { isResettable } from "../lib/api/feed";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
|
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
|
||||||
import { isPlainLeftClick } from "../lib/nav";
|
import { isPlainLeftClick } from "../lib/nav";
|
||||||
@@ -46,9 +47,8 @@ function Actions({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onState(video.id, video.status === status ? "new" : status);
|
onState(video.id, video.status === status ? "new" : status);
|
||||||
};
|
};
|
||||||
// Pristine = never opened: default status and no resume position. The reset clears the
|
// The reset clears the whole state (incl. an in-progress position the un-watch toggle can't touch).
|
||||||
// whole state (incl. an in-progress position the un-watch toggle can't touch).
|
const resettable = isResettable(video);
|
||||||
const resettable = video.status !== "new" || video.position_seconds > 0;
|
|
||||||
const toggleSave = (e: React.MouseEvent) => {
|
const toggleSave = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -22,12 +22,8 @@
|
|||||||
"noDescription": "No description",
|
"noDescription": "No description",
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": "Video",
|
"label": "Video",
|
||||||
"markWatched": "Mark watched",
|
|
||||||
"unmarkWatched": "Mark unwatched",
|
"unmarkWatched": "Mark unwatched",
|
||||||
"save": "Save for later",
|
|
||||||
"unsave": "Remove from saved",
|
"unsave": "Remove from saved",
|
||||||
"hide": "Hide",
|
"reset": "Reset (clear progress and status)"
|
||||||
"unhide": "Unhide",
|
|
||||||
"reset": "Clear watch progress"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,8 @@
|
|||||||
"noDescription": "Nincs leírás",
|
"noDescription": "Nincs leírás",
|
||||||
"menu": {
|
"menu": {
|
||||||
"label": "Videó",
|
"label": "Videó",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
|
||||||
"unmarkWatched": "Megnézés visszavonása",
|
"unmarkWatched": "Megnézés visszavonása",
|
||||||
"save": "Mentés későbbre",
|
|
||||||
"unsave": "Törlés a mentettek közül",
|
"unsave": "Törlés a mentettek közül",
|
||||||
"hide": "Elrejtés",
|
"reset": "Alaphelyzet (előzmény és státusz törlése)"
|
||||||
"unhide": "Megjelenítés",
|
|
||||||
"reset": "Nézési előzmény törlése"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export interface Me {
|
|||||||
diagnostics: DiagnosticsState;
|
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<Me, "is_demo" | "role">): "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 {
|
export interface Account {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export interface Video {
|
|||||||
watch_url: string;
|
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<Video, "status" | "position_seconds">): boolean {
|
||||||
|
return v.status !== "new" || v.position_seconds > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoDetail {
|
export interface VideoDetail {
|
||||||
id: string;
|
id: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user