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