feat: Takeout walkthrough, design-system restyle, replacing import

Adds a seven-step in-app guide to exporting subscriptions from Google
Takeout, with the real URLs opened in the system browser.

Restyles the app onto the supplied design system: slate/sky palette,
9-15px type ladder, outline-first controls, tiered radii, borders for
separation and shadows only for elevation. Light and dark are both
designed, with a System/Light/Dark control and a pre-paint script so
the window does not flash light on a dark machine.

Importing now replaces the subscription list rather than merging, per
request. Because that can delete downloaded files, a confirmation
dialog names exactly what will go first.
This commit is contained in:
vincent
2026-08-29 03:00:54 +02:00
parent 11331671c5
commit cac6727072
18 changed files with 1202 additions and 306 deletions
+168 -69
View File
@@ -1,109 +1,208 @@
import { useEffect, useState } from "react";
import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api";
import type { Prereqs } from "../types";
import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import type { ImportPreview, Prereqs } from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
} from "./ui";
interface Props {
onClose: () => void;
onImported: (count: number) => void;
appearance: Appearance;
onAppearance: (a: Appearance) => void;
onError: (message: string) => void;
}
function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) {
function StatusRow({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex items-start justify-between gap-4 py-2 border-b border-edge/60">
<span className="text-sm text-muted shrink-0">{label}</span>
<span className={`text-sm text-right ${value ? "text-emerald-300" : "text-red-300"}`}>
{value ?? hint ?? "Not found"}
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
<span className={LABEL}>{label}</span>
<span
className={`text-right text-[12px] ${
value ? "text-slate-600 dark:text-slate-300" : "text-red-600 dark:text-red-400"
}`}
>
{value ?? "Not found"}
</span>
</div>
);
}
export default function Settings({ onClose, onImported }: Props) {
export default function Settings({
onClose, onImported, appearance, onAppearance, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []);
const doImport = async () => {
setBusy(true);
setMessage(null);
const startImport = async () => {
try {
const n = await pickAndImportTakeout();
if (n != null) {
setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`);
onImported(n);
}
const path = await pickTakeoutFile();
if (!path) return;
setPending({ path, preview: await previewTakeoutImport(path) });
} catch (e) {
setMessage(String(e));
onError(String(e));
}
};
const confirmImport = async () => {
if (!pending) return;
setBusy(true);
try {
const n = await importTakeoutCsv(pending.path);
setPending(null);
onImported(n);
} catch (e) {
setPending(null);
onError(String(e));
} finally {
setBusy(false);
}
};
const doPickFolder = async () => {
const pickFolder = async () => {
try {
const p = await pickLibraryFolder();
if (p) { setMessage(`Library moved to ${p}`); load(); }
if (await pickLibraryFolder()) load();
} catch (e) {
setMessage(String(e));
onError(String(e));
}
};
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
const p = pending?.preview;
const destructive = !!p && (p.removed_channels > 0 || p.removed_downloads > 0);
return (
<div className="fixed inset-0 z-50 bg-black/60 grid place-items-center p-6" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}
className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Settings</h2>
<button onClick={onClose}
className="rounded-full px-3 py-1.5 text-xs bg-raised hover:bg-edge cursor-pointer">
Close
</button>
</div>
<>
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="flex max-h-[82vh] w-full max-w-lg flex-col rounded-2xl border border-slate-300
bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button>
</header>
<section className="space-y-2">
<h3 className="text-sm font-medium">Subscriptions</h3>
<p className="text-xs text-muted leading-relaxed">
Export <span className="text-white">YouTube subscriptions</span> from Google Takeout,
then import the <code className="text-white">subscriptions.csv</code> file here.
Re-importing merges with what you already have.
</p>
<button onClick={doImport} disabled={busy}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-2 text-xs
font-semibold cursor-pointer disabled:opacity-40">
{busy ? "Importing…" : "Import subscriptions.csv"}
</button>
</section>
<section className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted shrink-0">Library</span>
<button onClick={doPickFolder}
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
{prereqs?.library_path ?? "…"}
</button>
</div>
{missing && (
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<p className="text-xs text-red-200 leading-relaxed">
Downloads need both tools. Install them with:
<div className="min-h-0 flex-1 overflow-y-auto">
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Get your subscriptions</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
YouTube has no public API for someone else's subscription list, so FlightTube
reads the export Google gives you. It takes about two minutes.
</p>
<code className="mt-1.5 block text-xs text-white bg-black/40 rounded px-2 py-1.5">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>
<div className={`mt-3 ${SUBPANEL}`}>
<TakeoutGuide />
</div>
</section>
{message && <p className="text-xs text-muted break-words">{message}</p>}
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Import</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
Importing <b>replaces</b> your current subscription list — the CSV becomes the
whole truth. Channels no longer in it are removed along with their videos and
downloads. You'll see exactly what goes before anything is deleted.
</p>
<button onClick={startImport} className={`${BTN_PRIMARY} mt-3 cursor-pointer`}>
Import subscriptions.csv
</button>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3">
<span className={LABEL}>Theme</span>
<Segmented
value={appearance}
onChange={onAppearance}
options={APPEARANCE_MODES.map((m) => ({
value: m,
label: m[0].toUpperCase() + m.slice(1),
}))}
/>
</div>
</section>
<section className="px-5 py-4">
<SectionHeading>Status</SectionHeading>
<div className="mt-2">
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className={LABEL}>Library</span>
<button
onClick={pickFolder}
className="cursor-pointer break-all text-right text-[12px] text-sky-600
underline underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{prereqs?.library_path ?? "…"}
</button>
</div>
</div>
{missing && (
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
Downloads need both tools. Install them with:
</p>
<code className="mt-1.5 block rounded bg-slate-100 px-2 py-1 text-[11px] dark:bg-slate-800">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>
</div>
</div>
</div>
</div>
{pending && p && (
<Dialog
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
onCancel={() => setPending(null)}
onConfirm={busy ? undefined : confirmImport}
confirmLabel={destructive ? "Replace" : "Import"}
destructive={destructive}
>
<p>
The file lists <b>{p.incoming}</b> subscription{p.incoming === 1 ? "" : "s"}, which
will become your complete list.
</p>
{destructive ? (
<ul className="mt-2 space-y-1">
<li>
<b>{p.removed_channels}</b> channel{p.removed_channels === 1 ? "" : "s"} no longer
subscribed will be removed
</li>
<li>
<b>{p.removed_videos}</b> of their video{p.removed_videos === 1 ? "" : "s"} will
disappear from your feed
</li>
{p.removed_downloads > 0 && (
<li className="text-red-600 dark:text-red-400">
<b>{p.removed_downloads}</b> downloaded file
{p.removed_downloads === 1 ? "" : "s"} will be deleted from disk
</li>
)}
</ul>
) : (
<p className="mt-2">Nothing currently stored will be removed.</p>
)}
</Dialog>
)}
</>
);
}