Files
FlightTube/src/components/TopBar.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

203 lines
7.3 KiB
TypeScript

import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid";
interface Props {
search: string;
onSearch: (v: string) => void;
downloadedOnly: boolean;
onDownloadedOnly: (v: boolean) => void;
online: boolean;
reachable: boolean;
forcedOffline: boolean;
onToggleForcedOffline: () => void;
onRefresh: () => void;
refreshing: boolean;
refreshProgress: { done: number; total: number } | null;
resultCount: number;
view: ViewMode;
onView: (v: ViewMode) => void;
sidebarHidden: boolean;
onShowSidebar: () => void;
/** Present only when there is something to delete. */
onDeleteAll?: () => void;
/** Matches the sidebar's inset so the two headers share a baseline. */
titleBarInset: boolean;
}
/** Neutral outline until active; active is the one filled state. */
function Toggle({
active, onClick, children, title, disabled,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
title?: string;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
disabled={disabled}
className={
`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap rounded-lg border px-2.5 text-[12px] ` +
"font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
(active
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
: "border-slate-300 text-slate-500 hover:border-sky-500 hover:text-sky-600 " +
"dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 dark:hover:text-sky-400")
}
>
{children}
</button>
);
}
export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
: null;
return (
<div
className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur
dark:border-slate-800 dark:bg-slate-900/95"
>
{titleBarInset && <div data-tauri-drag-region className="h-9" />}
<div className="flex flex-nowrap items-center gap-2 px-4 py-3">
{sidebarHidden && (
<button
onClick={onShowSidebar}
title="Show subscriptions"
aria-label="Show subscriptions"
className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
dark:hover:border-sky-500 dark:hover:text-sky-400`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels"
className={`${INPUT} min-w-[17rem] max-w-sm flex-1`}
/>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{resultCount}
</span>
<div className="min-w-2 flex-1" />
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
disabled={!online}
title={online ? "Show only what is on this Mac" : "Offline: showing local videos only"}>
Local
</Toggle>
{downloadedOnly && onDeleteAll && (
<button
onClick={onDeleteAll}
title="Delete every download"
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium
text-slate-500 hover:border-red-500 hover:text-red-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
dark:hover:text-red-400`}
>
Delete all
</button>
)}
<Segmented
value={view}
onChange={onView}
options={[
{
value: "list",
title: "List",
label: (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
),
},
{
value: "grid",
title: "Tiles",
label: (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="4" width="7" height="7" rx="1.5" />
<rect x="13" y="4" width="7" height="7" rx="1.5" />
<rect x="4" y="13" width="7" height="7" rx="1.5" />
<rect x="13" y="13" width="7" height="7" rx="1.5" />
</svg>
),
},
]}
/>
<button
onClick={onToggleForcedOffline}
title={
!reachable
? "No connection detected"
: forcedOffline
? "Offline mode is forced on — click to go back online"
: "Simulate being offline"
}
className={`inline-flex ${CONTROL_H} cursor-pointer items-center gap-1.5 whitespace-nowrap
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium text-slate-500
hover:border-sky-500 hover:text-sky-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
dark:hover:text-sky-400`}
>
<span
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
/>
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</button>
<button onClick={onRefresh} disabled={refreshing || !online}
title={
online
? refreshProgress
? `Refreshing ${refreshProgress.done}/${refreshProgress.total}`
: "Fetch the latest videos from every channel"
: "Refreshing needs a connection"
}
aria-label="Refresh"
className={`${ICON_BTN} bg-sky-500 text-white hover:bg-sky-400`}>
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</svg>
</button>
</div>
{refreshing && (
<div className="h-0.5 overflow-hidden bg-slate-200 dark:bg-slate-700">
<div
className={`h-full bg-sky-500 transition-[width] duration-100 ${
pct == null ? "w-1/3 animate-pulse" : ""
}`}
style={pct == null ? undefined : { width: `${pct}%` }}
/>
</div>
)}
</div>
);
}