feat: manage subscriptions in the app, and act from the menu bar
Takeout is still the way a subscription list arrives. These are the things it cannot do. Hovering a channel in the sidebar reveals a delete, sitting where the count is — the least useful thing on that row at the moment you reach for it. It asks first, and says what goes: the channel, its videos, and how many downloaded files will be deleted. It also says plainly that YouTube is not touched; only this app forgets the channel. A + beside the cog adds one channel from any link — its page, its @handle, or one of its videos. The channel id and name are read off the page, which is the same pair the CSV carries, and the feed is fetched straight away so it is not sitting there empty. A menu bar item acts on whatever a browser is showing without leaving it: save that video, or subscribe to its channel, while it plays on. The address comes from whichever running browser has a YouTube page — asked with AppleScript, guarded so no browser is launched to answer, and reported honestly when macOS has not granted the permission yet. Answers come back as notifications rather than by raising the window. Saving a video is not subscribing to its channel, so a channel can now exist as a parent row without being a subscription: out of the sidebar, skipped by refreshes, and left alone when a CSV import replaces the subscription list. Also removes a stale duplicate of the file header that had been sitting inside run()'s setup block. Items are legal inside a block, so it compiled and nobody noticed.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { addChannel } from "../api";
|
||||
import { BTN, BTN_PRIMARY, Dialog, HELP, INPUT, Spinner } from "./ui";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAdded: (title: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one channel from a link, for the ones a Takeout export does not have —
|
||||
* something found after the last import, or a channel followed nowhere but
|
||||
* here. Any YouTube link belonging to the channel works, including a video of
|
||||
* theirs, since the channel is what gets read off the page.
|
||||
*/
|
||||
export default function AddChannel({ onClose, onAdded }: Props) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const field = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => field.current?.focus(), []);
|
||||
|
||||
const submit = () => {
|
||||
if (busy || !url.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
addChannel(url.trim())
|
||||
.then(onAdded)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className={`${BTN} cursor-pointer`}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !url.trim()}
|
||||
className={`${BTN_PRIMARY} cursor-pointer disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{busy ? <Spinner /> : "Add channel"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog title="Add a channel" onCancel={onClose} footer={footer}>
|
||||
<p className={HELP}>
|
||||
Paste any link belonging to the channel — its own page, its @handle, or
|
||||
one of its videos. FlightTube reads the channel from the page and starts
|
||||
following it here. Nothing changes on YouTube.
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={field}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
placeholder="https://www.youtube.com/@channel"
|
||||
spellCheck={false}
|
||||
className={`${INPUT} mt-3 w-full`}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-[12px] leading-snug text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface Props {
|
||||
activeChannel: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onOpenSettings: () => void;
|
||||
onAddChannel: () => void;
|
||||
onDeleteChannel: (id: string) => void;
|
||||
totalVideos: number;
|
||||
totalDownloaded: number;
|
||||
onHide: () => void;
|
||||
@@ -18,7 +20,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
||||
channels, activeChannel, onSelect, onOpenSettings, onAddChannel, onDeleteChannel,
|
||||
totalVideos, totalDownloaded,
|
||||
onHide, floating, titleBarInset,
|
||||
}: Props) {
|
||||
const [onlyFailing, setOnlyFailing] = useState(false);
|
||||
@@ -64,6 +67,17 @@ export default function Sidebar({
|
||||
FlightTube
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={onAddChannel}
|
||||
title="Add a channel by link"
|
||||
aria-label="Add a channel by link"
|
||||
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
@@ -127,7 +141,7 @@ export default function Sidebar({
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{shown.map((c) => (
|
||||
<li key={c.id}>
|
||||
<li key={c.id} className="group/row relative">
|
||||
<button
|
||||
onClick={() => onSelect(c.id)}
|
||||
title={c.title}
|
||||
@@ -142,11 +156,31 @@ export default function Sidebar({
|
||||
)}
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||
<span
|
||||
className="shrink-0 font-mono text-[11px] tabular-nums opacity-70
|
||||
group-hover/row:invisible"
|
||||
>
|
||||
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||
{c.video_count}
|
||||
</span>
|
||||
</button>
|
||||
{/* Sits over the count, which is the least useful thing on the
|
||||
row at the moment you are reaching for this. */}
|
||||
<button
|
||||
onClick={() => onDeleteChannel(c.id)}
|
||||
title={`Remove ${c.title} from FlightTube`}
|
||||
aria-label={`Remove ${c.title}`}
|
||||
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 cursor-pointer
|
||||
rounded p-1 text-slate-400 hover:text-red-500
|
||||
group-hover/row:block"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+17
-12
@@ -186,6 +186,7 @@ export function Dialog({
|
||||
onConfirm,
|
||||
destructive,
|
||||
wide,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
@@ -194,6 +195,8 @@ export function Dialog({
|
||||
onConfirm?: () => void;
|
||||
destructive?: boolean;
|
||||
wide?: boolean;
|
||||
/** Replaces Cancel/Confirm, for a dialog whose buttons have their own state. */
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -212,19 +215,21 @@ export function Dialog({
|
||||
<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 : BTN_PRIMARY} cursor-pointer`}
|
||||
>
|
||||
{confirmLabel ?? "Continue"}
|
||||
{footer ?? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onCancel} className={`${BTN} cursor-pointer`}>
|
||||
{onConfirm ? "Cancel" : "Close"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
|
||||
>
|
||||
{confirmLabel ?? "Continue"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user