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:
+18
-2
@@ -2,9 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -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<Prereqs, String
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_takeout_csv(
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let raw = tokio::fs::read(&path)
|
||||
async fn read_channels(path: &str) -> Result<Vec<Channel>, 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<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;
|
||||
db.upsert_channels(&channels)
|
||||
db.replace_channels(&channels)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+209
-1
@@ -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<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> {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -102,3 +102,12 @@ pub struct Prereqs {
|
||||
pub ffmpeg: Option<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,
|
||||
}
|
||||
|
||||
+88
-41
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
|
||||
openExternal, refreshFeeds,
|
||||
@@ -7,12 +7,16 @@ import Player from "./components/Player";
|
||||
import Settings from "./components/Settings";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TopBar from "./components/TopBar";
|
||||
import { Dialog, Toast } from "./components/ui";
|
||||
import VideoRow from "./components/VideoRow";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { useConnectivity } from "./hooks/useConnectivity";
|
||||
import { useDownloads } from "./hooks/useDownloads";
|
||||
import { useFeed } from "./hooks/useFeed";
|
||||
import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
|
||||
|
||||
const TOAST_MS = 2400;
|
||||
|
||||
export default function App() {
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -22,10 +26,22 @@ export default function App() {
|
||||
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
|
||||
const { mode, setMode } = useAppearance();
|
||||
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
||||
|
||||
// A toast reports success and fades; a modal reports a failure or asks a
|
||||
// question. Never make someone dismiss a box to be told it worked.
|
||||
const toastTimer = useRef<number | undefined>(undefined);
|
||||
const say = useCallback((message: string) => {
|
||||
setToast(message);
|
||||
window.clearTimeout(toastTimer.current);
|
||||
toastTimer.current = window.setTimeout(() => setToast(null), TOAST_MS);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(toastTimer.current), []);
|
||||
|
||||
// Offline, the only videos that can be played are the ones already on disk,
|
||||
// so the feed collapses to those regardless of the toggle.
|
||||
const effectiveDownloadedOnly = downloadedOnly || !online;
|
||||
@@ -50,29 +66,40 @@ export default function App() {
|
||||
return () => un?.();
|
||||
}, []);
|
||||
|
||||
const totalVideos = useMemo(
|
||||
() => channels.reduce((n, c) => n + c.video_count, 0),
|
||||
useEffect(() => {
|
||||
if (error) setFailure(error);
|
||||
}, [error]);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
channels.reduce(
|
||||
(acc, c) => ({
|
||||
videos: acc.videos + c.video_count,
|
||||
downloaded: acc.downloaded + c.downloaded_count,
|
||||
}),
|
||||
{ videos: 0, downloaded: 0 },
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const doRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const s = await refreshFeeds();
|
||||
const failed = s.failures.length;
|
||||
setNotice(
|
||||
`Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` +
|
||||
(failed ? ` · ${failed} failed` : ""),
|
||||
);
|
||||
await reload();
|
||||
const failed = s.failures.length;
|
||||
say(
|
||||
failed
|
||||
? `Checked ${s.channels} channels · ${failed} failed`
|
||||
: `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
setNotice(String(e));
|
||||
setFailure(String(e));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setRefreshProgress(null);
|
||||
}
|
||||
}, [reload]);
|
||||
}, [reload, say]);
|
||||
|
||||
const openItem = useCallback(
|
||||
async (item: FeedItem) => {
|
||||
@@ -83,7 +110,7 @@ export default function App() {
|
||||
} else if (online) {
|
||||
await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
|
||||
} else {
|
||||
setNotice("That video isn't downloaded, and you're offline.");
|
||||
setFailure("That video isn't downloaded, and you're offline.");
|
||||
}
|
||||
},
|
||||
[live, online],
|
||||
@@ -92,19 +119,25 @@ export default function App() {
|
||||
const emptyMessage = () => {
|
||||
if (loading) return "Loading…";
|
||||
if (channels.length === 0)
|
||||
return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv.";
|
||||
if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
|
||||
return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout.";
|
||||
if (totals.videos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
|
||||
if (!online) return "You're offline, and nothing has been downloaded yet.";
|
||||
if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
|
||||
return "Nothing matches this filter.";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-ink text-white overflow-hidden">
|
||||
<Sidebar channels={channels} activeChannel={channelId} onSelect={setChannelId}
|
||||
onOpenSettings={() => setShowSettings(true)} totalVideos={totalVideos} />
|
||||
<div className="flex h-screen flex-col lg:flex-row">
|
||||
<Sidebar
|
||||
channels={channels}
|
||||
activeChannel={channelId}
|
||||
onSelect={setChannelId}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
totalVideos={totals.videos}
|
||||
totalDownloaded={totals.downloaded}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<TopBar
|
||||
search={search} onSearch={setSearch}
|
||||
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
|
||||
@@ -112,47 +145,43 @@ export default function App() {
|
||||
online={online} reachable={reachable} forcedOffline={forcedOffline}
|
||||
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
|
||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||
resultCount={items.length}
|
||||
/>
|
||||
|
||||
{!online && (
|
||||
<div className="px-5 py-2 bg-amber-500/15 text-amber-200 text-xs border-b border-amber-500/25">
|
||||
<div
|
||||
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
|
||||
leading-snug text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Offline — showing only videos you've downloaded.
|
||||
{forcedOffline && " (Offline mode is forced on.)"}
|
||||
{forcedOffline && " Offline mode is forced on."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(notice || error) && (
|
||||
<div className="px-5 py-2 bg-surface text-xs text-muted border-b border-edge flex items-center justify-between gap-3">
|
||||
<span className="truncate">{error ?? notice}</span>
|
||||
<button onClick={() => setNotice(null)}
|
||||
className="shrink-0 hover:text-white cursor-pointer">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-3 py-3">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
|
||||
{items.length === 0 ? (
|
||||
<div className="h-full grid place-items-center">
|
||||
<p className="text-muted text-sm max-w-md text-center leading-relaxed">
|
||||
<div className="grid h-full place-items-center">
|
||||
<p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
|
||||
{emptyMessage()}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<ul className="mx-auto max-w-4xl space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
|
||||
onOpen={() => openItem(item)}
|
||||
onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))}
|
||||
onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))}
|
||||
onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))}
|
||||
onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))}
|
||||
onDelete={() =>
|
||||
deleteDownload(item.id).then(reload).catch((e) => setNotice(String(e)))
|
||||
deleteDownload(item.id)
|
||||
.then(() => { reload(); say("Download deleted"); })
|
||||
.catch((e) => setFailure(String(e)))
|
||||
} />
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{playing && (
|
||||
<Player item={playing.item} path={playing.path}
|
||||
@@ -161,12 +190,30 @@ export default function App() {
|
||||
await deleteDownload(playing.item.id);
|
||||
setPlaying(null);
|
||||
reload();
|
||||
say("Download deleted");
|
||||
}} />
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<Settings onClose={() => setShowSettings(false)} onImported={() => reload()} />
|
||||
<Settings
|
||||
onClose={() => setShowSettings(false)}
|
||||
appearance={mode}
|
||||
onAppearance={setMode}
|
||||
onError={setFailure}
|
||||
onImported={(n) => {
|
||||
reload();
|
||||
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{failure && (
|
||||
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
|
||||
<p className="break-words">{failure}</p>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Toast message={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-3
@@ -7,6 +7,7 @@ import type {
|
||||
DownloadStateEvent,
|
||||
FeedFilter,
|
||||
FeedItem,
|
||||
ImportPreview,
|
||||
Prereqs,
|
||||
RefreshProgress,
|
||||
RefreshSummary,
|
||||
@@ -39,16 +40,22 @@ export const openExternal = (url: string) =>
|
||||
invoke<void>("open_external", { url });
|
||||
|
||||
/** 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({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }],
|
||||
});
|
||||
if (typeof path !== "string") return null;
|
||||
return invoke<number>("import_takeout_csv", { path });
|
||||
return typeof path === "string" ? path : null;
|
||||
}
|
||||
|
||||
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> {
|
||||
const path = await open({ directory: true, multiple: false });
|
||||
if (typeof path !== "string") return null;
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<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`}>
|
||||
<span className="group-hover:hidden">✓ Saved</span>
|
||||
className={`${CHIP} group border border-slate-300 text-slate-500
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "running" || state === "queued") {
|
||||
const label =
|
||||
state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting";
|
||||
const known = state === "running" && pct != null;
|
||||
return (
|
||||
<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`}>
|
||||
<ProgressRing pct={state === "queued" ? null : pct} />
|
||||
<span className="group-hover:hidden tabular-nums">{label}</span>
|
||||
<span className="hidden group-hover:inline">Cancel</span>
|
||||
className={`${CHIP} group w-[104px] border border-slate-300 text-slate-500
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
@@ -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`}>
|
||||
<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>
|
||||
? "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"}
|
||||
</button>
|
||||
);
|
||||
|
||||
+26
-19
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 bg-ink/97 flex flex-col">
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-edge">
|
||||
<button onClick={onClose}
|
||||
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface hover:bg-raised cursor-pointer">
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||
<header
|
||||
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
← Back
|
||||
</button>
|
||||
<span className="text-sm text-muted truncate flex-1">{item.channel_title}</span>
|
||||
<button onClick={onDelete}
|
||||
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface text-muted
|
||||
hover:bg-red-500/20 hover:text-red-300 cursor-pointer">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete download
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
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
|
||||
className="absolute inset-0 h-full w-full object-contain" />
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge">
|
||||
<h2 className="font-semibold text-lg leading-snug">{item.title}</h2>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
{[compactViews(item.views), relativeTime(item.published)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
<footer
|
||||
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+152
-53
@@ -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 (
|
||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-edge/60">
|
||||
<span className="text-sm text-muted shrink-0">{label}</span>
|
||||
<span className={`text-sm text-right ${value ? "text-emerald-300" : "text-red-300"}`}>
|
||||
{value ?? hint ?? "Not found"}
|
||||
<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={LABEL}>{label}</span>
|
||||
<span
|
||||
className={`text-right text-[12px] ${
|
||||
value ? "text-slate-600 dark:text-slate-300" : "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{value ?? "Not found"}
|
||||
</span>
|
||||
</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 [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 grid place-items-center p-6" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
<button onClick={onClose}
|
||||
className="rounded-full px-3 py-1.5 text-xs bg-raised hover:bg-edge cursor-pointer">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => 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"
|
||||
>
|
||||
<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">
|
||||
<h3 className="text-sm font-medium">Subscriptions</h3>
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
Export <span className="text-white">YouTube subscriptions</span> from Google Takeout,
|
||||
then import the <code className="text-white">subscriptions.csv</code> file here.
|
||||
Re-importing merges with what you already have.
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Get your subscriptions</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
YouTube has no public API for someone else's subscription list, so FlightTube
|
||||
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"}
|
||||
<div className={`mt-3 ${SUBPANEL}`}>
|
||||
<TakeoutGuide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
||||
<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="text-sm text-muted shrink-0">Library</span>
|
||||
<button onClick={doPickFolder}
|
||||
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
|
||||
<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 bg-red-500/10 border border-red-500/30 p-3">
|
||||
<p className="text-xs text-red-200 leading-relaxed">
|
||||
<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 text-xs text-white bg-black/40 rounded px-2 py-1.5">
|
||||
<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>
|
||||
|
||||
{message && <p className="text-xs text-muted break-words">{message}</p>}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+55
-30
@@ -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 (
|
||||
<aside className="w-64 shrink-0 border-r border-edge flex flex-col bg-ink">
|
||||
<div className="px-4 py-4 flex items-center gap-2">
|
||||
<span className="text-xl">✈</span>
|
||||
<span className="font-semibold tracking-tight">FlightTube</span>
|
||||
</div>
|
||||
<aside
|
||||
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
|
||||
border-slate-300 bg-white lg:h-screen lg:max-h-none lg:w-[280px]
|
||||
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<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">
|
||||
<button onClick={() => onSelect(null)}
|
||||
className={`${rowBase} ${
|
||||
activeChannel === null ? "bg-raised text-white" : "text-muted hover:bg-surface"
|
||||
}`}>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`${row} ${activeChannel === null ? active : inactive}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<div className="pt-3 pb-1 px-3 text-[11px] uppercase tracking-wider text-muted/70">
|
||||
Channels
|
||||
</div>
|
||||
<h2 className={`${HEADING} px-2 pb-1 pt-4`}>Channels</h2>
|
||||
)}
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{channels.map((c) => (
|
||||
<button key={c.id} onClick={() => onSelect(c.id)} title={c.title}
|
||||
className={`${rowBase} ${
|
||||
activeChannel === c.id ? "bg-raised text-white" : "text-muted hover:bg-surface"
|
||||
}`}>
|
||||
<li key={c.id}>
|
||||
<button
|
||||
onClick={() => onSelect(c.id)}
|
||||
title={c.title}
|
||||
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
||||
>
|
||||
<span className="truncate">{c.title}</span>
|
||||
<span className="text-xs tabular-nums opacity-70 shrink-0">
|
||||
{c.downloaded_count > 0 && (
|
||||
<span className="text-emerald-400">{c.downloaded_count}/</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||
{c.video_count}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</nav>
|
||||
</ul>
|
||||
|
||||
<button onClick={onOpenSettings}
|
||||
className="m-2 px-3 py-2 rounded-lg text-sm text-muted hover:bg-surface text-left cursor-pointer">
|
||||
Settings
|
||||
</button>
|
||||
{channels.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
|
||||
No subscriptions yet. Open Settings for a step-by-step guide to exporting
|
||||
them from Google Takeout.
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+73
-32
@@ -1,3 +1,5 @@
|
||||
import { BTN_PRIMARY, INPUT } from "./ui";
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
onSearch: (v: string) => void;
|
||||
@@ -12,8 +14,10 @@ interface Props {
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
refreshProgress: { done: number; total: number } | null;
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
/** Neutral outline until active; active is the one filled state. */
|
||||
function Toggle({
|
||||
active, onClick, children, title, disabled,
|
||||
}: {
|
||||
@@ -24,10 +28,19 @@ function Toggle({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={disabled}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer whitespace-nowrap ${
|
||||
active ? "bg-white text-black" : "bg-surface text-muted hover:bg-raised hover:text-white"
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
@@ -36,16 +49,30 @@ function Toggle({
|
||||
export default function TopBar({
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress,
|
||||
onRefresh, refreshing, refreshProgress, resultCount,
|
||||
}: Props) {
|
||||
const pct = refreshProgress && refreshProgress.total > 0
|
||||
? (refreshProgress.done / refreshProgress.total) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header className="border-b border-edge px-5 py-3 flex items-center gap-3 flex-wrap bg-ink">
|
||||
<div className="relative flex-1 min-w-52 max-w-md">
|
||||
<input value={search} onChange={(e) => onSearch(e.target.value)}
|
||||
<div
|
||||
className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur
|
||||
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"
|
||||
className="w-full rounded-full bg-surface border border-edge px-4 py-2 text-sm
|
||||
placeholder:text-muted/70 focus:outline-none focus:border-accent" />
|
||||
</div>
|
||||
className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`}
|
||||
/>
|
||||
|
||||
<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}
|
||||
@@ -58,19 +85,8 @@ export default function TopBar({
|
||||
Hide Shorts
|
||||
</Toggle>
|
||||
|
||||
<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}
|
||||
<button
|
||||
onClick={onToggleForcedOffline}
|
||||
title={
|
||||
!reachable
|
||||
? "No connection detected"
|
||||
@@ -78,15 +94,40 @@ export default function TopBar({
|
||||
? "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"}`} />
|
||||
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>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
{refreshing && (
|
||||
<div className="h-0.5 overflow-hidden bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className={`h-full bg-sky-500 transition-[width] duration-100 ${
|
||||
pct == null ? "w-1/3 animate-pulse" : ""
|
||||
}`}
|
||||
style={pct == null ? undefined : { width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+33
-25
@@ -21,52 +21,60 @@ export default function VideoRow({
|
||||
const src = thumbSrc(item, online);
|
||||
|
||||
return (
|
||||
<div className="group flex gap-4 rounded-xl p-3 hover:bg-surface transition-colors">
|
||||
<button onClick={onOpen}
|
||||
className="relative shrink-0 w-44 aspect-video rounded-lg overflow-hidden bg-raised cursor-pointer">
|
||||
<li
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-2
|
||||
hover:border-slate-300 dark:border-slate-800 dark:bg-slate-800/50
|
||||
dark:hover:border-slate-700"
|
||||
>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="relative aspect-video w-32 shrink-0 cursor-pointer overflow-hidden rounded
|
||||
bg-slate-200 dark:bg-slate-800"
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy"
|
||||
className="size-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
|
||||
) : (
|
||||
<div className="size-full grid place-items-center text-muted text-xs px-2 text-center">
|
||||
No thumbnail cached
|
||||
</div>
|
||||
<span className="grid size-full place-items-center px-1 text-center text-[10px] text-slate-400">
|
||||
No thumbnail
|
||||
</span>
|
||||
)}
|
||||
{item.is_short && (
|
||||
<span className="absolute top-1.5 left-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
SHORT
|
||||
<span
|
||||
className="absolute left-1 top-1 rounded bg-slate-950/75 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Short
|
||||
</span>
|
||||
)}
|
||||
{downloaded && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-emerald-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-black">
|
||||
OFFLINE
|
||||
<span
|
||||
className="absolute bottom-1 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<button onClick={onOpen} className="text-left w-full cursor-pointer">
|
||||
<h3 className="font-medium leading-snug line-clamp-2 group-hover:text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<button onClick={onOpen} className="w-full cursor-pointer text-left">
|
||||
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
|
||||
</button>
|
||||
<div className="mt-1 text-sm text-muted truncate">{item.channel_title}</div>
|
||||
<div className="mt-0.5 text-xs text-muted">
|
||||
{[compactViews(item.views), relativeTime(item.published)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
|
||||
<div className="mt-1 text-xs text-red-400 line-clamp-1">
|
||||
<div className="mt-1 line-clamp-1 text-[11px] text-red-600 dark:text-red-400">
|
||||
{live?.error ?? item.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 self-center">
|
||||
<DownloadButton item={item} live={live} online={online}
|
||||
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+58
-11
@@ -1,16 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #0f0f0f;
|
||||
--color-surface: #181818;
|
||||
--color-raised: #212121;
|
||||
--color-edge: #303030;
|
||||
--color-muted: #aaaaaa;
|
||||
--color-accent: #3ea6ff;
|
||||
/* Class-based dark mode, so the app can offer System / Light / Dark rather
|
||||
than only following the OS. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
/* Native controls utilities cannot reach into. */
|
||||
input[type="range"] { accent-color: var(--color-sky-500); }
|
||||
select optgroup { font-style: normal; }
|
||||
|
||||
/* 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; background: var(--color-ink); color: #fff;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-edge); border-radius: 5px; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -75,3 +75,10 @@ export interface DownloadStateEvent {
|
||||
error: string | null;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
incoming: number;
|
||||
removed_channels: number;
|
||||
removed_videos: number;
|
||||
removed_downloads: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user