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
+89 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
openExternal, refreshFeeds,
@@ -7,12 +7,16 @@ import Player from "./components/Player";
import Settings from "./components/Settings";
import Sidebar from "./components/Sidebar";
import TopBar from "./components/TopBar";
import { Dialog, Toast } from "./components/ui";
import VideoRow from "./components/VideoRow";
import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed";
import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
const TOAST_MS = 2400;
export default function App() {
const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState("");
@@ -22,10 +26,22 @@ export default function App() {
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const { mode, setMode } = useAppearance();
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
// A toast reports success and fades; a modal reports a failure or asks a
// question. Never make someone dismiss a box to be told it worked.
const toastTimer = useRef<number | undefined>(undefined);
const say = useCallback((message: string) => {
setToast(message);
window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), TOAST_MS);
}, []);
useEffect(() => () => window.clearTimeout(toastTimer.current), []);
// Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle.
const effectiveDownloadedOnly = downloadedOnly || !online;
@@ -50,29 +66,40 @@ export default function App() {
return () => un?.();
}, []);
const totalVideos = useMemo(
() => channels.reduce((n, c) => n + c.video_count, 0),
useEffect(() => {
if (error) setFailure(error);
}, [error]);
const totals = useMemo(
() =>
channels.reduce(
(acc, c) => ({
videos: acc.videos + c.video_count,
downloaded: acc.downloaded + c.downloaded_count,
}),
{ videos: 0, downloaded: 0 },
),
[channels],
);
const doRefresh = useCallback(async () => {
setRefreshing(true);
setNotice(null);
try {
const s = await refreshFeeds();
const failed = s.failures.length;
setNotice(
`Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` +
(failed ? ` · ${failed} failed` : ""),
);
await reload();
const failed = s.failures.length;
say(
failed
? `Checked ${s.channels} channels · ${failed} failed`
: `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
);
} catch (e) {
setNotice(String(e));
setFailure(String(e));
} finally {
setRefreshing(false);
setRefreshProgress(null);
}
}, [reload]);
}, [reload, say]);
const openItem = useCallback(
async (item: FeedItem) => {
@@ -83,7 +110,7 @@ export default function App() {
} else if (online) {
await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
} else {
setNotice("That video isn't downloaded, and you're offline.");
setFailure("That video isn't downloaded, and you're offline.");
}
},
[live, online],
@@ -92,19 +119,25 @@ export default function App() {
const emptyMessage = () => {
if (loading) return "Loading…";
if (channels.length === 0)
return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv.";
if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout.";
if (totals.videos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
if (!online) return "You're offline, and nothing has been downloaded yet.";
if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
return "Nothing matches this filter.";
};
return (
<div className="h-screen flex bg-ink text-white overflow-hidden">
<Sidebar channels={channels} activeChannel={channelId} onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)} totalVideos={totalVideos} />
<div className="flex h-screen flex-col lg:flex-row">
<Sidebar
channels={channels}
activeChannel={channelId}
onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
/>
<div className="flex-1 min-w-0 flex flex-col">
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar
search={search} onSearch={setSearch}
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
@@ -112,47 +145,43 @@ export default function App() {
online={online} reachable={reachable} forcedOffline={forcedOffline}
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length}
/>
{!online && (
<div className="px-5 py-2 bg-amber-500/15 text-amber-200 text-xs border-b border-amber-500/25">
<div
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
leading-snug text-amber-700 dark:text-amber-300"
>
Offline showing only videos you've downloaded.
{forcedOffline && " (Offline mode is forced on.)"}
{forcedOffline && " Offline mode is forced on."}
</div>
)}
{(notice || error) && (
<div className="px-5 py-2 bg-surface text-xs text-muted border-b border-edge flex items-center justify-between gap-3">
<span className="truncate">{error ?? notice}</span>
<button onClick={() => setNotice(null)}
className="shrink-0 hover:text-white cursor-pointer">
Dismiss
</button>
</div>
)}
<main className="flex-1 overflow-y-auto px-3 py-3">
<div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
{items.length === 0 ? (
<div className="h-full grid place-items-center">
<p className="text-muted text-sm max-w-md text-center leading-relaxed">
<div className="grid h-full place-items-center">
<p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
{emptyMessage()}
</p>
</div>
) : (
<div className="max-w-5xl mx-auto">
<ul className="mx-auto max-w-4xl space-y-1.5">
{items.map((item) => (
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
onOpen={() => openItem(item)}
onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))}
onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))}
onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))}
onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))}
onDelete={() =>
deleteDownload(item.id).then(reload).catch((e) => setNotice(String(e)))
deleteDownload(item.id)
.then(() => { reload(); say("Download deleted"); })
.catch((e) => setFailure(String(e)))
} />
))}
</div>
</ul>
)}
</main>
</div>
</div>
</main>
{playing && (
<Player item={playing.item} path={playing.path}
@@ -161,12 +190,30 @@ export default function App() {
await deleteDownload(playing.item.id);
setPlaying(null);
reload();
say("Download deleted");
}} />
)}
{showSettings && (
<Settings onClose={() => setShowSettings(false)} onImported={() => reload()} />
<Settings
onClose={() => setShowSettings(false)}
appearance={mode}
onAppearance={setMode}
onError={setFailure}
onImported={(n) => {
reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}}
/>
)}
{failure && (
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
<p className="break-words">{failure}</p>
</Dialog>
)}
<Toast message={toast} />
</div>
);
}