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
+18 -2
View File
@@ -2,9 +2,25 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>FlightTube</title>
<script>
// Set the class before first paint, or the window flashes light on a
// dark machine. Deliberately inline and dependency-free.
(function () {
try {
var m = localStorage.getItem("flighttube.appearance") || "system";
var dark =
m === "dark" ||
(m === "system" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
} catch (e) {
/* storage blocked */
}
})();
</script>
</head> </head>
<body> <body>
+40 -8
View File
@@ -3,7 +3,9 @@
use crate::db::Db; use crate::db::Db;
use crate::downloader::{self, Progress}; use crate::downloader::{self, Progress};
use crate::feed; 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::net;
use crate::takeout; use crate::takeout;
use crate::thumbs; use crate::thumbs;
@@ -113,12 +115,8 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
}) })
} }
#[tauri::command] async fn read_channels(path: &str) -> Result<Vec<Channel>, String> {
pub async fn import_takeout_csv( let raw = tokio::fs::read(path)
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let raw = tokio::fs::read(&path)
.await .await
.map_err(|e| format!("Cannot read {path}: {e}"))?; .map_err(|e| format!("Cannot read {path}: {e}"))?;
// Takeout exports are UTF-8, sometimes with a BOM. // Takeout exports are UTF-8, sometimes with a BOM.
@@ -129,8 +127,42 @@ pub async fn import_takeout_csv(
if channels.is_empty() { if channels.is_empty() {
return Err("No channels found in that file.".into()); 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<ImportPreview, String> {
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<usize, String> {
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; let mut db = state.db.lock().await;
db.upsert_channels(&channels) db.replace_channels(&channels)
} }
#[tauri::command] #[tauri::command]
+209 -1
View File
@@ -1,6 +1,8 @@
//! SQLite storage. The only module that speaks SQL. //! 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 rusqlite::{params, Connection};
use std::path::Path; use std::path::Path;
@@ -80,6 +82,130 @@ impl Db {
Ok(Db { conn }) 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<ImportPreview, String> {
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<i64>>(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<Vec<String>, 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<usize, String> {
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<usize, String> { pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result<usize, String> {
let tx = self.conn.transaction().map_err(|e| e.to_string())?; let tx = self.conn.transaction().map_err(|e| e.to_string())?;
{ {
@@ -391,6 +517,88 @@ mod tests {
db 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] #[test]
fn upserting_same_channel_twice_yields_one_row() { fn upserting_same_channel_twice_yields_one_row() {
let mut db = Db::open_in_memory().unwrap(); let mut db = Db::open_in_memory().unwrap();
+1
View File
@@ -22,6 +22,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::check_prereqs, commands::check_prereqs,
commands::import_takeout_csv, commands::import_takeout_csv,
commands::preview_takeout_import,
commands::list_channels, commands::list_channels,
commands::list_feed, commands::list_feed,
commands::refresh_feeds, commands::refresh_feeds,
+9
View File
@@ -102,3 +102,12 @@ pub struct Prereqs {
pub ffmpeg: Option<String>, pub ffmpeg: Option<String>,
pub library_path: String, 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,
}
+89 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
openExternal, refreshFeeds, openExternal, refreshFeeds,
@@ -7,12 +7,16 @@ import Player from "./components/Player";
import Settings from "./components/Settings"; import Settings from "./components/Settings";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import TopBar from "./components/TopBar"; import TopBar from "./components/TopBar";
import { Dialog, Toast } from "./components/ui";
import VideoRow from "./components/VideoRow"; import VideoRow from "./components/VideoRow";
import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity"; import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads"; import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed"; import { useFeed } from "./hooks/useFeed";
import type { FeedFilter, FeedItem, RefreshProgress } from "./types"; import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
const TOAST_MS = 2400;
export default function App() { export default function App() {
const [channelId, setChannelId] = useState<string | null>(null); const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -22,10 +26,22 @@ export default function App() {
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null); const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null); 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(); 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, // Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle. // so the feed collapses to those regardless of the toggle.
const effectiveDownloadedOnly = downloadedOnly || !online; const effectiveDownloadedOnly = downloadedOnly || !online;
@@ -50,29 +66,40 @@ export default function App() {
return () => un?.(); return () => un?.();
}, []); }, []);
const totalVideos = useMemo( useEffect(() => {
() => channels.reduce((n, c) => n + c.video_count, 0), 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], [channels],
); );
const doRefresh = useCallback(async () => { const doRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
setNotice(null);
try { try {
const s = await refreshFeeds(); const s = await refreshFeeds();
const failed = s.failures.length;
setNotice(
`Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` +
(failed ? ` · ${failed} failed` : ""),
);
await reload(); 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) { } catch (e) {
setNotice(String(e)); setFailure(String(e));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
setRefreshProgress(null); setRefreshProgress(null);
} }
}, [reload]); }, [reload, say]);
const openItem = useCallback( const openItem = useCallback(
async (item: FeedItem) => { async (item: FeedItem) => {
@@ -83,7 +110,7 @@ export default function App() {
} else if (online) { } else if (online) {
await openExternal(`https://www.youtube.com/watch?v=${item.id}`); await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
} else { } else {
setNotice("That video isn't downloaded, and you're offline."); setFailure("That video isn't downloaded, and you're offline.");
} }
}, },
[live, online], [live, online],
@@ -92,19 +119,25 @@ export default function App() {
const emptyMessage = () => { const emptyMessage = () => {
if (loading) return "Loading…"; if (loading) return "Loading…";
if (channels.length === 0) if (channels.length === 0)
return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv."; return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout.";
if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos."; 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 (!online) return "You're offline, and nothing has been downloaded yet.";
if (effectiveDownloadedOnly) return "No downloaded videos match this filter."; if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
return "Nothing matches this filter."; return "Nothing matches this filter.";
}; };
return ( return (
<div className="h-screen flex bg-ink text-white overflow-hidden"> <div className="flex h-screen flex-col lg:flex-row">
<Sidebar channels={channels} activeChannel={channelId} onSelect={setChannelId} <Sidebar
onOpenSettings={() => setShowSettings(true)} totalVideos={totalVideos} /> 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 <TopBar
search={search} onSearch={setSearch} search={search} onSearch={setSearch}
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly} downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
@@ -112,47 +145,43 @@ export default function App() {
online={online} reachable={reachable} forcedOffline={forcedOffline} online={online} reachable={reachable} forcedOffline={forcedOffline}
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }} onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length}
/> />
{!online && ( {!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. Offline showing only videos you've downloaded.
{forcedOffline && " (Offline mode is forced on.)"} {forcedOffline && " Offline mode is forced on."}
</div> </div>
)} )}
{(notice || error) && ( <div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
<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">
{items.length === 0 ? ( {items.length === 0 ? (
<div className="h-full grid place-items-center"> <div className="grid h-full place-items-center">
<p className="text-muted text-sm max-w-md text-center leading-relaxed"> <p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
{emptyMessage()} {emptyMessage()}
</p> </p>
</div> </div>
) : ( ) : (
<div className="max-w-5xl mx-auto"> <ul className="mx-auto max-w-4xl space-y-1.5">
{items.map((item) => ( {items.map((item) => (
<VideoRow key={item.id} item={item} live={live[item.id]} online={online} <VideoRow key={item.id} item={item} live={live[item.id]} online={online}
onOpen={() => openItem(item)} onOpen={() => openItem(item)}
onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))} onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))}
onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))} onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))}
onDelete={() => 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 && ( {playing && (
<Player item={playing.item} path={playing.path} <Player item={playing.item} path={playing.path}
@@ -161,12 +190,30 @@ export default function App() {
await deleteDownload(playing.item.id); await deleteDownload(playing.item.id);
setPlaying(null); setPlaying(null);
reload(); reload();
say("Download deleted");
}} /> }} />
)} )}
{showSettings && ( {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> </div>
); );
} }
+10 -3
View File
@@ -7,6 +7,7 @@ import type {
DownloadStateEvent, DownloadStateEvent,
FeedFilter, FeedFilter,
FeedItem, FeedItem,
ImportPreview,
Prereqs, Prereqs,
RefreshProgress, RefreshProgress,
RefreshSummary, RefreshSummary,
@@ -39,16 +40,22 @@ export const openExternal = (url: string) =>
invoke<void>("open_external", { url }); invoke<void>("open_external", { url });
/** Opens the native file picker for a Takeout subscriptions.csv. */ /** Opens the native file picker for a Takeout subscriptions.csv. */
export async function pickAndImportTakeout(): Promise<number | null> { export async function pickTakeoutFile(): Promise<string | null> {
const path = await open({ const path = await open({
multiple: false, multiple: false,
directory: false, directory: false,
filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }], filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }],
}); });
if (typeof path !== "string") return null; return typeof path === "string" ? path : null;
return invoke<number>("import_takeout_csv", { path });
} }
export const previewTakeoutImport = (path: string) =>
invoke<ImportPreview>("preview_takeout_import", { path });
/** Replaces the whole subscription list. Confirm with the user first. */
export const importTakeoutCsv = (path: string) =>
invoke<number>("import_takeout_csv", { path });
export async function pickLibraryFolder(): Promise<string | null> { export async function pickLibraryFolder(): Promise<string | null> {
const path = await open({ directory: true, multiple: false }); const path = await open({ directory: true, multiple: false });
if (typeof path !== "string") return null; if (typeof path !== "string") return null;
+29 -35
View File
@@ -11,23 +11,7 @@ interface Props {
onDelete: () => void; onDelete: () => void;
} }
/** A ring that fills as the download progresses; indeterminate until yt-dlp const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer";
* 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 (
<svg viewBox="0 0 24 24" className={`size-5 ${pct == null ? "animate-spin" : ""}`}>
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor"
strokeWidth="2.5" className="opacity-25" />
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
transform="rotate(-90 12 12)"
className={pct == null ? "" : "transition-[stroke-dashoffset] duration-300"} />
</svg>
);
}
export default function DownloadButton({ export default function DownloadButton({
item, live, online, onDownload, onCancel, onDelete, item, live, online, onDownload, onCancel, onDelete,
@@ -36,28 +20,41 @@ export default function DownloadButton({
const pct = live?.pct ?? item.pct ?? null; const pct = live?.pct ?? item.pct ?? null;
const error = live?.error ?? item.error ?? 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") { if (state === "done") {
// Neutral until hovered, then reveals red — destruction is never
// pre-coloured on a control that is not currently destructive.
return ( return (
<button onClick={onDelete} title="Delete the downloaded file" <button onClick={onDelete} title="Delete the downloaded file"
className={`${base} bg-emerald-500/15 text-emerald-300 hover:bg-red-500/20 hover:text-red-300 group`}> className={`${CHIP} group border border-slate-300 text-slate-500
<span className="group-hover:hidden"> Saved</span> hover:border-red-500 hover:text-red-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
<span className="group-hover:hidden">Saved</span>
<span className="hidden group-hover:inline">Delete</span> <span className="hidden group-hover:inline">Delete</span>
</button> </button>
); );
} }
if (state === "running" || state === "queued") { if (state === "running" || state === "queued") {
const label = const known = state === "running" && pct != null;
state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting";
return ( return (
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"} <button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
className={`${base} bg-raised text-white hover:bg-red-500/20 hover:text-red-300 group`}> className={`${CHIP} group w-[104px] border border-slate-300 text-slate-500
<ProgressRing pct={state === "queued" ? null : pct} /> hover:border-red-500 hover:text-red-600
<span className="group-hover:hidden tabular-nums">{label}</span> dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
<span className="hidden group-hover:inline">Cancel</span> <span className="hidden group-hover:block">Cancel</span>
<span className="block group-hover:hidden">
<span className="mb-1 block font-mono tabular-nums">
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
</span>
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<span
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${
known ? "" : "animate-pulse"
}`}
style={{ width: known ? `${pct}%` : "35%" }}
/>
</span>
</span>
</button> </button>
); );
} }
@@ -72,14 +69,11 @@ export default function DownloadButton({
? error ? error
: "Download for offline viewing" : "Download for offline viewing"
} }
className={`${base} ${ className={`${CHIP} border disabled:cursor-not-allowed disabled:opacity-40 ${
failed failed
? "bg-red-500/15 text-red-300 hover:bg-red-500/25" ? "border-red-500/60 text-red-600 hover:bg-red-500/5 dark:text-red-400"
: "bg-raised text-white hover:bg-edge" : "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"
} disabled:opacity-35 disabled:cursor-not-allowed`}> }`}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
{failed ? "Retry" : "Download"} {failed ? "Retry" : "Download"}
</button> </button>
); );
+26 -19
View File
@@ -1,6 +1,7 @@
import { fileUrl } from "../api"; import { fileUrl } from "../api";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime } from "./format";
import { BTN, BTN_CHROME } from "./ui";
interface Props { interface Props {
item: FeedItem; item: FeedItem;
@@ -15,40 +16,46 @@ interface Props {
*/ */
export default function Player({ item, path, onClose, onDelete }: Props) { export default function Player({ item, path, onClose, onDelete }: Props) {
return ( return (
<div className="fixed inset-0 z-50 bg-ink/97 flex flex-col"> <div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
<div className="flex items-center gap-3 px-5 py-3 border-b border-edge"> <header
<button onClick={onClose} className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface hover:bg-raised cursor-pointer"> dark:border-slate-800 dark:bg-slate-900"
>
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
Back Back
</button> </button>
<span className="text-sm text-muted truncate flex-1">{item.channel_title}</span> <span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
<button onClick={onDelete} {item.channel_title}
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface text-muted </span>
hover:bg-red-500/20 hover:text-red-300 cursor-pointer"> <button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download Delete download
</button> </button>
</div> </header>
{/* Absolute fill + object-contain, so portrait Shorts and landscape {/* Absolute fill + object-contain, so portrait Shorts and landscape
videos are both letterboxed to the pane instead of overflowing it. */} videos are both letterboxed to the pane instead of overflowing it. */}
<div className="flex-1 min-h-0 bg-black relative"> <div className="relative min-h-0 flex-1 bg-slate-950">
<video key={path} src={fileUrl(path)} controls autoPlay <video key={path} src={fileUrl(path)} controls autoPlay
className="absolute inset-0 h-full w-full object-contain" /> className="absolute inset-0 size-full object-contain" />
</div> </div>
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge"> <footer
<h2 className="font-semibold text-lg leading-snug">{item.title}</h2> className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
<div className="mt-1 text-xs text-muted"> dark:border-slate-800 dark:bg-slate-900"
{[compactViews(item.views), relativeTime(item.published)] >
.filter(Boolean) <h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
.join(" · ")} <div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div> </div>
{item.description && ( {item.description && (
<p className="mt-3 text-sm text-muted whitespace-pre-wrap leading-relaxed"> <p className="mt-3 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description} {item.description}
</p> </p>
)} )}
</div> </footer>
</div> </div>
); );
} }
+168 -69
View File
@@ -1,109 +1,208 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api"; import {
import type { Prereqs } from "../types"; 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 { interface Props {
onClose: () => void; onClose: () => void;
onImported: (count: number) => 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 ( return (
<div className="flex items-start justify-between gap-4 py-2 border-b border-edge/60"> <div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
<span className="text-sm text-muted shrink-0">{label}</span> <span className={LABEL}>{label}</span>
<span className={`text-sm text-right ${value ? "text-emerald-300" : "text-red-300"}`}> <span
{value ?? hint ?? "Not found"} className={`text-right text-[12px] ${
value ? "text-slate-600 dark:text-slate-300" : "text-red-600 dark:text-red-400"
}`}
>
{value ?? "Not found"}
</span> </span>
</div> </div>
); );
} }
export default function Settings({ onClose, onImported }: Props) { export default function Settings({
onClose, onImported, appearance, onAppearance, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null); const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null)); const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const doImport = async () => { const startImport = async () => {
setBusy(true);
setMessage(null);
try { try {
const n = await pickAndImportTakeout(); const path = await pickTakeoutFile();
if (n != null) { if (!path) return;
setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`); setPending({ path, preview: await previewTakeoutImport(path) });
onImported(n);
}
} catch (e) { } 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 { } finally {
setBusy(false); setBusy(false);
} }
}; };
const doPickFolder = async () => { const pickFolder = async () => {
try { try {
const p = await pickLibraryFolder(); if (await pickLibraryFolder()) load();
if (p) { setMessage(`Library moved to ${p}`); load(); }
} catch (e) { } catch (e) {
setMessage(String(e)); onError(String(e));
} }
}; };
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg); const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
const p = pending?.preview;
const destructive = !!p && (p.removed_channels > 0 || p.removed_downloads > 0);
return ( return (
<div className="fixed inset-0 z-50 bg-black/60 grid place-items-center p-6" onClick={onClose}> <>
<div onClick={(e) => e.stopPropagation()} <div
className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5"> className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
<div className="flex items-center justify-between"> onClick={onClose}
<h2 className="text-lg font-semibold">Settings</h2> >
<button onClick={onClose} <div
className="rounded-full px-3 py-1.5 text-xs bg-raised hover:bg-edge cursor-pointer"> role="dialog"
Close aria-modal="true"
</button> onClick={(e) => e.stopPropagation()}
</div> 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"
>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button>
</header>
<section className="space-y-2"> <div className="min-h-0 flex-1 overflow-y-auto">
<h3 className="text-sm font-medium">Subscriptions</h3> <section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<p className="text-xs text-muted leading-relaxed"> <SectionHeading>Get your subscriptions</SectionHeading>
Export <span className="text-white">YouTube subscriptions</span> from Google Takeout, <p className={`mt-1.5 ${HELP}`}>
then import the <code className="text-white">subscriptions.csv</code> file here. YouTube has no public API for someone else's subscription list, so FlightTube
Re-importing merges with what you already have. reads the export Google gives you. It takes about two minutes.
</p>
<button onClick={doImport} disabled={busy}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-2 text-xs
font-semibold cursor-pointer disabled:opacity-40">
{busy ? "Importing…" : "Import subscriptions.csv"}
</button>
</section>
<section className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted shrink-0">Library</span>
<button onClick={doPickFolder}
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
{prereqs?.library_path ?? "…"}
</button>
</div>
{missing && (
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<p className="text-xs text-red-200 leading-relaxed">
Downloads need both tools. Install them with:
</p> </p>
<code className="mt-1.5 block text-xs text-white bg-black/40 rounded px-2 py-1.5"> <div className={`mt-3 ${SUBPANEL}`}>
brew install yt-dlp ffmpeg <TakeoutGuide />
</code> </div>
</div> </section>
)}
</section>
{message && <p className="text-xs text-muted break-words">{message}</p>} <section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Import</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
Importing <b>replaces</b> 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.
</p>
<button onClick={startImport} className={`${BTN_PRIMARY} mt-3 cursor-pointer`}>
Import subscriptions.csv
</button>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3">
<span className={LABEL}>Theme</span>
<Segmented
value={appearance}
onChange={onAppearance}
options={APPEARANCE_MODES.map((m) => ({
value: m,
label: m[0].toUpperCase() + m.slice(1),
}))}
/>
</div>
</section>
<section className="px-5 py-4">
<SectionHeading>Status</SectionHeading>
<div className="mt-2">
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className={LABEL}>Library</span>
<button
onClick={pickFolder}
className="cursor-pointer break-all text-right text-[12px] text-sky-600
underline underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{prereqs?.library_path ?? "…"}
</button>
</div>
</div>
{missing && (
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
Downloads need both tools. Install them with:
</p>
<code className="mt-1.5 block rounded bg-slate-100 px-2 py-1 text-[11px] dark:bg-slate-800">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>
</div>
</div>
</div> </div>
</div>
{pending && p && (
<Dialog
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
onCancel={() => setPending(null)}
onConfirm={busy ? undefined : confirmImport}
confirmLabel={destructive ? "Replace" : "Import"}
destructive={destructive}
>
<p>
The file lists <b>{p.incoming}</b> subscription{p.incoming === 1 ? "" : "s"}, which
will become your complete list.
</p>
{destructive ? (
<ul className="mt-2 space-y-1">
<li>
<b>{p.removed_channels}</b> channel{p.removed_channels === 1 ? "" : "s"} no longer
subscribed will be removed
</li>
<li>
<b>{p.removed_videos}</b> of their video{p.removed_videos === 1 ? "" : "s"} will
disappear from your feed
</li>
{p.removed_downloads > 0 && (
<li className="text-red-600 dark:text-red-400">
<b>{p.removed_downloads}</b> downloaded file
{p.removed_downloads === 1 ? "" : "s"} will be deleted from disk
</li>
)}
</ul>
) : (
<p className="mt-2">Nothing currently stored will be removed.</p>
)}
</Dialog>
)}
</>
); );
} }
+61 -36
View File
@@ -1,4 +1,5 @@
import type { ChannelWithCount } from "../types"; import type { ChannelWithCount } from "../types";
import { BTN_CHROME, HEADING } from "./ui";
interface Props { interface Props {
channels: ChannelWithCount[]; channels: ChannelWithCount[];
@@ -6,56 +7,80 @@ interface Props {
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onOpenSettings: () => void; onOpenSettings: () => void;
totalVideos: number; totalVideos: number;
totalDownloaded: number;
} }
export default function Sidebar({ export default function Sidebar({
channels, activeChannel, onSelect, onOpenSettings, totalVideos, channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
}: Props) { }: Props) {
const rowBase = const row =
"w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer"; "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 ( return (
<aside className="w-64 shrink-0 border-r border-edge flex flex-col bg-ink"> <aside
<div className="px-4 py-4 flex items-center gap-2"> className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
<span className="text-xl"></span> border-slate-300 bg-white lg:h-screen lg:max-h-none lg:w-[280px]
<span className="font-semibold tracking-tight">FlightTube</span> lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
</div> >
<header
className="sticky top-0 z-20 flex items-center justify-between gap-2 border-b
border-slate-200 bg-white/95 px-4 py-3 backdrop-blur
dark:border-slate-800 dark:bg-slate-900/95"
>
<span className="flex items-center gap-2 text-[15px] font-semibold tracking-tight">
<span aria-hidden className="text-sky-500"></span>
FlightTube
</span>
<button onClick={onOpenSettings} className={`${BTN_CHROME} cursor-pointer`}>
Settings
</button>
</header>
<nav className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5"> <nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
<button onClick={() => onSelect(null)} <button
className={`${rowBase} ${ onClick={() => onSelect(null)}
activeChannel === null ? "bg-raised text-white" : "text-muted hover:bg-surface" className={`${row} ${activeChannel === null ? active : inactive}`}
}`}> >
<span className="font-medium">All subscriptions</span> <span className="font-medium">All subscriptions</span>
<span className="text-xs tabular-nums opacity-70">{totalVideos}</span> <span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
{totalDownloaded > 0 && `${totalDownloaded}/`}
{totalVideos}
</span>
</button> </button>
{channels.length > 0 && ( {channels.length > 0 && (
<div className="pt-3 pb-1 px-3 text-[11px] uppercase tracking-wider text-muted/70"> <h2 className={`${HEADING} px-2 pb-1 pt-4`}>Channels</h2>
Channels
</div>
)} )}
{channels.map((c) => ( <ul className="space-y-0.5">
<button key={c.id} onClick={() => onSelect(c.id)} title={c.title} {channels.map((c) => (
className={`${rowBase} ${ <li key={c.id}>
activeChannel === c.id ? "bg-raised text-white" : "text-muted hover:bg-surface" <button
}`}> onClick={() => onSelect(c.id)}
<span className="truncate">{c.title}</span> title={c.title}
<span className="text-xs tabular-nums opacity-70 shrink-0"> className={`${row} ${activeChannel === c.id ? active : inactive}`}
{c.downloaded_count > 0 && ( >
<span className="text-emerald-400">{c.downloaded_count}/</span> <span className="truncate">{c.title}</span>
)} <span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
{c.video_count} {c.downloaded_count > 0 && `${c.downloaded_count}/`}
</span> {c.video_count}
</button> </span>
))} </button>
</nav> </li>
))}
</ul>
<button onClick={onOpenSettings} {channels.length === 0 && (
className="m-2 px-3 py-2 rounded-lg text-sm text-muted hover:bg-surface text-left cursor-pointer"> <p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
Settings No subscriptions yet. Open Settings for a step-by-step guide to exporting
</button> them from Google Takeout.
</p>
)}
</nav>
</aside> </aside>
); );
} }
+121
View File
@@ -0,0 +1,121 @@
import { openExternal } from "../api";
import { HELP } from "./ui";
/** Opens in the real browser — Takeout needs your signed-in Google session. */
function ExternalLink({ href, children }: { href: string; children?: string }) {
return (
<button
onClick={() => openExternal(href)}
title={href}
className="cursor-pointer break-all text-left text-sky-600 underline underline-offset-2
hover:text-sky-500 dark:text-sky-400"
>
{children ?? href}
</button>
);
}
interface Step {
title: string;
body: React.ReactNode;
}
const STEPS: Step[] = [
{
title: "Open Google Takeout",
body: (
<>
<ExternalLink href="https://takeout.google.com/" />
<div className="mt-1">
Or jump straight to the YouTube section, which pre-selects it for you:{" "}
<ExternalLink href="https://takeout.google.com/settings/takeout/custom/youtube" />
</div>
</>
),
},
{
title: "Select only YouTube",
body: (
<>
Click <b>Deselect all</b>, then tick <b>YouTube and YouTube Music</b>. Leave
everything else off the other products make the export enormous and slow.
</>
),
},
{
title: "Narrow it to subscriptions",
body: (
<>
Click <b>All YouTube data included</b> <b>Deselect all</b> tick only{" "}
<b>subscriptions</b> <b>OK</b>. Without this you get your entire watch
history and every video you have uploaded.
</>
),
},
{
title: "Check the format is CSV",
body: (
<>
Click <b>Multiple formats</b> and confirm <b>subscriptions</b> is set to{" "}
<b>CSV</b>. FlightTube reads the CSV, not the JSON.
</>
),
},
{
title: "Create the export",
body: (
<>
<b>Next step</b> transfer <b>Send download link by email</b>, frequency{" "}
<b>Export once</b>, type <b>.zip</b> <b>Create export</b>. A subscriptions-only
export is small and usually lands in a minute or two.
</>
),
},
{
title: "Download and unzip",
body: (
<>
Follow the emailed link, download the <b>.zip</b>, and unzip it. The file you
need is at:
<code
className="mt-1 block rounded bg-slate-100 px-2 py-1 text-[11px] break-all
dark:bg-slate-800"
>
Takeout/YouTube and YouTube Music/subscriptions/subscriptions.csv
</code>
</>
),
},
{
title: "Import it below",
body: (
<>
Pick that <code>subscriptions.csv</code> with the Import button, then hit{" "}
<b>Refresh</b> to pull in each channel's latest videos.
</>
),
},
];
export default function TakeoutGuide() {
return (
<ol className="space-y-3">
{STEPS.map((s, i) => (
<li key={s.title} className="flex gap-2.5">
<span
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full
bg-sky-500 text-[9px] font-bold leading-none text-white"
>
{i + 1}
</span>
<div className="min-w-0">
<div className="text-[12px] font-medium text-slate-700 dark:text-slate-200">
{s.title}
</div>
<div className={`mt-0.5 ${HELP}`}>{s.body}</div>
</div>
</li>
))}
</ol>
);
}
+92 -51
View File
@@ -1,3 +1,5 @@
import { BTN_PRIMARY, INPUT } from "./ui";
interface Props { interface Props {
search: string; search: string;
onSearch: (v: string) => void; onSearch: (v: string) => void;
@@ -12,8 +14,10 @@ interface Props {
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
refreshProgress: { done: number; total: number } | null; refreshProgress: { done: number; total: number } | null;
resultCount: number;
} }
/** Neutral outline until active; active is the one filled state. */
function Toggle({ function Toggle({
active, onClick, children, title, disabled, active, onClick, children, title, disabled,
}: { }: {
@@ -24,10 +28,19 @@ function Toggle({
disabled?: boolean; disabled?: boolean;
}) { }) {
return ( return (
<button onClick={onClick} title={title} disabled={disabled} <button
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer whitespace-nowrap ${ onClick={onClick}
active ? "bg-white text-black" : "bg-surface text-muted hover:bg-raised hover:text-white" title={title}
} disabled:opacity-40 disabled:cursor-not-allowed`}> 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} {children}
</button> </button>
); );
@@ -36,57 +49,85 @@ function Toggle({
export default function TopBar({ export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts, search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline, online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, onRefresh, refreshing, refreshProgress, resultCount,
}: Props) { }: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
: null;
return ( return (
<header className="border-b border-edge px-5 py-3 flex items-center gap-3 flex-wrap bg-ink"> <div
<div className="relative flex-1 min-w-52 max-w-md"> className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur
<input value={search} onChange={(e) => onSearch(e.target.value)} 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" placeholder="Search videos and channels"
className="w-full rounded-full bg-surface border border-edge px-4 py-2 text-sm className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`}
placeholder:text-muted/70 focus:outline-none focus:border-accent" /> />
<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> </div>
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)} {refreshing && (
disabled={!online} <div className="h-0.5 overflow-hidden bg-slate-200 dark:bg-slate-700">
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}> <div
Downloaded only className={`h-full bg-sky-500 transition-[width] duration-100 ${
</Toggle> pct == null ? "w-1/3 animate-pulse" : ""
}`}
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)} style={pct == null ? undefined : { width: `${pct}%` }}
title="Hide Shorts from the feed"> />
Hide Shorts </div>
</Toggle> )}
</div>
<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>
); );
} }
+35 -27
View File
@@ -21,52 +21,60 @@ export default function VideoRow({
const src = thumbSrc(item, online); const src = thumbSrc(item, online);
return ( return (
<div className="group flex gap-4 rounded-xl p-3 hover:bg-surface transition-colors"> <li
<button onClick={onOpen} className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-2
className="relative shrink-0 w-44 aspect-video rounded-lg overflow-hidden bg-raised cursor-pointer"> 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 ? ( {src ? (
<img src={src} alt="" loading="lazy" <img src={src} alt="" loading="lazy" className="size-full object-cover" />
className="size-full object-cover group-hover:scale-105 transition-transform duration-300" />
) : ( ) : (
<div className="size-full grid place-items-center text-muted text-xs px-2 text-center"> <span className="grid size-full place-items-center px-1 text-center text-[10px] text-slate-400">
No thumbnail cached No thumbnail
</div> </span>
)} )}
{item.is_short && ( {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"> <span
SHORT 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> </span>
)} )}
{downloaded && ( {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"> <span
OFFLINE 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> </span>
)} )}
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<button onClick={onOpen} className="text-left w-full cursor-pointer"> <button onClick={onOpen} className="w-full cursor-pointer text-left">
<h3 className="font-medium leading-snug line-clamp-2 group-hover:text-white"> <h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
{item.title}
</h3>
</button> </button>
<div className="mt-1 text-sm text-muted truncate">{item.channel_title}</div> <div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
<div className="mt-0.5 text-xs text-muted"> {item.channel_title}
{[compactViews(item.views), relativeTime(item.published)] </div>
.filter(Boolean) <div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
.join(" · ")} {[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
</div> </div>
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && ( {(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} {live?.error ?? item.error}
</div> </div>
)} )}
</div> </div>
<div className="shrink-0 self-center"> <DownloadButton item={item} live={live} online={online}
<DownloadButton item={item} live={live} online={online} onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} /> </li>
</div>
</div>
); );
} }
+174
View File
@@ -0,0 +1,174 @@
/**
* The design system's component vocabulary, in one place. Every other file
* composes these rather than re-spelling the class strings.
*/
import type { ReactNode } from "react";
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
export const HELP =
"text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const PANEL =
"bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-800";
export const SECTION =
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed";
export const BTN =
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` +
"hover:bg-sky-400 disabled:opacity-40";
export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME =
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " +
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
/** Reads as a link; sits at the edge of a group. */
export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
export function SectionHeading({
step,
children,
}: {
step?: number;
children: ReactNode;
}) {
return (
<h2 className={`flex items-center gap-2 ${HEADING}`}>
{step !== undefined && (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-sky-500 text-[9px] font-bold leading-none text-white">
{step}
</span>
)}
{children}
</h2>
);
}
/** Bordered container, borderless children — the group's outline does the framing. */
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div
role="group"
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700"
>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " +
(active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{o.label}
</button>
);
})}
</div>
);
}
/**
* Success confirmation of something that already happened, so it never asks to
* be dismissed. Making someone dismiss a box to be told it worked is a bug.
*/
export function Toast({ message }: { message: string | null }) {
return (
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[80] flex flex-col items-center gap-2">
{message && (
<div
role="status"
className="pointer-events-auto rounded-full border border-slate-300 bg-white px-4 py-2
text-[12.5px] shadow-xl transition-opacity duration-300
dark:border-slate-700 dark:bg-slate-800"
>
{message}
</div>
)}
</div>
);
}
/** A modal for anything needing a decision or reporting a failure. */
export function Dialog({
title,
children,
onCancel,
confirmLabel,
onConfirm,
destructive,
wide,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
confirmLabel?: string;
onConfirm?: () => void;
destructive?: boolean;
wide?: boolean;
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-lg max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
{onConfirm ? "Cancel" : "Close"}
</button>
{onConfirm && (
<button
onClick={onConfirm}
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`}
>
{confirmLabel ?? "Continue"}
</button>
)}
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useState } from "react";
export type Appearance = "system" | "light" | "dark";
export const APPEARANCE_MODES: Appearance[] = ["system", "light", "dark"];
const KEY = "flighttube.appearance";
const media = window.matchMedia("(prefers-color-scheme: dark)");
function stored(): Appearance {
try {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" || v === "system" ? v : "system";
} catch {
return "system";
}
}
const effective = (mode: Appearance) =>
mode === "system" ? (media.matches ? "dark" : "light") : mode;
/**
* Three states, not two. "System" keeps following the OS if it changes
* mid-session; light and dark are explicit overrides.
*/
export function useAppearance() {
const [mode, setMode] = useState<Appearance>(stored);
const [shown, setShown] = useState<"light" | "dark">(() => effective(stored()));
const apply = useCallback((m: Appearance) => {
const next = effective(m);
document.documentElement.classList.toggle("dark", next === "dark");
setShown(next);
}, []);
useEffect(() => {
apply(mode);
try {
localStorage.setItem(KEY, mode);
} catch {
/* storage blocked */
}
}, [mode, apply]);
useEffect(() => {
const onChange = () => {
if (mode === "system") apply(mode);
};
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [mode, apply]);
return { mode, setMode, shown };
}
+60 -13
View File
@@ -1,16 +1,63 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { /* Class-based dark mode, so the app can offer System / Light / Dark rather
--color-ink: #0f0f0f; than only following the OS. */
--color-surface: #181818; @custom-variant dark (&:where(.dark, .dark *));
--color-raised: #212121;
--color-edge: #303030;
--color-muted: #aaaaaa;
--color-accent: #3ea6ff;
}
html, body, #root { height: 100%; } @layer base {
body { margin: 0; background: var(--color-ink); color: #fff; /* Native controls utilities cannot reach into. */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } input[type="range"] { accent-color: var(--color-sky-500); }
::-webkit-scrollbar { width: 10px; } select optgroup { font-style: normal; }
::-webkit-scrollbar-thumb { background: var(--color-edge); border-radius: 5px; }
/* A `display` utility otherwise beats the [hidden] attribute. */
[hidden] { display: none !important; }
/* It is a tool, not a document: dragging across it should not leave a
selection, and a stray double-click should not highlight a label.
Text fields opt back in — without a selection you cannot fix a typo,
only retype the field. */
html { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable="true"] { -webkit-user-select: text; user-select: text; }
img, a { -webkit-user-drag: none; }
/* No focus ring anywhere. :focus-visible has to go too — WebKit counts a
click on a select or checkbox as "focus worth showing" and draws its own
ring, which survives a :focus rule alone. */
*, *::before, *::after, :focus, :focus-visible, :focus-within {
outline: none !important;
outline-offset: 0 !important;
-webkit-tap-highlight-color: transparent;
}
::-moz-focus-inner { border: 0 !important; }
::-moz-focus-outer { border: 0 !important; }
input[type="range"]:focus, input[type="range"]:focus-visible,
select:focus, select:focus-visible,
button:focus, button:focus-visible,
[contenteditable]:focus { outline: none !important; }
html, body, #root { height: 100%; }
body {
margin: 0;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
background: var(--color-slate-100);
color: var(--color-slate-800);
}
.dark body {
background: var(--color-slate-950);
color: var(--color-slate-100);
}
/* Borders do the separating; the scrollbar should not compete. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--color-slate-300);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb { background: var(--color-slate-700); background-clip: content-box; }
}
+7
View File
@@ -75,3 +75,10 @@ export interface DownloadStateEvent {
error: string | null; error: string | null;
path: string | null; path: string | null;
} }
export interface ImportPreview {
incoming: number;
removed_channels: number;
removed_videos: number;
removed_downloads: number;
}