feat(header): move the sync status into a chip on the header

The rail spent a two-row block plus a divider on per-user sync status. It's
global, not per-module, so it now rides the right end of the floating header as
a pill: the headline count at a glance, an accent dot when something wants you
(paused / missing history), and the counts, state, the channel-manager link and
pause in a popover.

Frees the rail's whole top block. Also drops SyncStatus's two now-dead layouts:
"bar" was a leftover of the top bar removed in 0.42.0, and "rail" lost its only
caller here — the component is chip-only, ~60 lines lighter, and the popover
reuses useDismiss.
This commit is contained in:
2026-07-15 22:42:31 +02:00
parent c440a043f2
commit 104183118d
4 changed files with 97 additions and 160 deletions
+6 -6
View File
@@ -786,12 +786,6 @@ export default function App() {
language={i18n.language as LangCode}
collapsed={navCollapsed}
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
content column. Sits beside the nav rail as its own collapsible column. */}
@@ -866,6 +860,12 @@ export default function App() {
page={page}
setPage={setPage}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level
+12 -4
View File
@@ -6,11 +6,11 @@ import type { Page } from "../lib/urlState";
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar";
import SyncStatus from "./SyncStatus";
// The floating top header: a fixed-position row of glass pills over the content — a ModuleName
// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill.
// The row's left edge is a constant (as if the nav rail AND filter sidebar were both open), so it
// never shifts when either collapses or when paging between modules; the right edge leaves ~10%.
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
export default function Header({
me,
filters,
@@ -24,6 +24,7 @@ export default function Header({
page,
setPage,
onYtSearch,
onGoToFullHistory,
}: {
me: Me;
filters: FeedFilters;
@@ -40,6 +41,8 @@ export default function Header({
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota).
onYtSearch: (q: string) => void;
// Jump to the channel manager filtered to channels still missing full history.
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
@@ -116,6 +119,11 @@ export default function Header({
canNext={multiModule}
/>
{search}
{/* Per-user sync status — global, not per-module, so it rides the right end of the header
rather than costing the nav rail a whole block. */}
<div className="ml-auto">
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
</div>
</div>
);
}
-13
View File
@@ -34,7 +34,6 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import SyncStatus from "./SyncStatus";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@@ -48,7 +47,6 @@ export default function NavSidebar({
language,
collapsed,
onToggleCollapse,
onGoToFullHistory,
}: {
me: Me;
page: Page;
@@ -60,7 +58,6 @@ export default function NavSidebar({
// it and asks App to flip it.
collapsed: boolean;
onToggleCollapse: () => void;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
@@ -306,16 +303,6 @@ export default function NavSidebar({
</button>
</div>
{/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */}
<div className="mb-3 pb-3 border-b border-border">
<SyncStatus
isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory}
variant="rail"
collapsed={slim}
/>
</div>
<div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
+79 -137
View File
@@ -1,9 +1,11 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip";
import { useDismiss } from "../lib/useDismiss";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -11,18 +13,19 @@ import Tooltip from "./Tooltip";
export default function SyncStatus({
isAdmin,
onGoToFullHistory,
variant = "bar",
collapsed = false,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
// "bar" = the legacy horizontal top-bar row. "rail" = a compact block for the top of the
// left nav sidebar (stacked; icon-only with a tooltip when the rail is collapsed).
variant?: "bar" | "rail";
collapsed?: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter
// would otherwise trap its stacking and positioning.
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
const { data } = useQuery({
queryKey: ["my-status"],
queryFn: api.myStatus,
@@ -57,7 +60,7 @@ export default function SyncStatus({
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
// Calm one-liner describing the current sync state; shared by the bar and rail layouts.
// Calm one-liner describing the current sync state.
const stateNode = data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
@@ -93,136 +96,75 @@ export default function SyncStatus({
</button>
);
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
if (variant === "rail") {
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos
)} ${t("header.sync.total")}`;
if (collapsed) {
return (
<Tooltip hint={countsText}>
<div className="relative grid place-items-center py-1 text-muted">
{active ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Database className="w-4 h-4" />
)}
{(data.paused || notFull > 0) && (
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</div>
</Tooltip>
);
}
return (
<div className="text-[11px] text-muted leading-snug space-y-1">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
{/* Pause sits inline at the right end of the primary status row (the sync state, or —
when idle with only deep-history pending — the "N without full history" row), never on
an orphaned line of its own. */}
{showMain && (
<div className="flex items-center justify-between gap-1.5">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5">
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
{!showMain && pauseBtn}
</div>
)}
</div>
);
}
// Chip: the headline count rides in the header; the state, the history link and pause live in a
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
// history) so the collapsed chip still tells you when to look.
const needsAttention = data.paused || notFull > 0;
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
</span>
<span className="opacity-40">/</span>
<span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
{showMain && (
<>
<span className="opacity-40">·</span>
{data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
// A sync job is running right now — spin and name what it's doing.
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span>
) : syncing > 0 ? (
// Recent uploads queued for the next run, but nothing running now — static.
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
<span>{t("header.sync.allSynced")}</span>
)}
</>
)}
{notFull > 0 && (
<>
<span className="opacity-40">·</span>
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
</>
)}
{/* Pause only makes sense when there's work to pause (recent OR deep backfill);
Resume must always show while paused so it can be lifted. Hidden entirely when
idle and not paused. */}
{isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
)}
<div className="pointer-events-auto shrink-0">
<button
ref={chipRef}
onClick={() => {
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
title={t("header.sync.countTooltip")}
aria-expanded={open}
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
>
{active ? (
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
) : (
<Database className="w-3.5 h-3.5 shrink-0" />
)}
<span className="text-fg font-medium tabular-nums">{formatViews(data.my_videos)}</span>
{needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</button>
{open &&
createPortal(
<div
ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-50 text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-1.5 pb-2 border-b border-border/60">
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</div>,
document.body
)}
</div>
);
}