diff --git a/frontend/e2e/takeout.spec.ts b/frontend/e2e/takeout.spec.ts new file mode 100644 index 00000000..1794f171 --- /dev/null +++ b/frontend/e2e/takeout.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { openApp, gotoPage } from "./helpers"; + +// Settings › Account › YouTube history: a Google Takeout watch history, uploaded through the real +// picker, is read, imported in the background, and the section says what it did — a video the +// catalog knows is marked watched, one it does not know is remembered for later. The seed clears +// the account's kept Takeout videos each run, but the projects share one account: a later one's +// feed no longer holds the video an earlier one marked, so what it adds is counted against what was +// there before it. +test("a Takeout watch history uploads, imports, and the section says what it did", async ({ + page, +}) => { + await openApp(page); + // A video the catalog knows: one on the account's feed (not a Short — the feed leaves them out). + const known = await page.getByTestId("video-card").first().getAttribute("data-video-id"); + expect(known).toBeTruthy(); + + await gotoPage(page, "settings"); + await page.getByRole("tab", { name: "Account" }).click(); + await expect(page.getByRole("heading", { name: "YouTube history" })).toBeVisible(); + // The first project's upload is an import already made: its "New import" opens the drop zone. + const zone = page.getByRole("button", { name: "Drop the files here, or choose them" }); + const again = page.getByRole("button", { name: "New import" }); + await expect(zone.or(again)).toBeVisible(); + if (await again.isVisible()) await again.click(); + await expect(zone).toBeVisible(); + const before = (await (await page.request.get("/api/takeout")).json()) as { + watched?: Record; + }; + const watchedBefore = before.watched?.watched ?? 0; + + // Watched now: later than any state the seed set, which the import would otherwise leave alone. + const now = new Date().toISOString(); + const history = [ + { titleUrl: `https://www.youtube.com/watch?v=${known}`, time: now }, + { titleUrl: "https://www.youtube.com/watch?v=e2eUnknown0", time: now }, + { + titleUrl: "https://www.youtube.com/watch?v=e2eAdvert00", + time: now, + details: [{ name: "From Google Ads" }], + }, + ]; + const uploaded = page.waitForResponse( + (r) => r.url().endsWith("/api/takeout") && r.request().method() === "POST", + ); + await page.locator('input[type="file"][accept=".json,.csv"]').setInputFiles({ + name: "watch-history.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(history)), + }); + expect((await uploaded).ok()).toBeTruthy(); + + await expect(page.getByText("watch-history.json: 2 videos read")).toBeVisible(); + const line = page.getByTestId("takeout-last"); + await expect(line).toContainText("Last import:"); + await expect(line).toContainText(`${watchedBefore + 1} watched`); + await expect(line).toContainText("1 remembered"); + await expect(page.getByRole("button", { name: "New import" })).toBeVisible(); +}); diff --git a/frontend/src/components/ui/DropZone.dom.test.tsx b/frontend/src/components/ui/DropZone.dom.test.tsx new file mode 100644 index 00000000..affa0212 --- /dev/null +++ b/frontend/src/components/ui/DropZone.dom.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +// +// ui/DropZone: a drop or a pick hands over the files (only the first unless `multiple`), the drag +// highlight survives the cursor crossing the zone's own children, and a disabled zone takes nothing. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { DropZone } from "./DropZone"; + +afterEach(cleanup); + +const files = (...names: string[]) => names.map((n) => new File(["x"], n)); +function mount(props: Partial[0]> = {}) { + const onFiles = vi.fn(); + render( + + {(over) => {over ? "over" : "idle"}} + , + ); + return { onFiles, zone: screen.getByRole("button"), inner: screen.getByTestId("inner") }; +} + +describe("DropZone", () => { + it("a drop hands over the first file, or all of them when multiple", () => { + const one = mount(); + fireEvent.drop(one.zone, { dataTransfer: { files: files("a", "b") } }); + expect(one.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a"]); + cleanup(); + const many = mount({ multiple: true }); + fireEvent.drop(many.zone, { dataTransfer: { files: files("a", "b") } }); + expect(many.onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["a", "b"]); + }); + + it("a pick hands over the picked files; an empty pick hands over nothing", () => { + const { onFiles } = mount({ multiple: true }); + const input = document.querySelector('input[type="file"]')!; + expect(input.multiple).toBe(true); + fireEvent.change(input, { target: { files: [] } }); + expect(onFiles).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { files: files("c") } }); + expect(onFiles.mock.calls[0]![0].map((f: File) => f.name)).toEqual(["c"]); + }); + + it("stays highlighted while the cursor crosses its own children, and clears on the last leave", () => { + const { zone, inner } = mount(); + fireEvent.dragEnter(zone); + fireEvent.dragEnter(inner); // into a child: the zone gets its leave after this enter + fireEvent.dragLeave(zone); + expect(inner.textContent).toBe("over"); + fireEvent.dragLeave(inner); + expect(inner.textContent).toBe("idle"); + }); + + it("a disabled zone takes no drop", () => { + const { zone, onFiles } = mount({ disabled: true }); + fireEvent.drop(zone, { dataTransfer: { files: files("a") } }); + expect(onFiles).not.toHaveBeenCalled(); + }); + + it("an invalid zone reads as invalid and points at the reason", () => { + const { zone } = mount({ invalid: true, describedBy: "why" }); + expect(zone.getAttribute("aria-invalid")).toBe("true"); + expect(zone.getAttribute("aria-describedby")).toBe("why"); + }); +}); diff --git a/frontend/src/components/ui/DropZone.tsx b/frontend/src/components/ui/DropZone.tsx new file mode 100644 index 00000000..ada39182 --- /dev/null +++ b/frontend/src/components/ui/DropZone.tsx @@ -0,0 +1,97 @@ +import { useRef, useState, type ReactNode } from "react"; +import clsx from "clsx"; +import { Pressable } from "./Pressable"; + +// A place to drop files onto, which is also the button that opens the native file dialog: the +// dashed surface an upload starts from. It owns what each such surface needed and the first one +// got right only after a flicker: dragenter/dragleave fire as the cursor crosses the zone's OWN +// children (an icon, a line of text), so a plain boolean flickers — enter and leave are counted +// and the highlight clears at depth 0. The caller draws the inside, told whether a drag is over it. +export function DropZone({ + onFiles, + multiple = false, + accept, + disabled = false, + invalid = false, + describedBy, + className, + children, +}: { + /** The files dropped or picked, never empty. One unless `multiple`. */ + onFiles: (files: [File, ...File[]]) => void; + multiple?: boolean; + /** The picker's filter (``); a drop is not filtered by it. */ + accept?: string; + disabled?: boolean; + /** What the zone holds is refused: the border turns danger and `describedBy` says why. */ + invalid?: boolean; + describedBy?: string; + /** Lays the zone out (its padding); the border and the drag paint are the zone's. */ + className?: string; + children: (dragOver: boolean) => ReactNode; +}) { + const [dragOver, setDragOver] = useState(false); + const depth = useRef(0); + const inputRef = useRef(null); + const take = (list: FileList | null | undefined) => { + const [first, ...rest] = [...(list ?? [])]; + if (first) onFiles(multiple ? [first, ...rest] : [first]); + }; + return ( + <> + inputRef.current?.click()} + onDragEnter={(e) => { + e.preventDefault(); + depth.current += 1; + setDragOver(true); + }} + onDragOver={(e) => e.preventDefault()} + onDragLeave={() => { + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragOver(false); + }} + onDrop={(e) => { + e.preventDefault(); + depth.current = 0; + setDragOver(false); + if (!disabled) take(e.dataTransfer.files); + }} + disabled={disabled} + // The refusal is tied to the control it invalidates, not left to a one-shot announcement: + // the zone reads as invalid and points at the sentence, so a reader who tabs back to it + // hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported list: + // Chromium's accessibility node for a native button does carry `invalid` from it.) The + // signal that is NOT colour is the caller's sentence — the border only reinforces it. + aria-invalid={invalid} + aria-describedby={describedBy} + // `dragOver` outranks `invalid`: a drag is transient and the refusal repaints the instant + // the drop lands, whereas a zone that stays red under the pointer tells the user their drop + // target is dead — in the one state where they are trying to replace the file. + className={clsx( + "w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition disabled:opacity-60", + dragOver + ? "border-accent bg-accent/5" + : invalid + ? "border-danger" + : "border-border hover:border-fg/40", + className, + )} + > + {children(dragOver)} + + { + take(e.target.files); + e.target.value = ""; // let re-picking the same file fire onChange again + }} + /> + + ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 2253e6a0..8dd13ac4 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage ## What lives here -| area | components | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) | -| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw ` + )} + + ) + )} + + ); +} diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py index 5d545baf..c4a1be6c 100644 --- a/scripts/e2e_seed.py +++ b/scripts/e2e_seed.py @@ -28,7 +28,7 @@ from app.db import SessionLocal import app.tables # noqa: F401 — every table, before the first query from app.catalog.models import Channel, Subscription, Video from app.models import AppState, User -from app.reader.models import Playlist, PlaylistItem, VideoState +from app.reader.models import Playlist, PlaylistItem, TakeoutItem, VideoState from app.security import hash_password EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local") @@ -118,6 +118,9 @@ def _seed_account(db, email: str, role: str, display_name: str) -> dict: # - one WATCHED video from the second channel, so the "watched" show-filter is a # real, non-empty narrowing of the feed (and the "all" vs "unwatched" split exists). db.execute(delete(VideoState).where(VideoState.user_id == user.id)) + # A Takeout import's kept videos too: a settled row keeps its outcome over a re-upload, so the + # import spec's counts would otherwise be the previous run's, not this one's. + db.execute(delete(TakeoutItem).where(TakeoutItem.user_id == user.id)) def _newest(channel_id: str, exclude: set[str]) -> Video | None: conds = [