refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick

E32 review round 1 (F3–F6).

- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
  listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
  constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
  loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
  (labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
  showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
  still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
  row shows every pick, as a placeholder while loading and as its id after a failure.
This commit is contained in:
2026-10-04 02:29:03 +02:00
parent e09d613b04
commit e5b155ada9
24 changed files with 245 additions and 129 deletions
+13 -9
View File
@@ -1,6 +1,7 @@
import type { ComponentProps, ReactNode } from "react";
import type { TFunction } from "i18next";
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api";
import { tagPickLabel, type TagList } from "../lib/tagPicks";
import { formatCountOrDash, formatTotalHours } from "../lib/format";
import type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
@@ -50,25 +51,28 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
return values.some((v) => c.tag_ids.includes(Number(v)));
}
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id whose tag no
* longer exists, then each user tag, then the "No tag" sentinel. A stored pick can outlive its tag
* (deleted here or on another device) and still narrows the table to nothing, so it stays a visible,
* removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2). */
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2) —
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
* (a placeholder, then the name, "Deleted tag" or the id), not hidden (E32 review). */
export function tagFilterChips(o: {
userTags: Tag[];
allTags: Tag[] | undefined;
tags: TagList;
picked: string[];
noTagOn: boolean;
toggle: (tagId: number) => void;
toggleNoTag: () => void;
t: TFunction;
}): FilterChip[] {
const known = new Set((o.allTags ?? []).map((tg) => String(tg.id)));
const gone = o.allTags ? o.picked.filter((id) => id !== NO_TAG_SENTINEL && !known.has(id)) : [];
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
return [
...gone.map((id) => ({
...others.map((id) => ({
key: `t${id}`,
label: o.t("channels.tags.deleted"),
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),