From cac67270724c9d745514ac0e9b5d34667e987b4f Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 03:00:54 +0200 Subject: [PATCH] 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. --- index.html | 20 ++- src-tauri/src/commands.rs | 48 +++++- src-tauri/src/db.rs | 210 +++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/models.rs | 9 ++ src/App.tsx | 131 +++++++++++------ src/api.ts | 13 +- src/components/DownloadButton.tsx | 64 ++++---- src/components/Player.tsx | 45 +++--- src/components/Settings.tsx | 237 +++++++++++++++++++++--------- src/components/Sidebar.tsx | 97 +++++++----- src/components/TakeoutGuide.tsx | 121 +++++++++++++++ src/components/TopBar.tsx | 143 +++++++++++------- src/components/VideoRow.tsx | 62 ++++---- src/components/ui.tsx | 174 ++++++++++++++++++++++ src/hooks/useAppearance.ts | 53 +++++++ src/index.css | 73 +++++++-- src/types.ts | 7 + 18 files changed, 1202 insertions(+), 306 deletions(-) create mode 100644 src/components/TakeoutGuide.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/hooks/useAppearance.ts diff --git a/index.html b/index.html index ff93803..d041203 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,25 @@ - - Tauri + React + Typescript + FlightTube + diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7ce9807..93a7db4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,9 @@ use crate::db::Db; use crate::downloader::{self, Progress}; use crate::feed; -use crate::models::{ChannelWithCount, DownloadState, FeedFilter, FeedItem, Prereqs}; +use crate::models::{ + Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs, +}; use crate::net; use crate::takeout; use crate::thumbs; @@ -113,12 +115,8 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result, -) -> Result { - let raw = tokio::fs::read(&path) +async fn read_channels(path: &str) -> Result, String> { + let raw = tokio::fs::read(path) .await .map_err(|e| format!("Cannot read {path}: {e}"))?; // Takeout exports are UTF-8, sometimes with a BOM. @@ -129,8 +127,42 @@ pub async fn import_takeout_csv( if channels.is_empty() { return Err("No channels found in that file.".into()); } + Ok(channels) +} + +/// Reports what a replacing import would add and destroy, so the UI can name +/// the consequences before the user commits to them. +#[tauri::command] +pub async fn preview_takeout_import( + path: String, + state: State<'_, AppState>, +) -> Result { + let channels = read_channels(&path).await?; + state.db.lock().await.preview_replace(&channels) +} + +/// The imported CSV becomes the entire subscription list. Channels that are no +/// longer in it are removed along with their videos, download records, and the +/// downloaded files themselves — leaving those on disk would orphan gigabytes +/// the app can no longer show or delete. +#[tauri::command] +pub async fn import_takeout_csv( + path: String, + state: State<'_, AppState>, +) -> Result { + let channels = read_channels(&path).await?; + + let doomed = state + .db + .lock() + .await + .paths_dropped_by_replace(&channels)?; + for p in doomed { + let _ = tokio::fs::remove_file(&p).await; + } + let mut db = state.db.lock().await; - db.upsert_channels(&channels) + db.replace_channels(&channels) } #[tauri::command] diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 05f7726..ee404cb 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,6 +1,8 @@ //! SQLite storage. The only module that speaks SQL. -use crate::models::{Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, Video}; +use crate::models::{ + Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Video, +}; use rusqlite::{params, Connection}; use std::path::Path; @@ -80,6 +82,130 @@ impl Db { Ok(Db { conn }) } + /// What a replacing import would destroy. Callers show this before asking + /// the user to confirm, so nothing is deleted without being named first. + pub fn preview_replace(&self, incoming: &[Channel]) -> Result { + let keep: std::collections::HashSet<&str> = + incoming.iter().map(|c| c.id.as_str()).collect(); + + let mut removed_channels = 0i64; + for id in self.channel_ids()? { + if !keep.contains(id.as_str()) { + removed_channels += 1; + } + } + + let mut stmt = self + .conn + .prepare( + "SELECT v.channel_id, COUNT(*), + SUM(CASE WHEN d.state = 'done' THEN 1 ELSE 0 END) + FROM videos v + LEFT JOIN downloads d ON d.video_id = v.id + GROUP BY v.channel_id", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, Option>(2)?.unwrap_or(0), + )) + }) + .map_err(|e| e.to_string())?; + + let mut removed_videos = 0i64; + let mut removed_downloads = 0i64; + for row in rows { + let (cid, videos, downloads) = row.map_err(|e| e.to_string())?; + if !keep.contains(cid.as_str()) { + removed_videos += videos; + removed_downloads += downloads; + } + } + + Ok(ImportPreview { + incoming: incoming.len() as i64, + removed_channels, + removed_videos, + removed_downloads, + }) + } + + /// Files belonging to channels that a replacing import would drop, so the + /// caller can delete them rather than orphaning them on disk. + pub fn paths_dropped_by_replace(&self, incoming: &[Channel]) -> Result, String> { + let keep: std::collections::HashSet<&str> = + incoming.iter().map(|c| c.id.as_str()).collect(); + let mut stmt = self + .conn + .prepare( + "SELECT v.channel_id, d.path FROM downloads d + JOIN videos v ON v.id = d.video_id + WHERE d.path IS NOT NULL", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + let (cid, path) = row.map_err(|e| e.to_string())?; + if !keep.contains(cid.as_str()) { + out.push(path); + } + } + Ok(out) + } + + /// Replaces the subscription list outright: the imported CSV becomes the + /// whole truth. Channels no longer present are dropped along with their + /// videos and download records. Channels that survive keep their videos and + /// download state untouched. + pub fn replace_channels(&mut self, channels: &[Channel]) -> Result { + let tx = self.conn.transaction().map_err(|e| e.to_string())?; + { + // A temp table keeps the delete set explicit and avoids building a + // giant IN(...) clause for a few hundred channels. + tx.execute_batch( + "CREATE TEMP TABLE IF NOT EXISTS keep_ids (id TEXT PRIMARY KEY); + DELETE FROM keep_ids;", + ) + .map_err(|e| e.to_string())?; + { + let mut ins = tx + .prepare("INSERT OR IGNORE INTO keep_ids (id) VALUES (?1)") + .map_err(|e| e.to_string())?; + for c in channels { + ins.execute(params![c.id]).map_err(|e| e.to_string())?; + } + } + + tx.execute_batch( + "DELETE FROM downloads WHERE video_id IN ( + SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids)); + DELETE FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids); + DELETE FROM channels WHERE id NOT IN (SELECT id FROM keep_ids);", + ) + .map_err(|e| e.to_string())?; + + let mut stmt = tx + .prepare( + "INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", + ) + .map_err(|e| e.to_string())?; + let ts = now(); + for c in channels { + stmt.execute(params![c.id, c.title, c.url, ts]) + .map_err(|e| e.to_string())?; + } + } + tx.commit().map_err(|e| e.to_string())?; + Ok(channels.len()) + } + pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result { let tx = self.conn.transaction().map_err(|e| e.to_string())?; { @@ -391,6 +517,88 @@ mod tests { db } + #[test] + fn replacing_drops_channels_absent_from_the_new_csv() { + let mut db = seeded(); + db.set_download_state("b", DownloadState::Done, None).unwrap(); + + // New CSV contains only UC1; UC2 (and its downloaded video "b") must go. + db.replace_channels(&[Channel { + id: "UC1".into(), + title: "Alpha".into(), + url: "u1".into(), + }]) + .unwrap(); + + let chans = db.list_channels().unwrap(); + assert_eq!(chans.len(), 1); + assert_eq!(chans[0].id, "UC1"); + + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + assert!(feed.iter().all(|f| f.channel_id == "UC1")); + assert!(feed.iter().all(|f| f.id != "b"), "video of dropped channel must go"); + } + + #[test] + fn replacing_keeps_download_state_for_surviving_channels() { + let mut db = seeded(); + db.set_download_state("a", DownloadState::Done, None).unwrap(); + db.set_download_path("a", "/movies/a.mp4").unwrap(); + + db.replace_channels(&[Channel { + id: "UC1".into(), + title: "Alpha renamed".into(), + url: "u1".into(), + }]) + .unwrap(); + + let feed = db.list_feed(&FeedFilter::default()).unwrap(); + let a = feed.iter().find(|f| f.id == "a").expect("surviving video kept"); + assert_eq!(a.state, Some(DownloadState::Done)); + assert_eq!(a.path.as_deref(), Some("/movies/a.mp4")); + assert_eq!(a.channel_title, "Alpha renamed"); + } + + #[test] + fn preview_counts_what_replacing_would_remove() { + let mut db = seeded(); + db.set_download_state("b", DownloadState::Done, None).unwrap(); + + let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }]; + let p = db.preview_replace(&incoming).unwrap(); + assert_eq!(p.incoming, 1); + assert_eq!(p.removed_channels, 1); + assert_eq!(p.removed_videos, 1); + assert_eq!(p.removed_downloads, 1); + } + + #[test] + fn preview_removes_nothing_when_csv_is_a_superset() { + let mut db = seeded(); + let incoming = vec![ + Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }, + Channel { id: "UC2".into(), title: "Beta".into(), url: "u".into() }, + Channel { id: "UC3".into(), title: "Gamma".into(), url: "u".into() }, + ]; + let p = db.preview_replace(&incoming).unwrap(); + assert_eq!(p.removed_channels, 0); + assert_eq!(p.removed_videos, 0); + assert_eq!(p.removed_downloads, 0); + } + + #[test] + fn dropped_paths_lists_only_files_of_removed_channels() { + let mut db = seeded(); + db.set_download_state("a", DownloadState::Done, None).unwrap(); + db.set_download_path("a", "/movies/a.mp4").unwrap(); + db.set_download_state("b", DownloadState::Done, None).unwrap(); + db.set_download_path("b", "/movies/b.mp4").unwrap(); + + let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }]; + let paths = db.paths_dropped_by_replace(&incoming).unwrap(); + assert_eq!(paths, vec!["/movies/b.mp4".to_string()]); + } + #[test] fn upserting_same_channel_twice_yields_one_row() { let mut db = Db::open_in_memory().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e5da5d..ae6b81d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::check_prereqs, commands::import_takeout_csv, + commands::preview_takeout_import, commands::list_channels, commands::list_feed, commands::refresh_feeds, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 542108c..1554fca 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -102,3 +102,12 @@ pub struct Prereqs { pub ffmpeg: Option, pub library_path: String, } + +/// What a replacing Takeout import would add and destroy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportPreview { + pub incoming: i64, + pub removed_channels: i64, + pub removed_videos: i64, + pub removed_downloads: i64, +} diff --git a/src/App.tsx b/src/App.tsx index 767b371..2c0f1b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(null); - const [notice, setNotice] = useState(null); + const [toast, setToast] = useState(null); + const [failure, setFailure] = useState(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(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 ( -
- setShowSettings(true)} totalVideos={totalVideos} /> +
+ setShowSettings(true)} + totalVideos={totals.videos} + totalDownloaded={totals.downloaded} + /> -
+
{ setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} + resultCount={items.length} /> {!online && ( -
+
Offline — showing only videos you've downloaded. - {forcedOffline && " (Offline mode is forced on.)"} + {forcedOffline && " Offline mode is forced on."}
)} - {(notice || error) && ( -
- {error ?? notice} - -
- )} - -
+
{items.length === 0 ? ( -
-

+

+

{emptyMessage()}

) : ( -
+
    {items.map((item) => ( 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))) } /> ))} -
