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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user