Files
FlightTube/src/components/Sidebar.tsx
T
vincent dc1efccf87 feat: icon view toggle, failing filter, quality badges
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.
2026-08-29 15:47:32 +02:00

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>
);
}