+ )} -
-
+
+ {playing && ( )} {showSettings && ( - setShowSettings(false)} onImported={() => reload()} /> + setShowSettings(false)} + appearance={mode} + onAppearance={setMode} + onError={setFailure} + onImported={(n) => { + reload(); + say(`Imported ${n} subscription${n === 1 ? "" : "s"}`); + }} + /> )} + + {failure && ( + setFailure(null)}> +

{failure}

+
+ )} + +
); } diff --git a/src/api.ts b/src/api.ts index 79d8b7e..b9b89cf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,6 +7,7 @@ import type { DownloadStateEvent, FeedFilter, FeedItem, + ImportPreview, Prereqs, RefreshProgress, RefreshSummary, @@ -39,16 +40,22 @@ export const openExternal = (url: string) => invoke("open_external", { url }); /** Opens the native file picker for a Takeout subscriptions.csv. */ -export async function pickAndImportTakeout(): Promise { +export async function pickTakeoutFile(): Promise { const path = await open({ multiple: false, directory: false, filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }], }); - if (typeof path !== "string") return null; - return invoke("import_takeout_csv", { path }); + return typeof path === "string" ? path : null; } +export const previewTakeoutImport = (path: string) => + invoke("preview_takeout_import", { path }); + +/** Replaces the whole subscription list. Confirm with the user first. */ +export const importTakeoutCsv = (path: string) => + invoke("import_takeout_csv", { path }); + export async function pickLibraryFolder(): Promise { const path = await open({ directory: true, multiple: false }); if (typeof path !== "string") return null; diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index 84507b2..a3857ac 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -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 ( - - - - - ); -} +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 ( ); } if (state === "running" || state === "queued") { - const label = - state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting"; + const known = state === "running" && pct != null; return ( ); } @@ -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`}> - - - + ? "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"} ); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index b52c6b6..5c1bef9 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,6 +1,7 @@ import { fileUrl } from "../api"; import type { FeedItem } from "../types"; import { compactViews, relativeTime } from "./format"; +import { BTN, BTN_CHROME } from "./ui"; interface Props { item: FeedItem; @@ -15,40 +16,46 @@ interface Props { */ export default function Player({ item, path, onClose, onDelete }: Props) { return ( -
-
- - {item.channel_title} - -
+ {/* Absolute fill + object-contain, so portrait Shorts and landscape videos are both letterboxed to the pane instead of overflowing it. */} -
+
-
-

{item.title}

-
- {[compactViews(item.views), relativeTime(item.published)] - .filter(Boolean) - .join(" · ")} +
+

{item.title}

+
+ {[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
{item.description && ( -

+

{item.description}

)} -
+
); } diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 5bc3ae9..75dbc50 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,109 +1,208 @@ import { useEffect, useState } from "react"; -import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api"; -import type { Prereqs } from "../types"; +import { + checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport, +} from "../api"; +import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; +import type { ImportPreview, Prereqs } from "../types"; +import TakeoutGuide from "./TakeoutGuide"; +import { + BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL, +} from "./ui"; interface Props { onClose: () => void; onImported: (count: number) => void; + appearance: Appearance; + onAppearance: (a: Appearance) => void; + onError: (message: string) => void; } -function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) { +function StatusRow({ label, value }: { label: string; value: string | null }) { return ( -
- {label} - - {value ?? hint ?? "Not found"} +
+ {label} + + {value ?? "Not found"}
); } -export default function Settings({ onClose, onImported }: Props) { +export default function Settings({ + onClose, onImported, appearance, onAppearance, onError, +}: Props) { const [prereqs, setPrereqs] = useState(null); + const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(null); const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null)); useEffect(() => { load(); }, []); - const doImport = async () => { - setBusy(true); - setMessage(null); + const startImport = async () => { try { - const n = await pickAndImportTakeout(); - if (n != null) { - setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`); - onImported(n); - } + const path = await pickTakeoutFile(); + if (!path) return; + setPending({ path, preview: await previewTakeoutImport(path) }); } catch (e) { - setMessage(String(e)); + onError(String(e)); + } + }; + + const confirmImport = async () => { + if (!pending) return; + setBusy(true); + try { + const n = await importTakeoutCsv(pending.path); + setPending(null); + onImported(n); + } catch (e) { + setPending(null); + onError(String(e)); } finally { setBusy(false); } }; - const doPickFolder = async () => { + const pickFolder = async () => { try { - const p = await pickLibraryFolder(); - if (p) { setMessage(`Library moved to ${p}`); load(); } + if (await pickLibraryFolder()) load(); } catch (e) { - setMessage(String(e)); + onError(String(e)); } }; const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg); + const p = pending?.preview; + const destructive = !!p && (p.removed_channels > 0 || p.removed_downloads > 0); return ( -
-
e.stopPropagation()} - className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5"> -
-

Settings

- -
+ <> +
+
e.stopPropagation()} + className="flex max-h-[82vh] w-full max-w-lg flex-col rounded-2xl border border-slate-300 + bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900" + > +
+

Settings

+ +
-
-

Subscriptions

-

- Export YouTube subscriptions from Google Takeout, - then import the subscriptions.csv file here. - Re-importing merges with what you already have. -

- -
- -
-

Status

- - -
- Library - -
- {missing && ( -
-

- Downloads need both tools. Install them with: +

+
+ Get your subscriptions +

+ YouTube has no public API for someone else's subscription list, so FlightTube + reads the export Google gives you. It takes about two minutes.

- - brew install yt-dlp ffmpeg - -
- )} -
+
+ +
+ - {message &&

{message}

} +
+ Import +

+ Importing replaces your current subscription list — the CSV becomes the + whole truth. Channels no longer in it are removed along with their videos and + downloads. You'll see exactly what goes before anything is deleted. +

+ +
+ +
+ Appearance +
+ Theme + ({ + value: m, + label: m[0].toUpperCase() + m.slice(1), + }))} + /> +
+
+ +
+ Status +
+ + +
+ Library + +
+
+ + {missing && ( +
+

+ Downloads need both tools. Install them with: +

+ + brew install yt-dlp ffmpeg + +
+ )} +
+
+
-
+ + {pending && p && ( + setPending(null)} + onConfirm={busy ? undefined : confirmImport} + confirmLabel={destructive ? "Replace" : "Import"} + destructive={destructive} + > +

+ The file lists {p.incoming} subscription{p.incoming === 1 ? "" : "s"}, which + will become your complete list. +

+ {destructive ? ( +
    +
  • + {p.removed_channels} channel{p.removed_channels === 1 ? "" : "s"} no longer + subscribed will be removed +
  • +
  • + {p.removed_videos} of their video{p.removed_videos === 1 ? "" : "s"} will + disappear from your feed +
  • + {p.removed_downloads > 0 && ( +
  • + {p.removed_downloads} downloaded file + {p.removed_downloads === 1 ? "" : "s"} will be deleted from disk +
  • + )} +
+ ) : ( +

Nothing currently stored will be removed.

+ )} +
+ )} + ); } + diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5931085..04dbd35 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import type { ChannelWithCount } from "../types"; +import { BTN_CHROME, HEADING } from "./ui"; interface Props { channels: ChannelWithCount[]; @@ -6,56 +7,80 @@ interface Props { onSelect: (id: string | null) => void; onOpenSettings: () => void; totalVideos: number; + totalDownloaded: number; } export default function Sidebar({ - channels, activeChannel, onSelect, onOpenSettings, totalVideos, + channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded, }: Props) { - const rowBase = - "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer"; + const row = + "flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " + + "text-[13px] transition-colors"; + const inactive = + "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"; + const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900"; return ( -