Files
siftlode/frontend/src/components/plexBrowseLists.tsx
T
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00

244 lines
9.0 KiB
TypeScript

import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
function collectionMeta(c: PlexCollection, t: TFunction): string {
const parts: string[] = [];
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
const rt = formatRuntime(c.total_runtime_s);
if (rt) parts.push(rt);
return parts.join(" · ");
}
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
// take the colour fallback.
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
// A grid of the user's Plex playlists; a tile opens that playlist's view.
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const playlists = q.data?.playlists ?? [];
if (q.isError && !q.data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading) return <LoadingState shape="poster" label={t("plex.loading")} />;
if (playlists.length === 0)
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<NavGrid className={GRID}>
{playlists.map((p, i) => (
<PosterTile
key={p.id}
navIndex={i}
title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
onClick={() => onOpen(p.id)}
className="h-28"
/>
))}
</NavGrid>
);
}
// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one —
// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an
// empty collection. Creating opens the new collection's detail page (onCreated → onPick).
function NewCollectionPanel({
existingNames,
onCreated,
onClose,
}: {
existingNames: Set<string>;
onCreated: (rk: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [term, setTerm] = useState("");
const [busy, setBusy] = useState(false);
const trimmed = name.trim();
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
const nameOk = trimmed.length > 0;
async function create(card: PlexCard) {
if (!nameOk || busy) return;
setBusy(true);
try {
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
onCreated(col.id);
} catch (e) {
// The backend is authoritative on per-library name uniqueness — a taken name comes back
// structured, and so localized (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed"));
setBusy(false);
}
}
return (
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollectionTitle")}</h3>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
</div>
<Input
className="w-full"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("plex.collection.namePlaceholder")}
aria-label={t("plex.collection.newCollection")}
/>
{dupHint ? (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
) : (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
)}
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted" />
<Input
className="flex-1"
value={term}
onChange={(e) => setTerm(e.target.value)}
disabled={!nameOk}
placeholder={t("plex.collection.addSearch")}
aria-label={t("plex.collection.addSearch")}
/>
</div>
<PlexLibraryPicker
scope="both"
term={term}
enabled={nameOk}
renderAction={(c) => (
<Button
variant="glass"
size="xs"
icon={<Check />}
onClick={() => create(c)}
loading={busy}
>
{t("plex.collection.createWith")}
</Button>
)}
/>
</div>
);
}
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const [search, setSearch] = useState("");
const [creating, setCreating] = useState(false);
const searchDeb = useDebounced(search.trim(), 300);
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
});
const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]);
const existingNames = useMemo(
() => new Set(collections.map((c) => c.title.trim().toLowerCase())),
[collections],
);
return (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
className="w-full max-w-sm"
size="sm"
pill
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
aria-label={t("plex.filter.collectionSearch")}
/>
{isAdmin && !creating && (
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
{t("plex.collection.newCollection")}
</Button>
)}
</div>
{creating && (
<NewCollectionPanel
existingNames={existingNames}
onCreated={(rk) => {
setCreating(false);
onPick(rk, "");
}}
onClose={() => setCreating(false)}
/>
)}
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState shape="poster" label={t("plex.loading")} />
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<NavGrid className={GRID}>
{collections.map((c, i) => (
<PosterTile
key={c.id}
navIndex={i}
title={c.title}
art={c.art}
subtitle={collectionMeta(c, t)}
topRight={
isIntrinsicReadOnly(c) ? (
<Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")}
/>
) : undefined
}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>
))}
</NavGrid>
)}
</div>
);
}