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:
+168
-69
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user