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">
|
<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>
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
+88
-41
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-53
@@ -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>
|
</p>
|
||||||
<button onClick={doImport} disabled={busy}
|
<div className={`mt-3 ${SUBPANEL}`}>
|
||||||
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-2 text-xs
|
<TakeoutGuide />
|
||||||
font-semibold cursor-pointer disabled:opacity-40">
|
</div>
|
||||||
{busy ? "Importing…" : "Import subscriptions.csv"}
|
</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>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-1">
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
<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="yt-dlp" value={prereqs?.yt_dlp ?? null} />
|
||||||
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
|
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
|
||||||
<div className="flex items-start justify-between gap-4 py-2">
|
<div className="flex items-start justify-between gap-4 py-2">
|
||||||
<span className="text-sm text-muted shrink-0">Library</span>
|
<span className={LABEL}>Library</span>
|
||||||
<button onClick={doPickFolder}
|
<button
|
||||||
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
|
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 ?? "…"}
|
{prereqs?.library_path ?? "…"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{missing && (
|
{missing && (
|
||||||
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
|
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
|
||||||
<p className="text-xs text-red-200 leading-relaxed">
|
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
|
||||||
Downloads need both tools. Install them with:
|
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">
|
<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
|
brew install yt-dlp ffmpeg
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{message && <p className="text-xs text-muted break-words">{message}</p>}
|
{pending && p && (
|
||||||
</div>
|
<Dialog
|
||||||
</div>
|
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 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-0.5">
|
||||||
{channels.map((c) => (
|
{channels.map((c) => (
|
||||||
<button key={c.id} onClick={() => onSelect(c.id)} title={c.title}
|
<li key={c.id}>
|
||||||
className={`${rowBase} ${
|
<button
|
||||||
activeChannel === c.id ? "bg-raised text-white" : "text-muted hover:bg-surface"
|
onClick={() => onSelect(c.id)}
|
||||||
}`}>
|
title={c.title}
|
||||||
|
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
||||||
|
>
|
||||||
<span className="truncate">{c.title}</span>
|
<span className="truncate">{c.title}</span>
|
||||||
<span className="text-xs tabular-nums opacity-70 shrink-0">
|
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||||
{c.downloaded_count > 0 && (
|
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||||
<span className="text-emerald-400">{c.downloaded_count}/</span>
|
|
||||||
)}
|
|
||||||
{c.video_count}
|
{c.video_count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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,16 +49,30 @@ 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" />
|
/>
|
||||||
</div>
|
|
||||||
|
<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)}
|
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
|
||||||
disabled={!online}
|
disabled={!online}
|
||||||
@@ -58,19 +85,8 @@ export default function TopBar({
|
|||||||
Hide Shorts
|
Hide Shorts
|
||||||
</Toggle>
|
</Toggle>
|
||||||
|
|
||||||
<button onClick={onRefresh} disabled={refreshing || !online}
|
<button
|
||||||
title={online ? "Fetch the latest videos" : "Refreshing needs a connection"}
|
onClick={onToggleForcedOffline}
|
||||||
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={
|
title={
|
||||||
!reachable
|
!reachable
|
||||||
? "No connection detected"
|
? "No connection detected"
|
||||||
@@ -78,15 +94,40 @@ export default function TopBar({
|
|||||||
? "Offline mode is forced on — click to go back online"
|
? "Offline mode is forced on — click to go back online"
|
||||||
: "Simulate being offline"
|
: "Simulate being offline"
|
||||||
}
|
}
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-medium flex items-center gap-1.5
|
className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border
|
||||||
cursor-pointer transition-colors whitespace-nowrap ${
|
border-slate-300 px-2.5 py-1.5 text-[11px] font-medium text-slate-500
|
||||||
online
|
hover:border-sky-500 hover:text-sky-600
|
||||||
? "bg-surface text-emerald-300 hover:bg-raised"
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
|
||||||
: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30"
|
dark:hover:text-sky-400"
|
||||||
}`}>
|
>
|
||||||
<span className={`size-2 rounded-full ${online ? "bg-emerald-400" : "bg-amber-400"}`} />
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
|
||||||
|
/>
|
||||||
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
|
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
|
||||||
</button>
|
</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);
|
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} />
|
||||||
</div>
|
</li>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
@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;
|
@layer base {
|
||||||
--color-muted: #aaaaaa;
|
/* Native controls utilities cannot reach into. */
|
||||||
--color-accent: #3ea6ff;
|
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%; }
|
html, body, #root { height: 100%; }
|
||||||
body { margin: 0; background: var(--color-ink); color: #fff;
|
body {
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
margin: 0;
|
||||||
::-webkit-scrollbar { width: 10px; }
|
-webkit-font-smoothing: antialiased;
|
||||||
::-webkit-scrollbar-thumb { background: var(--color-edge); border-radius: 5px; }
|
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;
|
error: string | null;
|
||||||
path: 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