From 3553eac8c44c4066bc758bbf0f45dbe0dae93f05 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 23 Oct 2026 18:51:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20upload=20a=20Google=20Takeout?= =?UTF-8?q?=20export=20in=20Settings=20=E2=80=BA=20Account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "YouTube history" section under YouTube access: the watch history and the Watch later csv are dropped or picked (one request each, each file's outcome said on its own), and the section then says what the import did - only the counts it produced - or that it is running, failed or was cut off by a restart. While it runs it asks again until it ends; a failed re-read shows the failure, not the previous answer. The drop zone the Files upload had inline is now ui/DropZone, used by both. The e2e seed clears the account's kept Takeout videos. --- frontend/e2e/takeout.spec.ts | 59 +++++ .../src/components/ui/DropZone.dom.test.tsx | 64 ++++++ frontend/src/components/ui/DropZone.tsx | 97 +++++++++ frontend/src/components/ui/README.md | 14 +- .../ui/pressableSelectable.contract.test.ts | 2 +- frontend/src/i18n/locales/en/errors.json | 1 + frontend/src/i18n/locales/en/settings.json | 35 +++ frontend/src/i18n/locales/hu/errors.json | 1 + frontend/src/i18n/locales/hu/settings.json | 35 +++ frontend/src/lib/api/feed.ts | 34 ++- frontend/src/lib/queryKeys.ts | 1 + frontend/src/modules/files/UploadDialog.tsx | 100 +++------ .../src/modules/settings/SettingsPanel.tsx | 3 + .../settings/TakeoutImport.dom.test.tsx | 203 ++++++++++++++++++ .../src/modules/settings/TakeoutImport.tsx | 179 +++++++++++++++ scripts/e2e_seed.py | 5 +- 16 files changed, 749 insertions(+), 84 deletions(-) create mode 100644 frontend/e2e/takeout.spec.ts create mode 100644 frontend/src/components/ui/DropZone.dom.test.tsx create mode 100644 frontend/src/components/ui/DropZone.tsx create mode 100644 frontend/src/modules/settings/TakeoutImport.dom.test.tsx create mode 100644 frontend/src/modules/settings/TakeoutImport.tsx 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 = [