feat(settings): upload a Google Takeout export in Settings › Account
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.
This commit is contained in:
@@ -15,7 +15,7 @@ import type { DownloadJob } from "../../lib/api";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { DropZone } from "../../components/ui/DropZone";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
|
||||
// Local-file ingest. A general upload — ANY file type, no format list: media
|
||||
@@ -46,13 +46,8 @@ export default function UploadDialog({
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(initialFile);
|
||||
const [name, setName] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||
const dragDepth = useRef(0);
|
||||
const busy = pct !== null;
|
||||
// The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up
|
||||
// the wire. Both server-side caps are still the boundary — this is only the pleasant half of it.
|
||||
@@ -86,8 +81,6 @@ export default function UploadDialog({
|
||||
// footer Cancel wired the abort; this covers every other dismissal.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
|
||||
// button holds focus it swallows the activation itself, and while it does not it is natively
|
||||
@@ -138,74 +131,33 @@ export default function UploadDialog({
|
||||
<Modal initialFocus="field" title={t("downloads.upload.title")} onClose={onClose}>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
|
||||
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={pick}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) pick_(f);
|
||||
}}
|
||||
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog.
|
||||
With a file picked, its label shows the file's name rather than `dropActive`, so the
|
||||
zone's drag border is the only feedback that it will take a replacement. */}
|
||||
<DropZone
|
||||
className="px-4 py-8"
|
||||
onFiles={([f]) => pick_(f)}
|
||||
disabled={busy}
|
||||
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
|
||||
// the drop 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 this
|
||||
// attribute.) The signal that is NOT colour is the sentence below, visible to everyone —
|
||||
// the border only reinforces it.
|
||||
aria-invalid={tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
// `dragOver` outranks `tooBig`: 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. With
|
||||
// a file picked, the label below shows its name rather than `dropActive`, so this border
|
||||
// is the ONLY feedback that the zone will accept the drop.
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver
|
||||
? "border-accent bg-accent/5"
|
||||
: tooBig
|
||||
? "border-danger"
|
||||
: "border-border hover:border-fg/40"
|
||||
}`}
|
||||
invalid={tooBig}
|
||||
describedBy={tooBig ? refusalId : undefined}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-7 h-7 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) pick_(f);
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
{(dragOver) =>
|
||||
file ? (
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-7 h-7 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</DropZone>
|
||||
|
||||
{tooBig && (
|
||||
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { DownloadFormats } from "./ProfileEditor";
|
||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||
import ApiTokens from "./ApiTokens";
|
||||
import TakeoutImport from "./TakeoutImport";
|
||||
import { type SettingsTabId } from "../../lib/settingsTab";
|
||||
import { SCHEMES, type Scheme } from "../../lib/theme";
|
||||
import { api, HttpError, type Me } from "../../lib/api";
|
||||
@@ -870,6 +871,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<TakeoutImport />
|
||||
|
||||
<Section title={t("settings.account.dangerZone")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">
|
||||
{t("settings.account.deleteHint")}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Settings › Account › YouTube history: the Google Takeout files are dropped (or picked) and sent one
|
||||
// request each, every file's outcome said on its own; the section then says what the import did —
|
||||
// only the counts it produced, history first — or that it is running, failed or was cut off. Before
|
||||
// the first import the drop zone is open; after one, "New import" opens it. i18n is key-passthrough
|
||||
// (a key's options ride along in brackets).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, HttpError, type TakeoutStatus, type TakeoutUpload } from "../../lib/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ notify: vi.fn(), notifyActionError: vi.fn() }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: Record<string, unknown>) =>
|
||||
o && Object.keys(o).length ? `${key}(${Object.values(o).join(",")})` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: mocks.notify,
|
||||
}));
|
||||
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: mocks.notifyActionError }));
|
||||
vi.mock("../../lib/format", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/format")>()),
|
||||
formatDateTime: (iso: string) => `at ${iso}`,
|
||||
}));
|
||||
|
||||
import TakeoutImport from "./TakeoutImport";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mocks.notify.mockClear();
|
||||
mocks.notifyActionError.mockClear();
|
||||
});
|
||||
|
||||
const run = (status: string, extra: Partial<NonNullable<TakeoutStatus["run"]>> = {}) => ({
|
||||
status: status as NonNullable<TakeoutStatus["run"]>["status"],
|
||||
started_at: "S",
|
||||
finished_at: status === "running" || status === "interrupted" ? null : "F",
|
||||
result: null,
|
||||
failure: null,
|
||||
...extra,
|
||||
});
|
||||
const uploaded = (videos: number): TakeoutUpload => ({
|
||||
ok: true,
|
||||
kind: "watched",
|
||||
videos,
|
||||
entries: videos,
|
||||
ads: 0,
|
||||
other: 0,
|
||||
malformed: 0,
|
||||
});
|
||||
|
||||
function mount(status: TakeoutStatus | Error | Promise<never>) {
|
||||
const spy = vi.spyOn(api, "takeoutStatus");
|
||||
if (status instanceof Error) spy.mockRejectedValue(status);
|
||||
else if (status instanceof Promise) spy.mockReturnValue(status);
|
||||
else spy.mockResolvedValue(status);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<TakeoutImport />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return spy;
|
||||
}
|
||||
|
||||
const zone = () => screen.queryByRole("button", { name: /settings\.takeout\.drop/ });
|
||||
const line = () => screen.getByTestId("takeout-last").textContent;
|
||||
const drop = (files: File[]) =>
|
||||
act(async () => {
|
||||
fireEvent.drop(zone()!, { dataTransfer: { files } });
|
||||
});
|
||||
const file = (name: string) => new File(["[]"], name);
|
||||
|
||||
describe("YouTube history import · what it says", () => {
|
||||
it("before the first import: the drop zone, and no line about an import", async () => {
|
||||
mount({ run: null, watched: {}, watch_later: {} });
|
||||
await waitFor(() => expect(zone()).toBeTruthy());
|
||||
expect(screen.queryByTestId("takeout-last")).toBeNull();
|
||||
});
|
||||
|
||||
it("not while the last import is still being asked for", () => {
|
||||
mount(new Promise<never>(() => {}));
|
||||
expect(zone()).toBeNull();
|
||||
});
|
||||
|
||||
it("after one: when, then only the counts it produced, the history's first", async () => {
|
||||
mount({
|
||||
run: run("ok"),
|
||||
watched: { watched: 1020, waiting: 6785, short: 0, kept: 81 },
|
||||
watch_later: { added: 41, waiting: 2 },
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("takeout-last")).toBeTruthy());
|
||||
expect(line()).toBe(
|
||||
[
|
||||
"settings.takeout.last(at F)",
|
||||
"settings.takeout.count.watched(1020,1,020)",
|
||||
"settings.takeout.count.waiting(6785,6,785)",
|
||||
"settings.takeout.count.kept(81,81)",
|
||||
"settings.takeout.count.added(41,41)",
|
||||
"settings.takeout.count.laterWaiting(2,2)",
|
||||
].join(" · "),
|
||||
);
|
||||
expect(zone()).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
|
||||
expect(zone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a failed run and one a restart cut off say so, not counts", async () => {
|
||||
mount({ run: run("error"), watched: { watched: 3 } });
|
||||
await waitFor(() => expect(line()).toBe("settings.takeout.failed(at F)"));
|
||||
cleanup();
|
||||
mount({ run: run("interrupted"), watched: { watched: 3 } });
|
||||
await waitFor(() => expect(line()).toBe("settings.takeout.interrupted(at S)"));
|
||||
});
|
||||
|
||||
it("a running import: the running line, no drop zone, asked again until it ends", async () => {
|
||||
const spy = mount({ run: run("running"), watched: {} });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("takeout-running").textContent).toBe("settings.takeout.running"),
|
||||
);
|
||||
expect(zone()).toBeNull();
|
||||
spy.mockResolvedValue({ run: run("ok"), watched: { watched: 5 } });
|
||||
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(5,5)"), {
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("a status that could not be read says so, with a retry — not an empty section", async () => {
|
||||
mount(new HttpError(500, "boom"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain("settings.takeout.loadFailed"),
|
||||
);
|
||||
expect(zone()).toBeNull();
|
||||
expect(screen.queryByTestId("takeout-last")).toBeNull();
|
||||
});
|
||||
|
||||
it("a re-read that fails does not leave the previous answer standing under it", async () => {
|
||||
const status = mount({ run: run("ok", { finished_at: "OLD" }), watched: {} });
|
||||
await waitFor(() => expect(line()).toContain("OLD"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
|
||||
vi.spyOn(api, "takeoutUpload").mockResolvedValue(uploaded(1));
|
||||
status.mockRejectedValue(new HttpError(500, "boom"));
|
||||
await drop([file("watch-history.json")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain("settings.takeout.loadFailed"),
|
||||
);
|
||||
expect(screen.queryByTestId("takeout-last")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("YouTube history import · the upload", () => {
|
||||
it("sends each dropped file on its own, says each outcome, and reads the new run", async () => {
|
||||
const status = mount({ run: null, watched: {} });
|
||||
await waitFor(() => expect(zone()).toBeTruthy());
|
||||
const refused = new HttpError(422, "refused");
|
||||
const sent: string[] = [];
|
||||
vi.spyOn(api, "takeoutUpload").mockImplementation(async (f: File) => {
|
||||
sent.push(f.name);
|
||||
if (f.name === "bad.html") throw refused;
|
||||
return uploaded(f.name === "watch-history.json" ? 9034 : 56);
|
||||
});
|
||||
status.mockResolvedValue({ run: run("ok"), watched: { watched: 7 } });
|
||||
await drop([file("watch-history.json"), file("bad.html"), file("Watch later-videos.csv")]);
|
||||
|
||||
await waitFor(() => expect(line()).toContain("settings.takeout.count.watched(7,7)"));
|
||||
expect(sent).toEqual(["watch-history.json", "bad.html", "Watch later-videos.csv"]);
|
||||
expect(mocks.notify.mock.calls.map(([n]) => (n as { message: string }).message)).toEqual([
|
||||
"settings.takeout.read(watch-history.json,9034,9,034)",
|
||||
"settings.takeout.read(Watch later-videos.csv,56,56)",
|
||||
]);
|
||||
expect(mocks.notifyActionError).toHaveBeenCalledWith(
|
||||
refused,
|
||||
"errors.writeFailed.takeoutUpload",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the file being sent, then never shows the previous import's line before the new one", async () => {
|
||||
const status = mount({ run: run("ok", { finished_at: "OLD" }), watched: {} });
|
||||
await waitFor(() => expect(line()).toContain("OLD"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.takeout.newImport" }));
|
||||
let finish!: (v: TakeoutUpload) => void;
|
||||
vi.spyOn(api, "takeoutUpload").mockReturnValue(new Promise((r) => (finish = r)));
|
||||
let answer!: (v: TakeoutStatus) => void;
|
||||
status.mockReturnValue(new Promise((r) => (answer = r)));
|
||||
await drop([file("watch-history.json")]);
|
||||
expect(screen.getByTestId("takeout-running").textContent).toBe(
|
||||
"settings.takeout.uploading(watch-history.json)",
|
||||
);
|
||||
await act(async () => finish(uploaded(1)));
|
||||
// The new run is still being read: the upload's line stays, the old import's does not return.
|
||||
expect(screen.queryByTestId("takeout-last")).toBeNull();
|
||||
await act(async () => answer({ run: run("ok", { finished_at: "NEW" }), watched: {} }));
|
||||
await waitFor(() => expect(line()).toContain("NEW"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, UploadCloud } from "lucide-react";
|
||||
import { Section } from "../../components/ui/form";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { DropZone } from "../../components/ui/DropZone";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { api, type TakeoutStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatDateTime, formatNumber } from "../../lib/format";
|
||||
|
||||
// Settings › Account: the user's Google Takeout export, imported. What they watched on YouTube and
|
||||
// their YouTube Watch later list reach no API, so the export's two files are uploaded here, one
|
||||
// request each; the server tells them apart and runs the import in the background, and its outcome
|
||||
// also reaches the inbox. The import has no steps to count (the whole history is applied in one
|
||||
// go), so while it runs the section says so and asks again until it is done.
|
||||
|
||||
const POLL_MS = 1500;
|
||||
|
||||
// The outcome counts, in the order they are said: the history's, then Watch later's. A count the
|
||||
// import did not produce is not said at all.
|
||||
const HISTORY = [
|
||||
["watched", "watched"],
|
||||
["waiting", "waiting"],
|
||||
["short", "short"],
|
||||
["kept", "kept"],
|
||||
] as const;
|
||||
const WATCH_LATER = [
|
||||
["added", "added"],
|
||||
["present", "present"],
|
||||
["missing", "missing"],
|
||||
["waiting", "laterWaiting"],
|
||||
] as const;
|
||||
|
||||
function countParts(s: TakeoutStatus, say: (key: string, n: number) => string): string[] {
|
||||
const parts: string[] = [];
|
||||
for (const [outcome, key] of HISTORY) {
|
||||
const n = s.watched?.[outcome] ?? 0;
|
||||
if (n) parts.push(say(key, n));
|
||||
}
|
||||
for (const [outcome, key] of WATCH_LATER) {
|
||||
const n = s.watch_later?.[outcome] ?? 0;
|
||||
if (n) parts.push(say(key, n));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export default function TakeoutImport() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const status = useQuery({
|
||||
queryKey: qk.takeout(),
|
||||
queryFn: api.takeoutStatus,
|
||||
refetchInterval: (q) => (q.state.data?.run?.status === "running" ? POLL_MS : false),
|
||||
});
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
// "New import" reopens the drop zone over a finished one; before the first import it is open.
|
||||
const [reopened, setReopened] = useState(false);
|
||||
const run = status.data?.run ?? null;
|
||||
const running = uploading !== null || run?.status === "running";
|
||||
const finishedAt = run?.finished_at ?? null;
|
||||
|
||||
// A run that ended has changed what the feed and Watch later show: those lists are read again
|
||||
// when the user next opens them, not 30 s later from the cache.
|
||||
useEffect(() => {
|
||||
if (!finishedAt) return;
|
||||
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
void qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
}, [finishedAt, qc]);
|
||||
|
||||
const upload = async (files: File[]) => {
|
||||
for (const file of files) await uploadOne(file);
|
||||
// The new run read first, then the upload's line let go: the other way round the previous
|
||||
// import's line showed for a moment, as if this upload had done nothing.
|
||||
await qc.invalidateQueries({ queryKey: qk.takeout() });
|
||||
setUploading(null);
|
||||
setReopened(false);
|
||||
};
|
||||
const uploadOne = async (file: File) => {
|
||||
setUploading(file.name);
|
||||
try {
|
||||
const res = await api.takeoutUpload(file);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("settings.takeout.read", {
|
||||
name: file.name,
|
||||
count: res.videos,
|
||||
n: formatNumber(res.videos, lang),
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
// A refusal names its reason (not a Takeout file, nothing in it); the next file still goes.
|
||||
notifyActionError(e, t("errors.writeFailed.takeoutUpload"));
|
||||
}
|
||||
};
|
||||
|
||||
const say = (key: string, n: number) =>
|
||||
t(`settings.takeout.count.${key}`, { count: n, n: formatNumber(n, lang) });
|
||||
const when = (iso: string | null) => (iso ? formatDateTime(iso, lang) : "");
|
||||
// Not while the last import is still being asked for: a zone that flashes before the line that
|
||||
// replaces it would offer a first import to someone who has done one.
|
||||
const showZone = status.isSuccess && !running && (run === null || reopened);
|
||||
|
||||
return (
|
||||
<Section title={t("settings.takeout.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.takeout.intro")}</p>
|
||||
|
||||
{status.isError && (
|
||||
<StateMessage compact tone="error" onRetry={() => void status.refetch()}>
|
||||
{t("settings.takeout.loadFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
|
||||
{showZone && (
|
||||
<>
|
||||
<DropZone className="px-4 py-5" multiple accept=".json,.csv" onFiles={upload}>
|
||||
{(dragOver) => (
|
||||
<>
|
||||
<UploadCloud className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm text-muted">
|
||||
{dragOver ? t("settings.takeout.dropActive") : t("settings.takeout.drop")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DropZone>
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-muted cursor-pointer">
|
||||
{t("settings.takeout.howTo")}
|
||||
</summary>
|
||||
<p className="mt-1 text-xs text-muted leading-relaxed">
|
||||
{t("settings.takeout.howToBody")}
|
||||
</p>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
<div
|
||||
className="mt-2 flex items-center gap-2 text-xs text-muted"
|
||||
role="status"
|
||||
data-testid="takeout-running"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
{uploading !== null
|
||||
? t("settings.takeout.uploading", { name: uploading })
|
||||
: t("settings.takeout.running")}
|
||||
</div>
|
||||
) : (
|
||||
// A failed re-read keeps the previous answer: it is not shown under the line saying the
|
||||
// read failed, as if it were current.
|
||||
run &&
|
||||
status.data &&
|
||||
!status.isError && (
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted" data-testid="takeout-last">
|
||||
{run.status === "error"
|
||||
? t("settings.takeout.failed", { when: when(run.finished_at) })
|
||||
: run.status === "interrupted"
|
||||
? t("settings.takeout.interrupted", { when: when(run.started_at) })
|
||||
: [
|
||||
t("settings.takeout.last", { when: when(run.finished_at) }),
|
||||
...countParts(status.data, say),
|
||||
].join(" · ")}
|
||||
</p>
|
||||
{!reopened && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReopened(true)}>
|
||||
{t("settings.takeout.newImport")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user