feat(playlists): say which lists YouTube's API cannot give us
The module shows fewer lists than youtube.com, and without a word about it that read as a Siftlode bug (the owner's ask, 2026-09-03). Owner's pick from the S7 mockups (2026-09-19): one quiet line at the foot of the rail, "Missing a list? Why?", whose Why? opens three facts — YouTube's own Watch later and History are closed to its API (the Watch later list here is Siftlode's own), only lists you own sync, and an account with no YouTube channel has no playlists at all.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The playlists module shows fewer lists than youtube.com, and said nothing about why, so it read as
|
||||
// a Siftlode bug (debt burn A3). One quiet line at the foot of the rail; "Why?" opens the three facts.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||
|
||||
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, and query with
|
||||
// `hidden: true` — floating-ui never positions in jsdom, so the surface stays visibility:hidden.
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlaylistLimitsHint", () => {
|
||||
it("keeps the facts behind Why?, and opens them as a named dialog", async () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<PlaylistLimitsHint />
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByText("playlists.limits.hint")).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "playlists.limits.why" }));
|
||||
const dialog = await screen.findByRole("dialog", { hidden: true });
|
||||
// Read off the attribute: jsdom computes no name for a visibility:hidden element.
|
||||
expect(dialog.getAttribute("aria-label")).toBe("playlists.limits.title");
|
||||
for (const fact of ["closed", "ownOnly", "noChannel"])
|
||||
expect(dialog.textContent).toContain(`playlists.limits.${fact}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
|
||||
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
|
||||
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
|
||||
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the S7 mockups
|
||||
// (2026-09-19): always there to find again, never in the way.
|
||||
export default function PlaylistLimitsHint() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "right-end",
|
||||
offset: 8,
|
||||
});
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 px-1 pt-2 text-2xs text-muted">
|
||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("playlists.limits.hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t("playlists.limits.why")}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("playlists.limits.title")}
|
||||
className="glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<p className="mb-2 font-semibold">{t("playlists.limits.title")}</p>
|
||||
<ul className="list-disc space-y-1.5 pl-4 text-muted">
|
||||
<li>{t("playlists.limits.closed")}</li>
|
||||
<li>{t("playlists.limits.ownOnly")}</li>
|
||||
<li>{t("playlists.limits.noChannel")}</li>
|
||||
</ul>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import PlaylistLimitsHint from "./PlaylistLimitsHint";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -382,6 +383,7 @@ export default function PlaylistsRail({
|
||||
)}
|
||||
</div>
|
||||
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
|
||||
<PlaylistLimitsHint />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user