diff --git a/frontend/src/components/PlaylistLimitsHint.dom.test.tsx b/frontend/src/components/PlaylistLimitsHint.dom.test.tsx new file mode 100644 index 00000000..092ad38b --- /dev/null +++ b/frontend/src/components/PlaylistLimitsHint.dom.test.tsx @@ -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( + + + , + ); + 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}`); + }); +}); diff --git a/frontend/src/components/PlaylistLimitsHint.tsx b/frontend/src/components/PlaylistLimitsHint.tsx new file mode 100644 index 00000000..e9682ce3 --- /dev/null +++ b/frontend/src/components/PlaylistLimitsHint.tsx @@ -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 ( +
+
+ ); +} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index ad6ce147..10c32ba5 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -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({ )} + ), }, diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index d4483693..67276bfc 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -118,5 +118,13 @@ "viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.", "viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.", "viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on.", - "addFailed": "Couldn't add it to the playlist." + "addFailed": "Couldn't add it to the playlist.", + "limits": { + "hint": "Missing a list?", + "why": "Why?", + "title": "What YouTube lets Siftlode read", + "closed": "YouTube's own Watch later and History have been closed to its API since 2016, so no app can read them. The Watch later list here is Siftlode's own.", + "ownOnly": "Only playlists you own sync. Lists you saved from other channels are not returned.", + "noChannel": "A Google account with no YouTube channel has no playlists at all, as far as the API is concerned." + } } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 8b6c104d..45be5d2c 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -118,5 +118,13 @@ "viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.", "viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.", "viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető.", - "addFailed": "Nem sikerült hozzáadni a lejátszási listához." + "addFailed": "Nem sikerült hozzáadni a lejátszási listához.", + "limits": { + "hint": "Hiányzik egy lista?", + "why": "Miért?", + "title": "Amit a YouTube kiad a Siftlode-nak", + "closed": "A YouTube saját „Megnézem később” listája és az Előzmények 2016 óta zárva vannak az API előtt, így egyik alkalmazás sem olvashatja őket. Az itteni „Megnézem később” lista a Siftlode saját listája.", + "ownOnly": "Csak a saját listáid szinkronizálódnak. A más csatornáktól elmentett listákat az API nem adja vissza.", + "noChannel": "Ha a Google-fiókodhoz nem tartozik YouTube-csatorna, az API szerint egy listád sincs." + } }