The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
import { useLayoutEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { ListPlus, Plus } from "lucide-react";
|
|
import { YouTubeIcon } from "../lib/brandIcons";
|
|
import { MenuCheck } from "./ui/MenuCheck";
|
|
import { api, type Playlist } from "../lib/api";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { playlistName } from "../lib/playlistName";
|
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
|
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
|
|
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
|
|
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
|
export default function AddToPlaylist({
|
|
videoId,
|
|
variant = "ghost",
|
|
size = "sm",
|
|
}: {
|
|
videoId: string;
|
|
variant?: "ghost" | "outline" | "overlay" | "media";
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const pop = useMembershipPopover(open, setOpen);
|
|
const { setReference } = pop.refs;
|
|
return (
|
|
<>
|
|
<IconButton
|
|
ref={setReference}
|
|
variant={variant}
|
|
size={size}
|
|
{...pop.getReferenceProps({
|
|
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
|
// reach it.
|
|
onClick: (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
})}
|
|
label={t("playlists.addToPlaylist")}
|
|
>
|
|
<ListPlus />
|
|
</IconButton>
|
|
<MembershipPanel videoId={videoId} pop={pop} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The same panel, opened from an object's menu ("Add to playlist…") rather than its own
|
|
* button: anchored to the element the menu came from, and gone when it closes. */
|
|
export function AddToPlaylistAt({
|
|
videoId,
|
|
anchor,
|
|
onClose,
|
|
}: {
|
|
videoId: string;
|
|
anchor: Element;
|
|
onClose: () => void;
|
|
}) {
|
|
const pop = useMembershipPopover(true, (next) => {
|
|
if (!next) onClose();
|
|
});
|
|
const { setPositionReference } = pop.refs;
|
|
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
|
|
return <MembershipPanel videoId={videoId} pop={pop} />;
|
|
}
|
|
|
|
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
|
|
return useAnchoredPopover({
|
|
open,
|
|
onOpenChange,
|
|
offset: 6,
|
|
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
|
// roving list so they don't steal the caret.
|
|
hostsInput: true,
|
|
});
|
|
}
|
|
|
|
function MembershipPanel({
|
|
videoId,
|
|
pop,
|
|
}: {
|
|
videoId: string;
|
|
pop: ReturnType<typeof useAnchoredPopover>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
const open = pop.open;
|
|
const [newName, setNewName] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const membership = useQuery({
|
|
queryKey: qk.playlistMembership(videoId),
|
|
queryFn: () => api.playlists(videoId),
|
|
enabled: open,
|
|
});
|
|
|
|
function refresh() {
|
|
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
// Also invalidate every open/cached playlist detail (["playlist", id]) so the
|
|
// Playlists page reflects the add/remove when navigated to, not only after F5.
|
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
}
|
|
|
|
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
|
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
|
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
|
|
async function toggle(pl: Playlist) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
|
else await api.addToPlaylist(pl.id, videoId);
|
|
} catch (e) {
|
|
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
|
}
|
|
refresh();
|
|
setBusy(false);
|
|
}
|
|
|
|
async function createAndAdd() {
|
|
const name = newName.trim();
|
|
if (!name || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
const pl = await api.createPlaylist(name);
|
|
await api.addToPlaylist(pl.id, videoId);
|
|
setNewName("");
|
|
} catch (e) {
|
|
notifyActionError(e, t("playlists.addFailed"));
|
|
}
|
|
refresh();
|
|
setBusy(false);
|
|
}
|
|
|
|
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
|
// marks them dirty, and a later "Sync to YouTube" pushes the change back.
|
|
const lists = membership.data ?? [];
|
|
|
|
return (
|
|
<>
|
|
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
|
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
|
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<div className="text-xs text-muted px-1.5 pb-1.5">
|
|
{t("playlists.addToPlaylistTitle")}
|
|
</div>
|
|
<div
|
|
ref={fadeRef}
|
|
style={fadeStyle}
|
|
aria-busy={busy}
|
|
className="max-h-56 overflow-y-auto no-scrollbar"
|
|
>
|
|
{lists.length === 0 && !membership.isLoading && (
|
|
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
|
)}
|
|
{lists.map((pl) => (
|
|
<PopoverItem
|
|
key={pl.id}
|
|
// A video can sit in several playlists at once — checkboxes, not one choice.
|
|
role="menuitemcheckbox"
|
|
checked={pl.has_video}
|
|
onSelect={() => toggle(pl)}
|
|
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
|
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
|
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
|
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
|
label={playlistName(pl, t)}
|
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
|
>
|
|
<MenuCheck kind="many" on={!!pl.has_video} />
|
|
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
|
|
)}
|
|
</PopoverItem>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
|
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
|
<Input
|
|
className="flex-1"
|
|
size="sm"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
// Escape bubbles to the popover's dismiss; every other key is text editing here and
|
|
// must not reach the surrounding menu's list navigation.
|
|
if (e.key === "Escape") return;
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
void createAndAdd();
|
|
}
|
|
}}
|
|
placeholder={t("playlists.newPlaylist")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|