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
+29 -35
View File
@@ -11,23 +11,7 @@ interface Props {
onDelete: () => void;
}
/** A ring that fills as the download progresses; indeterminate until yt-dlp
* knows the total size, which it doesn't until the stream is resolved. */
function ProgressRing({ pct }: { pct: number | null }) {
const r = 9;
const circumference = 2 * Math.PI * r;
const offset = pct == null ? circumference * 0.7 : circumference * (1 - pct / 100);
return (
<svg viewBox="0 0 24 24" className={`size-5 ${pct == null ? "animate-spin" : ""}`}>
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor"
strokeWidth="2.5" className="opacity-25" />
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
transform="rotate(-90 12 12)"
className={pct == null ? "" : "transition-[stroke-dashoffset] duration-300"} />
</svg>
);
}
const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer";
export default function DownloadButton({
item, live, online, onDownload, onCancel, onDelete,
@@ -36,28 +20,41 @@ export default function DownloadButton({
const pct = live?.pct ?? item.pct ?? null;
const error = live?.error ?? item.error ?? null;
const base =
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0";
if (state === "done") {
// Neutral until hovered, then reveals red — destruction is never
// pre-coloured on a control that is not currently destructive.
return (
<button onClick={onDelete} title="Delete the downloaded file"
className={`${base} bg-emerald-500/15 text-emerald-300 hover:bg-red-500/20 hover:text-red-300 group`}>
<span className="group-hover:hidden"> Saved</span>
className={`${CHIP} group border border-slate-300 text-slate-500
hover:border-red-500 hover:text-red-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
<span className="group-hover:hidden">Saved</span>
<span className="hidden group-hover:inline">Delete</span>
</button>
);
}
if (state === "running" || state === "queued") {
const label =
state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting";
const known = state === "running" && pct != null;
return (
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
className={`${base} bg-raised text-white hover:bg-red-500/20 hover:text-red-300 group`}>
<ProgressRing pct={state === "queued" ? null : pct} />
<span className="group-hover:hidden tabular-nums">{label}</span>
<span className="hidden group-hover:inline">Cancel</span>
className={`${CHIP} group w-[104px] border border-slate-300 text-slate-500
hover:border-red-500 hover:text-red-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
<span className="hidden group-hover:block">Cancel</span>
<span className="block group-hover:hidden">
<span className="mb-1 block font-mono tabular-nums">
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
</span>
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<span
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${
known ? "" : "animate-pulse"
}`}
style={{ width: known ? `${pct}%` : "35%" }}
/>
</span>
</span>
</button>
);
}
@@ -72,14 +69,11 @@ export default function DownloadButton({
? error
: "Download for offline viewing"
}
className={`${base} ${
className={`${CHIP} border disabled:cursor-not-allowed disabled:opacity-40 ${
failed
? "bg-red-500/15 text-red-300 hover:bg-red-500/25"
: "bg-raised text-white hover:bg-edge"
} disabled:opacity-35 disabled:cursor-not-allowed`}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
? "border-red-500/60 text-red-600 hover:bg-red-500/5 dark:text-red-400"
: "border-slate-300 hover:border-sky-500 hover:text-sky-600 dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400"
}`}>
{failed ? "Retry" : "Download"}
</button>
);
+26 -19
View File
@@ -1,6 +1,7 @@
import { fileUrl } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME } from "./ui";
interface Props {
item: FeedItem;
@@ -15,40 +16,46 @@ interface Props {
*/
export default function Player({ item, path, onClose, onDelete }: Props) {
return (
<div className="fixed inset-0 z-50 bg-ink/97 flex flex-col">
<div className="flex items-center gap-3 px-5 py-3 border-b border-edge">
<button onClick={onClose}
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface hover:bg-raised cursor-pointer">
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
<header
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900"
>
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
Back
</button>
<span className="text-sm text-muted truncate flex-1">{item.channel_title}</span>
<button onClick={onDelete}
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface text-muted
hover:bg-red-500/20 hover:text-red-300 cursor-pointer">
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</span>
<button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download
</button>
</div>
</header>
{/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */}
<div className="flex-1 min-h-0 bg-black relative">
<div className="relative min-h-0 flex-1 bg-slate-950">
<video key={path} src={fileUrl(path)} controls autoPlay
className="absolute inset-0 h-full w-full object-contain" />
className="absolute inset-0 size-full object-contain" />
</div>
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge">
<h2 className="font-semibold text-lg leading-snug">{item.title}</h2>
<div className="mt-1 text-xs text-muted">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
<footer
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
dark:border-slate-800 dark:bg-slate-900"
>
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div>
{item.description && (
<p className="mt-3 text-sm text-muted whitespace-pre-wrap leading-relaxed">
<p className="mt-3 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description}
</p>
)}
</div>
</footer>
</div>
);
}
+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>
)}
</>
);
}
+61 -36
View File
@@ -1,4 +1,5 @@
import type { ChannelWithCount } from "../types";
import { BTN_CHROME, HEADING } from "./ui";
interface Props {
channels: ChannelWithCount[];
@@ -6,56 +7,80 @@ interface Props {
onSelect: (id: string | null) => void;
onOpenSettings: () => void;
totalVideos: number;
totalDownloaded: number;
}
export default function Sidebar({
channels, activeChannel, onSelect, onOpenSettings, totalVideos,
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
}: Props) {
const rowBase =
"w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer";
const row =
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
"text-[13px] transition-colors";
const inactive =
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
return (
<aside className="w-64 shrink-0 border-r border-edge flex flex-col bg-ink">
<div className="px-4 py-4 flex items-center gap-2">
<span className="text-xl"></span>
<span className="font-semibold tracking-tight">FlightTube</span>
</div>
<aside
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
border-slate-300 bg-white lg:h-screen lg:max-h-none lg:w-[280px]
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
>
<header
className="sticky top-0 z-20 flex items-center justify-between gap-2 border-b
border-slate-200 bg-white/95 px-4 py-3 backdrop-blur
dark:border-slate-800 dark:bg-slate-900/95"
>
<span className="flex items-center gap-2 text-[15px] font-semibold tracking-tight">
<span aria-hidden className="text-sky-500"></span>
FlightTube
</span>
<button onClick={onOpenSettings} className={`${BTN_CHROME} cursor-pointer`}>
Settings
</button>
</header>
<nav className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5">
<button onClick={() => onSelect(null)}
className={`${rowBase} ${
activeChannel === null ? "bg-raised text-white" : "text-muted hover:bg-surface"
}`}>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
<button
onClick={() => onSelect(null)}
className={`${row} ${activeChannel === null ? active : inactive}`}
>
<span className="font-medium">All subscriptions</span>
<span className="text-xs tabular-nums opacity-70">{totalVideos}</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
{totalDownloaded > 0 && `${totalDownloaded}/`}
{totalVideos}
</span>
</button>
{channels.length > 0 && (
<div className="pt-3 pb-1 px-3 text-[11px] uppercase tracking-wider text-muted/70">
Channels
</div>
<h2 className={`${HEADING} px-2 pb-1 pt-4`}>Channels</h2>
)}
{channels.map((c) => (
<button key={c.id} onClick={() => onSelect(c.id)} title={c.title}
className={`${rowBase} ${
activeChannel === c.id ? "bg-raised text-white" : "text-muted hover:bg-surface"
}`}>
<span className="truncate">{c.title}</span>
<span className="text-xs tabular-nums opacity-70 shrink-0">
{c.downloaded_count > 0 && (
<span className="text-emerald-400">{c.downloaded_count}/</span>
)}
{c.video_count}
</span>
</button>
))}
</nav>
<ul className="space-y-0.5">
{channels.map((c) => (
<li key={c.id}>
<button
onClick={() => onSelect(c.id)}
title={c.title}
className={`${row} ${activeChannel === c.id ? active : inactive}`}
>
<span className="truncate">{c.title}</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
{c.video_count}
</span>
</button>
</li>
))}
</ul>
<button onClick={onOpenSettings}
className="m-2 px-3 py-2 rounded-lg text-sm text-muted hover:bg-surface text-left cursor-pointer">
Settings
</button>
{channels.length === 0 && (
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
No subscriptions yet. Open Settings for a step-by-step guide to exporting
them from Google Takeout.
</p>
)}
</nav>
</aside>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { openExternal } from "../api";
import { HELP } from "./ui";
/** Opens in the real browser — Takeout needs your signed-in Google session. */
function ExternalLink({ href, children }: { href: string; children?: string }) {
return (
<button
onClick={() => openExternal(href)}
title={href}
className="cursor-pointer break-all text-left text-sky-600 underline underline-offset-2
hover:text-sky-500 dark:text-sky-400"
>
{children ?? href}
</button>
);
}
interface Step {
title: string;
body: React.ReactNode;
}
const STEPS: Step[] = [
{
title: "Open Google Takeout",
body: (
<>
<ExternalLink href="https://takeout.google.com/" />
<div className="mt-1">
Or jump straight to the YouTube section, which pre-selects it for you:{" "}
<ExternalLink href="https://takeout.google.com/settings/takeout/custom/youtube" />
</div>
</>
),
},
{
title: "Select only YouTube",
body: (
<>
Click <b>Deselect all</b>, then tick <b>YouTube and YouTube Music</b>. Leave
everything else off the other products make the export enormous and slow.
</>
),
},
{
title: "Narrow it to subscriptions",
body: (
<>
Click <b>All YouTube data included</b> <b>Deselect all</b> tick only{" "}
<b>subscriptions</b> <b>OK</b>. Without this you get your entire watch
history and every video you have uploaded.
</>
),
},
{
title: "Check the format is CSV",
body: (
<>
Click <b>Multiple formats</b> and confirm <b>subscriptions</b> is set to{" "}
<b>CSV</b>. FlightTube reads the CSV, not the JSON.
</>
),
},
{
title: "Create the export",
body: (
<>
<b>Next step</b> transfer <b>Send download link by email</b>, frequency{" "}
<b>Export once</b>, type <b>.zip</b> <b>Create export</b>. A subscriptions-only
export is small and usually lands in a minute or two.
</>
),
},
{
title: "Download and unzip",
body: (
<>
Follow the emailed link, download the <b>.zip</b>, and unzip it. The file you
need is at:
<code
className="mt-1 block rounded bg-slate-100 px-2 py-1 text-[11px] break-all
dark:bg-slate-800"
>
Takeout/YouTube and YouTube Music/subscriptions/subscriptions.csv
</code>
</>
),
},
{
title: "Import it below",
body: (
<>
Pick that <code>subscriptions.csv</code> with the Import button, then hit{" "}
<b>Refresh</b> to pull in each channel's latest videos.
</>
),
},
];
export default function TakeoutGuide() {
return (
<ol className="space-y-3">
{STEPS.map((s, i) => (
<li key={s.title} className="flex gap-2.5">
<span
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full
bg-sky-500 text-[9px] font-bold leading-none text-white"
>
{i + 1}
</span>
<div className="min-w-0">
<div className="text-[12px] font-medium text-slate-700 dark:text-slate-200">
{s.title}
</div>
<div className={`mt-0.5 ${HELP}`}>{s.body}</div>
</div>
</li>
))}
</ol>
);
}
+92 -51
View File
@@ -1,3 +1,5 @@
import { BTN_PRIMARY, INPUT } from "./ui";
interface Props {
search: string;
onSearch: (v: string) => void;
@@ -12,8 +14,10 @@ interface Props {
onRefresh: () => void;
refreshing: boolean;
refreshProgress: { done: number; total: number } | null;
resultCount: number;
}
/** Neutral outline until active; active is the one filled state. */
function Toggle({
active, onClick, children, title, disabled,
}: {
@@ -24,10 +28,19 @@ function Toggle({
disabled?: boolean;
}) {
return (
<button onClick={onClick} title={title} disabled={disabled}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer whitespace-nowrap ${
active ? "bg-white text-black" : "bg-surface text-muted hover:bg-raised hover:text-white"
} disabled:opacity-40 disabled:cursor-not-allowed`}>
<button
onClick={onClick}
title={title}
disabled={disabled}
className={
"cursor-pointer whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-[11px] " +
"font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
(active
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
: "border-slate-300 text-slate-500 hover:border-sky-500 hover:text-sky-600 " +
"dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 dark:hover:text-sky-400")
}
>
{children}
</button>
);
@@ -36,57 +49,85 @@ function Toggle({
export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress,
onRefresh, refreshing, refreshProgress, resultCount,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
: null;
return (
<header className="border-b border-edge px-5 py-3 flex items-center gap-3 flex-wrap bg-ink">
<div className="relative flex-1 min-w-52 max-w-md">
<input value={search} onChange={(e) => onSearch(e.target.value)}
<div
className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur
dark:border-slate-800 dark:bg-slate-900/95"
>
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels"
className="w-full rounded-full bg-surface border border-edge px-4 py-2 text-sm
placeholder:text-muted/70 focus:outline-none focus:border-accent" />
className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`}
/>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{resultCount}
</span>
<div className="flex-1" />
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
disabled={!online}
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}>
Downloaded only
</Toggle>
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
title="Hide Shorts from the feed">
Hide Shorts
</Toggle>
<button
onClick={onToggleForcedOffline}
title={
!reachable
? "No connection detected"
: forcedOffline
? "Offline mode is forced on — click to go back online"
: "Simulate being offline"
}
className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border
border-slate-300 px-2.5 py-1.5 text-[11px] font-medium text-slate-500
hover:border-sky-500 hover:text-sky-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
dark:hover:text-sky-400"
>
<span
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
/>
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</button>
<button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos from every channel" : "Refreshing needs a connection"}
className={`${BTN_PRIMARY} cursor-pointer py-1.5 font-mono text-[11px] tabular-nums`}>
{refreshing
? refreshProgress
? `${refreshProgress.done}/${refreshProgress.total}`
: "Refreshing"
: "Refresh"}
</button>
</div>
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
disabled={!online}
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}>
Downloaded only
</Toggle>
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
title="Hide Shorts from the feed">
Hide Shorts
</Toggle>
<button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos" : "Refreshing needs a connection"}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-1.5 text-xs
font-semibold transition-colors cursor-pointer disabled:opacity-40
disabled:cursor-not-allowed tabular-nums whitespace-nowrap">
{refreshing
? refreshProgress
? `${refreshProgress.done}/${refreshProgress.total}`
: "Refreshing…"
: "Refresh"}
</button>
<button onClick={onToggleForcedOffline}
title={
!reachable
? "No connection detected"
: forcedOffline
? "Offline mode is forced on — click to go back online"
: "Simulate being offline"
}
className={`rounded-full px-3 py-1.5 text-xs font-medium flex items-center gap-1.5
cursor-pointer transition-colors whitespace-nowrap ${
online
? "bg-surface text-emerald-300 hover:bg-raised"
: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30"
}`}>
<span className={`size-2 rounded-full ${online ? "bg-emerald-400" : "bg-amber-400"}`} />
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</button>
</header>
{refreshing && (
<div className="h-0.5 overflow-hidden bg-slate-200 dark:bg-slate-700">
<div
className={`h-full bg-sky-500 transition-[width] duration-100 ${
pct == null ? "w-1/3 animate-pulse" : ""
}`}
style={pct == null ? undefined : { width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
+35 -27
View File
@@ -21,52 +21,60 @@ export default function VideoRow({
const src = thumbSrc(item, online);
return (
<div className="group flex gap-4 rounded-xl p-3 hover:bg-surface transition-colors">
<button onClick={onOpen}
className="relative shrink-0 w-44 aspect-video rounded-lg overflow-hidden bg-raised cursor-pointer">
<li
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-2
hover:border-slate-300 dark:border-slate-800 dark:bg-slate-800/50
dark:hover:border-slate-700"
>
<button
onClick={onOpen}
className="relative aspect-video w-32 shrink-0 cursor-pointer overflow-hidden rounded
bg-slate-200 dark:bg-slate-800"
>
{src ? (
<img src={src} alt="" loading="lazy"
className="size-full object-cover group-hover:scale-105 transition-transform duration-300" />
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
) : (
<div className="size-full grid place-items-center text-muted text-xs px-2 text-center">
No thumbnail cached
</div>
<span className="grid size-full place-items-center px-1 text-center text-[10px] text-slate-400">
No thumbnail
</span>
)}
{item.is_short && (
<span className="absolute top-1.5 left-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-semibold">
SHORT
<span
className="absolute left-1 top-1 rounded bg-slate-950/75 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Short
</span>
)}
{downloaded && (
<span className="absolute bottom-1.5 right-1.5 rounded bg-emerald-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-black">
OFFLINE
<span
className="absolute bottom-1 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
font-bold uppercase tracking-widest leading-none text-white"
>
Offline
</span>
)}
</button>
<div className="min-w-0 flex-1">
<button onClick={onOpen} className="text-left w-full cursor-pointer">
<h3 className="font-medium leading-snug line-clamp-2 group-hover:text-white">
{item.title}
</h3>
<button onClick={onOpen} className="w-full cursor-pointer text-left">
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
</button>
<div className="mt-1 text-sm text-muted truncate">{item.channel_title}</div>
<div className="mt-0.5 text-xs text-muted">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
{item.channel_title}
</div>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
<div className="mt-1 text-xs text-red-400 line-clamp-1">
<div className="mt-1 line-clamp-1 text-[11px] text-red-600 dark:text-red-400">
{live?.error ?? item.error}
</div>
)}
</div>
<div className="shrink-0 self-center">
<DownloadButton item={item} live={live} online={online}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</div>
</div>
<DownloadButton item={item} live={live} online={online}
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
</li>
);
}
+174
View File
@@ -0,0 +1,174 @@
/**
* The design system's component vocabulary, in one place. Every other file
* composes these rather than re-spelling the class strings.
*/
import type { ReactNode } from "react";
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
export const HELP =
"text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const PANEL =
"bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-800";
export const SECTION =
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed";
export const BTN =
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` +
"hover:bg-sky-400 disabled:opacity-40";
export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME =
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " +
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
/** Reads as a link; sits at the edge of a group. */
export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
export function SectionHeading({
step,
children,
}: {
step?: number;
children: ReactNode;
}) {
return (
<h2 className={`flex items-center gap-2 ${HEADING}`}>
{step !== undefined && (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-sky-500 text-[9px] font-bold leading-none text-white">
{step}
</span>
)}
{children}
</h2>
);
}
/** Bordered container, borderless children — the group's outline does the framing. */
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div
role="group"
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700"
>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " +
(active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{o.label}
</button>
);
})}
</div>
);
}
/**
* Success confirmation of something that already happened, so it never asks to
* be dismissed. Making someone dismiss a box to be told it worked is a bug.
*/
export function Toast({ message }: { message: string | null }) {
return (
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[80] flex flex-col items-center gap-2">
{message && (
<div
role="status"
className="pointer-events-auto rounded-full border border-slate-300 bg-white px-4 py-2
text-[12.5px] shadow-xl transition-opacity duration-300
dark:border-slate-700 dark:bg-slate-800"
>
{message}
</div>
)}
</div>
);
}
/** A modal for anything needing a decision or reporting a failure. */
export function Dialog({
title,
children,
onCancel,
confirmLabel,
onConfirm,
destructive,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
confirmLabel?: string;
onConfirm?: () => void;
destructive?: boolean;
wide?: boolean;
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-lg max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
{onConfirm ? "Cancel" : "Close"}
</button>
{onConfirm && (
<button
onClick={onConfirm}
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`}
>
{confirmLabel ?? "Continue"}
</button>
)}
</div>
</div>
</div>
);
}