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:
+89
-42
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user