feat: feed UI, in-app player, offline mode, settings

Adds an end-to-end integration test that runs the real pipeline against
live YouTube Atom feeds, verifying the merged feed is newest-first
across channels.
This commit is contained in:
vincent
2026-08-29 02:34:14 +02:00
parent a30423e4b5
commit e75d896933
16 changed files with 1059 additions and 2 deletions
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api";
import type { Prereqs } from "../types";
interface Props {
onClose: () => void;
onImported: (count: number) => void;
}
function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) {
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"}
</span>
</div>
);
}
export default function Settings({ onClose, onImported }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | 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);
try {
const n = await pickAndImportTakeout();
if (n != null) {
setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`);
onImported(n);
}
} catch (e) {
setMessage(String(e));
} finally {
setBusy(false);
}
};
const doPickFolder = async () => {
try {
const p = await pickLibraryFolder();
if (p) { setMessage(`Library moved to ${p}`); load(); }
} catch (e) {
setMessage(String(e));
}
};
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
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>
<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.
</p>
<button onClick={doImport} disabled={busy}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-2 text-xs
font-semibold cursor-pointer disabled:opacity-40">
{busy ? "Importing…" : "Import subscriptions.csv"}
</button>
</section>
<section className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted shrink-0">Library</span>
<button onClick={doPickFolder}
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
{prereqs?.library_path ?? "…"}
</button>
</div>
{missing && (
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<p className="text-xs text-red-200 leading-relaxed">
Downloads need both tools. Install them with:
</p>
<code className="mt-1.5 block text-xs text-white bg-black/40 rounded px-2 py-1.5">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>
{message && <p className="text-xs text-muted break-words">{message}</p>}
</div>
</div>
);
}