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>
);