List and Tiles are now icons rather than words, centred in their buttons — Segmented takes a ReactNode label and an optional title. The red "N failing" count in the sidebar is a button: click it to see only the channels that failed to refresh, click again for all of them. It clears itself once a refresh fixes everything, so the filter can never strand you on an empty list. The player states its quality as a badge instead of loose grey text, and a downloaded video now says so too — "Downloaded · 1440p" — read from the file itself. Quality is the short side, so a portrait Short reads 1080p rather than 1920p, and it resets between videos so one never inherits the previous one's number.
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import type { ChannelWithCount } from "../types";
|
|
import { HEADING, ICON_BTN } from "./ui";
|
|
|
|
interface Props {
|
|
channels: ChannelWithCount[];
|
|
activeChannel: string | null;
|
|
onSelect: (id: string | null) => void;
|
|
onOpenSettings: () => void;
|
|
totalVideos: number;
|
|
totalDownloaded: number;
|
|
onHide: () => void;
|
|
/** True when revealed by hover over the left edge rather than pinned open. */
|
|
floating?: boolean;
|
|
/** Reserve room for the macOS traffic lights above the header. */
|
|
titleBarInset: boolean;
|
|
}
|
|
|
|
export default function Sidebar({
|
|
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
|
onHide, floating, titleBarInset,
|
|
}: Props) {
|
|
const [onlyFailing, setOnlyFailing] = useState(false);
|
|
const failing = channels.filter((c) => c.last_error).length;
|
|
|
|
// A filter with nothing left to show is a dead end — drop it on the next
|
|
// refresh that fixes everything.
|
|
useEffect(() => {
|
|
if (failing === 0) setOnlyFailing(false);
|
|
}, [failing]);
|
|
|
|
const shown = onlyFailing ? channels.filter((c) => c.last_error) : channels;
|
|
|
|
const row =
|
|
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
|
|
"text-[13px] transition-colors";
|
|
const inactive =
|
|
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
|
|
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
|
|
|
|
return (
|
|
<aside
|
|
className={
|
|
"flex flex-col overflow-hidden border-slate-300 bg-white dark:border-slate-800 " +
|
|
"dark:bg-slate-900 " +
|
|
(floating
|
|
? "absolute inset-y-0 left-0 z-30 w-[280px] border-r shadow-2xl"
|
|
: "max-h-[45vh] w-full shrink-0 border-b lg:h-full lg:max-h-none lg:w-[280px] " +
|
|
"lg:border-b-0 lg:border-r")
|
|
}
|
|
>
|
|
{/* The pane starts at the very top of the window so its right border is
|
|
one unbroken line; this reserves the strip the traffic lights sit in. */}
|
|
{titleBarInset && <div data-tauri-drag-region className="h-9 shrink-0" />}
|
|
|
|
<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>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
onClick={onOpenSettings}
|
|
title="Settings"
|
|
aria-label="Settings"
|
|
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">
|
|
<circle cx="12" cy="12" r="3.2" />
|
|
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={onHide}
|
|
title="Hide subscriptions"
|
|
aria-label="Hide subscriptions"
|
|
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">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
|
<button
|
|
onClick={() => onSelect(null)}
|
|
className={`${row} ${activeChannel === null ? active : inactive}`}
|
|
>
|
|
<span className="font-medium">All subscriptions</span>
|
|
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
|
{totalDownloaded > 0 && `${totalDownloaded}/`}
|
|
{totalVideos}
|
|
</span>
|
|
</button>
|
|
|
|
{channels.length > 0 && (
|
|
<h2 className={`${HEADING} flex items-center justify-between px-2 pb-1 pt-4`}>
|
|
<span>Channels</span>
|
|
{failing > 0 && (
|
|
<button
|
|
onClick={() => setOnlyFailing((v) => !v)}
|
|
title={
|
|
onlyFailing
|
|
? "Show every channel again"
|
|
: `Show only the ${failing} channel${failing === 1 ? "" : "s"} that failed to refresh`
|
|
}
|
|
className={
|
|
"cursor-pointer rounded-full px-1.5 py-0.5 font-mono text-[10px] normal-case " +
|
|
"tracking-normal transition-colors " +
|
|
(onlyFailing
|
|
? "bg-red-500 text-white"
|
|
: "text-red-500 hover:bg-red-500/10")
|
|
}
|
|
>
|
|
{failing} failing
|
|
</button>
|
|
)}
|
|
</h2>
|
|
)}
|
|
|
|
<ul className="space-y-0.5">
|
|
{shown.map((c) => (
|
|
<li key={c.id}>
|
|
<button
|
|
onClick={() => onSelect(c.id)}
|
|
title={c.title}
|
|
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
{c.last_error && (
|
|
<span
|
|
title={`Last refresh failed: ${c.last_error}`}
|
|
className="size-1.5 shrink-0 rounded-full bg-red-500"
|
|
/>
|
|
)}
|
|
<span className="truncate">{c.title}</span>
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
|
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
|
{c.video_count}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{channels.length === 0 && (
|
|
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
|
|
No subscriptions yet. Open Settings for a step-by-step guide to exporting
|
|
them from Google Takeout.
|
|
</p>
|
|
)}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|