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