+
+ className="absolute inset-0 size-full object-contain" />
-
-
{item.title}
-
- {[compactViews(item.views), relativeTime(item.published)]
- .filter(Boolean)
- .join(" · ")}
+
+
);
}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
index 5bc3ae9..75dbc50 100644
--- a/src/components/Settings.tsx
+++ b/src/components/Settings.tsx
@@ -1,109 +1,208 @@
import { useEffect, useState } from "react";
-import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api";
-import type { Prereqs } from "../types";
+import {
+ checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
+} from "../api";
+import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
+import type { ImportPreview, Prereqs } from "../types";
+import TakeoutGuide from "./TakeoutGuide";
+import {
+ BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
+} from "./ui";
interface Props {
onClose: () => void;
onImported: (count: number) => void;
+ appearance: Appearance;
+ onAppearance: (a: Appearance) => void;
+ onError: (message: string) => void;
}
-function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) {
+function StatusRow({ label, value }: { label: string; value: string | null }) {
return (
-
-
{label}
-
- {value ?? hint ?? "Not found"}
+
+ {label}
+
+ {value ?? "Not found"}
);
}
-export default function Settings({ onClose, onImported }: Props) {
+export default function Settings({
+ onClose, onImported, appearance, onAppearance, onError,
+}: Props) {
const [prereqs, setPrereqs] = useState(null);
+ const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
const [busy, setBusy] = useState(false);
- const [message, setMessage] = useState(null);
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []);
- const doImport = async () => {
- setBusy(true);
- setMessage(null);
+ const startImport = async () => {
try {
- const n = await pickAndImportTakeout();
- if (n != null) {
- setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`);
- onImported(n);
- }
+ const path = await pickTakeoutFile();
+ if (!path) return;
+ setPending({ path, preview: await previewTakeoutImport(path) });
} catch (e) {
- setMessage(String(e));
+ onError(String(e));
+ }
+ };
+
+ const confirmImport = async () => {
+ if (!pending) return;
+ setBusy(true);
+ try {
+ const n = await importTakeoutCsv(pending.path);
+ setPending(null);
+ onImported(n);
+ } catch (e) {
+ setPending(null);
+ onError(String(e));
} finally {
setBusy(false);
}
};
- const doPickFolder = async () => {
+ const pickFolder = async () => {
try {
- const p = await pickLibraryFolder();
- if (p) { setMessage(`Library moved to ${p}`); load(); }
+ if (await pickLibraryFolder()) load();
} catch (e) {
- setMessage(String(e));
+ onError(String(e));
}
};
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
+ const p = pending?.preview;
+ const destructive = !!p && (p.removed_channels > 0 || p.removed_downloads > 0);
return (
-
-
e.stopPropagation()}
- className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5">
-
-
Settings
-
-
+ <>
+
+
e.stopPropagation()}
+ className="flex max-h-[82vh] w-full max-w-lg flex-col rounded-2xl border border-slate-300
+ bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
+ >
+
-
- Subscriptions
-
- Export YouTube subscriptions from Google Takeout,
- then import the subscriptions.csv file here.
- Re-importing merges with what you already have.
-
-
-
-
-
- Status
-
-
-
- Library
-
-
- {missing && (
-
-
- Downloads need both tools. Install them with:
+
+
+ Get your subscriptions
+
+ YouTube has no public API for someone else's subscription list, so FlightTube
+ reads the export Google gives you. It takes about two minutes.
-
- brew install yt-dlp ffmpeg
-
-
- )}
-
+
+
+
+
- {message &&
{message}
}
+
+ Import
+
+ Importing replaces your current subscription list — the CSV becomes the
+ whole truth. Channels no longer in it are removed along with their videos and
+ downloads. You'll see exactly what goes before anything is deleted.
+
+
+
+
+
+ Appearance
+
+ Theme
+ ({
+ value: m,
+ label: m[0].toUpperCase() + m.slice(1),
+ }))}
+ />
+
+
+
+
+ Status
+
+
+
+
+ Library
+
+
+
+
+ {missing && (
+
+
+ Downloads need both tools. Install them with:
+
+
+ brew install yt-dlp ffmpeg
+
+
+ )}
+
+
+
-
+
+ {pending && p && (
+
+ )}
+ >
);
}
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 5931085..04dbd35 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -1,4 +1,5 @@
import type { ChannelWithCount } from "../types";
+import { BTN_CHROME, HEADING } from "./ui";
interface Props {
channels: ChannelWithCount[];
@@ -6,56 +7,80 @@ interface Props {
onSelect: (id: string | null) => void;
onOpenSettings: () => void;
totalVideos: number;
+ totalDownloaded: number;
}
export default function Sidebar({
- channels, activeChannel, onSelect, onOpenSettings, totalVideos,
+ channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
}: Props) {
- const rowBase =
- "w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer";
+ const row =
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
+ "text-[13px] transition-colors";
+ const inactive =
+ "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
+ const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
return (
-