Merge: promote dev to prod

This commit is contained in:
2026-07-16 01:24:24 +02:00
29 changed files with 801 additions and 422 deletions
+1 -1
View File
@@ -1 +1 @@
0.43.1
0.44.0
+7 -6
View File
@@ -116,7 +116,7 @@ def _filtered_query(
*,
tags: list[int],
tag_mode: str,
channel_id: str | None,
channel_ids: list[str],
q: str | None,
min_duration: int | None,
max_duration: int | None,
@@ -186,7 +186,7 @@ def _filtered_query(
# opened an un-subscribed channel's page) is visible only to users who explored or
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
# asking for scope=all + channel_id (the channel is in this accessible set).
# asking for scope=all + channel_ids (the channel is in this accessible set).
accessible_explore = (
select(Subscription.channel_id).where(Subscription.user_id == user.id)
).union(
@@ -227,8 +227,9 @@ def _filtered_query(
else: # "organic" (default): subscriptions only
query = query.where(subscribed)
if channel_id:
query = query.where(Video.channel_id == channel_id)
if channel_ids:
# OR across the picked channels; every other filter still applies on top.
query = query.where(Video.channel_id.in_(channel_ids))
if min_duration is not None:
query = query.where(Video.duration_seconds >= min_duration)
if max_duration is not None:
@@ -354,7 +355,7 @@ def _to_tsquery_str(q: str) -> str | None:
def _feed_params(
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
channel_id: str | None = None,
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
max_duration: int | None = None,
@@ -371,7 +372,7 @@ def _feed_params(
return {
"tags": tags,
"tag_mode": tag_mode,
"channel_id": channel_id,
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
"max_duration": max_duration,
+26 -9
View File
@@ -89,6 +89,7 @@ const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
channelIds: [],
q: "",
sort: "newest",
scope: "my",
@@ -373,12 +374,13 @@ export default function App() {
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, true))
);
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false))
Boolean(readAccount(LS.filterCollapsed, true))
);
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.playlistsCollapsed, false))
Boolean(readAccount(LS.playlistsCollapsed, true))
);
function setNavCollapsed(next: boolean) {
setNavCollapsedState(next);
@@ -785,12 +787,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. */}
@@ -848,6 +844,21 @@ export default function App() {
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
// ADD to the picked set, don't replace it: channels OR together now, so arriving
// from a second channel page should widen the feed, not silently drop the first.
// Deduped — clicking it again for a channel already picked is a no-op.
setFilters({
...filters,
channelIds: [...new Set([...filters.channelIds, channelView.id])],
});
// setPage already leaves an open channel page and pushes a clean history entry.
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
// and restored whatever page the channel had been opened from.
setPage("feed");
}}
/>
</Suspense>
) : (
@@ -865,6 +876,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
@@ -893,7 +910,7 @@ export default function App() {
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => openChannel(id, name)}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setFilters({ ...filters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
export default function ActiveFilterChips({
filters,
onClearAll,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
}) {
const { t } = useTranslation();
if (filters.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5 pb-3" aria-label={t("feed.chips.label")}>
<span className="text-[11px] uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
<button
key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
>
<span className="max-w-[14rem] truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
))}
{filters.length > 1 && (
<button
onClick={onClearAll}
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
)}
</div>
);
}
+37 -21
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider";
@@ -56,12 +56,17 @@ export default function ChannelPage({
view,
onBack,
onOpenChannel,
onShowInFeed,
}: {
channelId: string;
initialName?: string;
me: Me;
view: "grid" | "list";
onBack: () => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
// follow would just land you on an empty page.
onShowInFeed: () => void;
onOpenChannel: (id: string, name?: string) => void;
}) {
const { t, i18n } = useTranslation();
@@ -172,8 +177,7 @@ export default function ChannelPage({
includeShorts: false,
includeLive: true,
show: "all",
channelId,
channelName: initialName,
channelIds: [channelId],
}));
const name = ch?.title ?? initialName ?? channelId;
@@ -279,7 +283,26 @@ export default function ChannelPage({
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pb-1">
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
<div className="ml-2 flex items-center gap-2 pb-1.5">
<a
href={ytUrl}
target="_blank"
@@ -290,6 +313,16 @@ export default function ChannelPage({
>
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<button
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
)}
{!me.is_demo && (
<button
onClick={() => block.mutate()}
@@ -329,23 +362,6 @@ export default function ChannelPage({
))}
</div>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
</div>
</div>
{/* Tab content */}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import { inputCls } from "./ui/form";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
// the panel had a remove button for something nothing could apply.
//
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
// fetch here means a subscription list of a few hundred loads when the picker is actually looked at.
export default function ChannelPicker({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade();
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
staleTime: 5 * 60_000,
});
const q = query.trim().toLowerCase();
const all = channelsQuery.data ?? [];
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
const picked = filters.channelIds;
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
const toggle = (id: string) =>
setFilters({
...filters,
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
});
return (
<>
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
{picked.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-2">
{picked.map((id) => (
<button
key={id}
onClick={() => toggle(id)}
title={t("sidebar.channelRemove", { name: nameOf(id) })}
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-sm hover:opacity-90 transition"
>
<span className="truncate">{nameOf(id)}</span>
<X className="w-3 h-3 shrink-0" />
</button>
))}
</div>
)}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sidebar.channelSearch")}
aria-label={t("sidebar.channelSearch")}
className={inputCls}
/>
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
but does drop the rows that explain it. */}
{!q && all.length > 0 && (
<div className="px-2 pt-1.5 text-[11px] text-muted">{t("sidebar.channelOrder")}</div>
)}
<div
ref={fade.ref}
style={fade.style}
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
>
{matches.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted">
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
</div>
) : (
matches.map((c) => {
const on = picked.includes(c.id);
return (
<button
key={c.id}
onClick={() => toggle(c.id)}
aria-pressed={on}
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
// them they squash to ~12px each and clip their own text instead of the box scrolling.
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
}`}
>
{c.title ?? c.id}
</button>
);
})
)}
</div>
</>
);
}
+22 -26
View File
@@ -6,6 +6,16 @@ import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import {
buildSort,
DIRECTIONLESS,
parseSort,
SORT_DEFAULT_DIR,
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -25,32 +35,6 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance";
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
title: { asc: "title", desc: "title_desc" },
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" },
};
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
};
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
}
return { key: "date", dir: "desc" };
}
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
}
function matchesView(status: string, show: string): boolean {
switch (show) {
@@ -102,6 +86,8 @@ export default function Feed({
channelScoped?: boolean;
}) {
const { t } = useTranslation();
// Same list the panel's badge counts — see lib/useActiveFilters.
const activeFilters = useActiveFilters(filters, setFilters);
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
@@ -643,6 +629,16 @@ export default function Feed({
return (
<div className="p-4">
{toolbar}
{/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for
them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog,
show-all — so every one of those would render as a "filter you applied" when it's just the
page being itself. */}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
+14 -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,13 @@ export default function Header({
canNext={multiModule}
/>
{search}
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
pills rather than pinned to the far right: on a wide window that just strands it. Only on
the two modules it says anything about: the feed those subscriptions fill, and the manager
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
{(page === "feed" || page === "channels") && (
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
)}
</div>
);
}
+11 -52
View File
@@ -30,10 +30,10 @@ import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
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
@@ -47,7 +47,6 @@ export default function NavSidebar({
language,
collapsed,
onToggleCollapse,
onGoToFullHistory,
}: {
me: Me;
page: Page;
@@ -59,40 +58,14 @@ export default function NavSidebar({
// it and asks App to flip it.
collapsed: boolean;
onToggleCollapse: () => void;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
// onFocus/onBlur (below) track focus through the REACT tree, so the rail's portaled menus
// (language switcher, account popover) count as "inside" and keep it open while you use them.
// The case they miss: a portal unmounts while focused, and browsers fire NO blur when a focused
// node is removed — focus silently falls to the body and `focused` would latch on forever.
// Safety net: whenever focus has landed nowhere, clear it. Deferred a tick so activeElement has
// settled, and rescheduled rather than queued — only the latest state matters.
useEffect(() => {
let timer: number | undefined;
const clearIfFocusLandedNowhere = () => {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) setFocused(false);
};
const soon = () => {
window.clearTimeout(timer);
timer = window.setTimeout(clearIfFocusLandedNowhere, 0);
};
document.addEventListener("focusout", soon);
document.addEventListener("click", soon);
return () => {
document.removeEventListener("focusout", soon);
document.removeEventListener("click", soon);
window.clearTimeout(timer);
};
}, []);
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
// `collapsed` (persisted per-account) now means "unpinned".
const peek = useHoverFocusWithin();
const pinned = !collapsed;
const expanded = pinned || hovered || focused;
const expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
@@ -299,16 +272,12 @@ export default function NavSidebar({
return (
// Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
// overlays content instead of pushing it. Pinned reserves the full width (in-flow).
<div
className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
>
<div className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}>
<nav
// Peek handlers sit on the CARD, not the slot: the slot is wider than the card by its
// margin, and a hover zone out there would fire from the dead space next to the rail —
// leaving the side panel to the left would pop the rail open with nothing under the cursor.
{...peek.handlers}
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3"
}`}
@@ -334,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"
@@ -86,8 +86,7 @@ export default function NotificationsPanel({
setFilters({
...filters,
show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
channelIds: meta.channelId ? [meta.channelId] : [],
});
setPage("feed");
}
+13 -3
View File
@@ -26,7 +26,17 @@ import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
// saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
// loses that filter.
const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
});
function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
@@ -99,7 +109,7 @@ export default function SavedViewsWidget({
async function share(v: SavedView) {
try {
await navigator.clipboard.writeText(shareUrl(v.filters));
await navigator.clipboard.writeText(shareUrl(asFilters(v.filters)));
notify({ level: "success", message: t("views.shareCopied") });
} catch {
notify({ level: "warning", message: t("sidebar.shareFailed") });
@@ -138,7 +148,7 @@ export default function SavedViewsWidget({
view={v}
active={keyOf(v.filters) === currentKey}
renaming={renameId === v.id}
onApply={() => onApply(v.filters)}
onApply={() => onApply(asFilters(v.filters))}
onStartRename={() => setRenameId(v.id)}
onRename={(name) => rename(v.id, name)}
onCancelRename={() => setRenameId(null)}
+94 -72
View File
@@ -1,13 +1,14 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../lib/useScrollFade";
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read
// as one system: a rounded glass card that floats beside the nav (aligned with the header top,
// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a
// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses
// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav).
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
// `collapsed` (persisted per-account) therefore means "unpinned".
export default function SidePanel({
title,
icon,
@@ -37,14 +38,31 @@ export default function SidePanel({
}) {
const { t } = useTranslation();
const fade = useScrollFade();
const peek = useHoverFocusWithin();
const pinned = !collapsed;
const expanded = pinned || peek.active;
if (collapsed) {
return (
return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
<div
{...peek.handlers}
className={`hidden md:block relative shrink-0 transition-[width] ${
pinned ? "w-[268px]" : "w-9"
}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
mounted while the card is open — swapping it out from under the pointer would drop the very
hover that opened the card, and unmounting it while focused would throw keyboard focus to
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<button
onClick={onToggleCollapse}
title={t("sidebar.expand")}
aria-label={t("sidebar.expand")}
className="hidden md:flex my-3 mr-3 w-11 shrink-0 rounded-2xl glass glass-hover flex-col items-center justify-center gap-3 text-fg"
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-[34] flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
>
<span className="text-accent">{icon}</span>
{count > 0 && (
@@ -59,69 +77,73 @@ export default function SidePanel({
{title}
</span>
</button>
);
}
return (
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
{/* Row 1 — identity only: collapse, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
{title}
</span>
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{count}
</span>
)}
{editing && onReset && (
<button
onClick={onReset}
title={t("sidebar.resetDefaults")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
{onToggleEditing && (
<button
onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
)}
</div>
{expanded && (
<aside className="glass absolute top-3 bottom-3 left-0 z-[35] w-64 rounded-2xl flex flex-col overflow-hidden">
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<button
onClick={onToggleCollapse}
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
{title}
</span>
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{count}
</span>
)}
{editing && onReset && (
<button
onClick={onReset}
title={t("sidebar.resetDefaults")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
{onToggleEditing && (
<button
onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
)}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
{!editing && actions && (
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
{actions}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
{!editing && actions && (
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
{actions}
</div>
)}
<div
ref={fade.ref}
style={fade.style}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
>
<div className="p-3 space-y-3">
{editing && editHint && <div className="text-[11px] text-muted">{editHint}</div>}
{children}
</div>
</div>
</aside>
)}
<div ref={fade.ref} style={fade.style} className="flex-1 min-h-0 overflow-y-auto no-scrollbar">
<div className="p-3 space-y-3">
{editing && editHint && <div className="text-[11px] text-muted">{editHint}</div>}
{children}
</div>
</div>
</aside>
</div>
);
}
+10 -64
View File
@@ -1,16 +1,18 @@
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
@@ -21,18 +23,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 365, key: "1year" },
];
// Filter values owned by the sidebar (everything except the header search `q` and the
// `scope` mode, both preserved across a "Clear all").
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
function TagChip({
tag,
count,
@@ -112,22 +102,6 @@ export default function Sidebar({
);
};
const needChannelName = !!filters.channelId && !filters.channelName;
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
enabled: needChannelName,
staleTime: 5 * 60_000,
});
const resolvedChannelName =
filters.channelName ??
channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ??
undefined;
const channelChipLabel =
resolvedChannelName ??
(needChannelName && channelsQuery.isLoading
? t("sidebar.loading")
: t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
@@ -143,21 +117,9 @@ export default function Sidebar({
});
}
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
const contentChanged =
!filters.includeNormal || filters.includeShorts || filters.includeLive;
const scopeActive = filters.scope === "all";
const sourceActive =
filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic";
const activeCount =
filters.tags.length +
(filters.show !== "unwatched" ? 1 : 0) +
(filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) +
(contentChanged ? 1 : 0) +
(filters.channelId ? 1 : 0) +
(dateActive ? 1 : 0) +
(scopeActive ? 1 : 0) +
(sourceActive ? 1 : 0);
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length;
const active = activeCount > 0;
async function shareView() {
@@ -171,12 +133,13 @@ export default function Sidebar({
function clearAll() {
setCustomDates(false);
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" });
setFilters(clearedFilters(filters));
}
const available: Record<string, boolean> = {
savedviews: !isDemo,
date: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
tags: userTags.length > 0,
@@ -186,6 +149,8 @@ export default function Sidebar({
switch (id) {
case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
return (
<>
@@ -436,25 +401,6 @@ export default function Sidebar({
))}
</div>
{filters.channelId && !editing && (
<div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
{t("sidebar.channel")}
</div>
<div className="px-3 pb-3 pt-2">
<button
onClick={() =>
setFilters({ ...filters, channelId: undefined, channelName: undefined })
}
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
>
<span className="truncate">{channelChipLabel}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</div>
</div>
)}
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
{tagManagerOpen && (
+86 -136
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,83 @@ 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;
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos
)} ${t("header.sync.total")}`;
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"
<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);
}}
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
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
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<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>
)}
<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>
);
}
+2 -1
View File
@@ -23,5 +23,6 @@
"country": "Country",
"language": "Language",
"topics": "Topics",
"keywords": "Keywords"
"keywords": "Keywords",
"showInFeed": "Show in my feed"
}
+7
View File
@@ -1,4 +1,11 @@
{
"chips": {
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"maxAge_one": "Last {{count}} day",
"maxAge_other": "Last {{count}} days"
},
"loading": "Loading feed…",
"loadError": "Couldn't load the feed.",
"emptyTitle": "Your feed is empty",
+4 -4
View File
@@ -39,7 +39,7 @@
"sync": {
"yours": "yours",
"total": "total",
"countTooltip": "Videos from your subscriptions, against the total in the shared catalog that every user's subscriptions have pulled in.",
"countTooltip": "Your subscriptions vs. the whole shared catalog.",
"paused": "paused",
"syncing": "{{count}} syncing",
"backfillingHistory": "fetching history",
@@ -47,9 +47,9 @@
"recentQueued": "{{count}} queued",
"allSynced": "all synced",
"withoutFullHistory": "{{count}} without full history",
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",
"pause": "Pause background sync",
"resume": "Resume background sync"
"fullHistoryTooltip": "Recent uploads only — click to fetch the rest.",
"pause": "Pause sync",
"resume": "Resume sync"
},
"banner": {
"updated": "Updated to v{{version}} — see what's changed.",
+9 -5
View File
@@ -8,7 +8,6 @@
"editHint": "Drag to reorder · eye to show/hide",
"channel": "Channel",
"loading": "Loading…",
"thisChannel": "This channel",
"channelCount": "{{count}} channel",
"channelCount_other": "{{count}} channels",
"dragToReorder": "Drag to reorder",
@@ -16,8 +15,8 @@
"hideWidget": "Hide widget",
"expand": "Expand",
"collapse": "Collapse",
"collapsePanel": "Collapse filters",
"expandPanel": "Expand filters",
"pin": "Pin panel open",
"unpin": "Unpin panel",
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",
@@ -40,7 +39,8 @@
"content": "Content type",
"language": "Language",
"topic": "Topic",
"tags": "Your tags"
"tags": "Your tags",
"channel": "Channel"
},
"show": {
"unwatched": "Unwatched",
@@ -72,5 +72,9 @@
"1month": "1 month",
"6months": "6 months",
"1year": "1 year"
}
},
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
"channelOrder": "Priority first, then AZ",
"channelRemove": "Remove channel: {{name}}"
}
+2 -1
View File
@@ -23,5 +23,6 @@
"country": "Ország",
"language": "Nyelv",
"topics": "Témák",
"keywords": "Kulcsszavak"
"keywords": "Kulcsszavak",
"showInFeed": "Mutasd a hírfolyamomban"
}
+7
View File
@@ -1,4 +1,11 @@
{
"chips": {
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"maxAge_one": "Utolsó {{count}} nap",
"maxAge_other": "Utolsó {{count}} nap"
},
"loading": "Hírfolyam betöltése…",
"loadError": "Nem sikerült betölteni a hírfolyamot.",
"emptyTitle": "A hírfolyamod üres",
+4 -4
View File
@@ -39,7 +39,7 @@
"sync": {
"yours": "tiéd",
"total": "összes",
"countTooltip": "A feliratkozásaidból származó videók száma a megosztott katalógus teljes számához képest, amelyet az összes felhasználó feliratkozásai gyűjtöttek össze.",
"countTooltip": "A feliratkozásaid a teljes megosztott katalógushoz képest.",
"paused": "szüneteltetve",
"syncing": "{{count}} szinkronizálás alatt",
"backfillingHistory": "előzmény letöltése",
@@ -47,9 +47,9 @@
"recentQueued": "{{count}} sorban",
"allSynced": "minden szinkronban",
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",
"pause": "Háttér-szinkronizálás szüneteltetése",
"resume": "Háttér-szinkronizálás folytatása"
"fullHistoryTooltip": "Csak a legutóbbi feltöltések — kattints a többiért.",
"pause": "Szinkron szüneteltetése",
"resume": "Szinkron folytatása"
},
"banner": {
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
+9 -5
View File
@@ -8,7 +8,6 @@
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
"channel": "Csatorna",
"loading": "Betöltés…",
"thisChannel": "Ez a csatorna",
"channelCount": "{{count}} csatorna",
"channelCount_other": "{{count}} csatorna",
"dragToReorder": "Húzd az átrendezéshez",
@@ -16,8 +15,8 @@
"hideWidget": "Modul elrejtése",
"expand": "Kibontás",
"collapse": "Összecsukás",
"collapsePanel": "Szűrők összecsukása",
"expandPanel": "Szűrők kibontása",
"pin": "Panel rögzítése nyitva",
"unpin": "Panel rögzítésének feloldása",
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
@@ -40,7 +39,8 @@
"content": "Tartalomtípus",
"language": "Nyelv",
"topic": "Téma",
"tags": "Saját címkék"
"tags": "Saját címkék",
"channel": "Csatorna"
},
"show": {
"unwatched": "Nem nézett",
@@ -72,5 +72,9 @@
"1month": "1 hónap",
"6months": "6 hónap",
"1year": "1 év"
}
},
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
"channelOrder": "Prioritás szerint, majd AZ",
"channelRemove": "Csatorna eltávolítása: {{name}}"
}
+3 -3
View File
@@ -184,8 +184,8 @@ export interface FeedFilters {
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search" | "plex";
channelId?: string;
channelName?: string;
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
channelIds: string[];
maxAgeDays?: number;
minDuration?: number;
maxDuration?: number;
@@ -432,7 +432,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
p.set("include_shorts", String(f.includeShorts));
p.set("include_live", String(f.includeLive));
p.set("show", f.show);
if (f.channelId) p.set("channel_id", f.channelId);
f.channelIds.forEach((id) => p.append("channel_ids", id));
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
+61
View File
@@ -0,0 +1,61 @@
// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc",
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
// chips can label a sort without re-deriving the mapping.
export type SortKey =
| "date"
| "popular"
| "duration"
| "title"
| "subscribers"
| "priority"
| "shuffle"
| "relevance";
export const SORT_KEYS: SortKey[] = [
"date",
"popular",
"duration",
"title",
"subscribers",
"priority",
"shuffle",
];
/** Sorts with no meaningful asc/desc flip. */
export const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
title: { asc: "title", desc: "title_desc" },
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" },
};
export const SORT_DEFAULT_DIR: Record<
Exclude<SortKey, "shuffle" | "relevance">,
"asc" | "desc"
> = {
date: "desc",
popular: "desc",
duration: "desc",
title: "asc",
subscribers: "desc",
priority: "desc",
};
export function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
}
return { key: "date", dir: "desc" };
}
export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["savedviews", "date", "tags", "language", "topic"],
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
plex: [
"scope",
"playlists",
+12
View File
@@ -14,6 +14,18 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.44.0",
date: "2026-07-16",
summary:
"The filter panels get out of the way, and you can finally see — and pick — what's narrowing your feed.",
features: [
"The Filters, Plex and Playlists panels now sit as a small tab beside the sidebar and slide open over the page when you point at them, so they cost you nothing until you want them. Prefer one always open? Pin it. The feed starts a lot further left than it used to.",
"Everything currently filtering your feed now shows as a row of chips above it — drop any one with a click, or clear the lot. No more opening the panel just to see what you'd set.",
"Filter the feed by channel — at last. Pick channels in the panel's new Channel section (searchable), or hit \"Show in my feed\" on any channel's page. Pick several and you'll get all of them, with your other filters still applied.",
"Your sync status moved out of the sidebar into a small chip at the top, next to the search: the video count at a glance, and the details, the missing-history link and pause a click away.",
],
},
{
version: "0.43.1",
date: "2026-07-15",
+2 -2
View File
@@ -37,7 +37,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
if (f.channelId) p.set("channel", f.channelId);
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
if (f.dateTo) p.set("to", f.dateTo);
@@ -72,7 +72,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
f.channelId = params.get("channel") || undefined;
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
f.dateTo = params.get("to") || undefined;
+145
View File
@@ -0,0 +1,145 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
export interface ActiveFilter {
key: string;
label: string;
remove: () => void;
}
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
channelIds: [],
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
/**
* The reset behind every "Clear all" (the panel's and the chip row's), so they can't drift apart.
* The search term survives — clearing filters isn't the same as abandoning the search — and the
* view returns to your own subscriptions.
*/
export function clearedFilters(current: FeedFilters): FeedFilters {
return { ...DEFAULT_SIDEBAR_FILTERS, q: current.q, scope: "my" };
}
/**
* The single source of truth for "what is currently narrowing the feed" — it backs BOTH the side
* panel's active-count badge and the removable chip row above the feed, so the two can never
* disagree. Tags/channels come from the shared react-query caches, so callers need not thread them.
*
* A dimension counts as active when it differs from the feed's default: show=unwatched,
* sort=newest (relevance is search's implicit default, so it doesn't count either), normal-only
* content, scope=my, source=organic, and no tags/channel/date.
*/
export function useActiveFilters(
filters: FeedFilters,
setFilters: (f: FeedFilters) => void
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({
queryKey: ["channels"],
queryFn: api.channels,
enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000,
});
const out: ActiveFilter[] = [];
// Tags: one chip each — removing one shouldn't drop the rest.
for (const id of filters.tags) {
const tag = (tagsQuery.data ?? []).find((x) => x.id === id);
out.push({
key: `tag:${id}`,
label: tag?.name ?? String(id),
remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }),
});
}
if (filters.show !== "unwatched") {
out.push({
key: "show",
label: t("sidebar.show." + filters.show),
remove: () => setFilters({ ...filters, show: "unwatched" }),
});
}
if (filters.sort !== "newest" && filters.sort !== "relevance") {
out.push({
key: "sort",
label: t("feed.sortKey." + parseSort(filters.sort).key),
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }),
});
}
if (!filters.includeNormal || filters.includeShorts || filters.includeLive) {
const on = [
filters.includeNormal && t("sidebar.content.normal"),
filters.includeShorts && t("sidebar.content.shorts"),
filters.includeLive && t("sidebar.content.live"),
].filter(Boolean);
out.push({
key: "content",
// Nothing selected is a legitimate (empty-feed) state — say so rather than render a bare label.
label: on.length ? on.join(" + ") : t("feed.chips.contentNone"),
remove: () =>
setFilters({ ...filters, includeNormal: true, includeShorts: false, includeLive: false }),
});
}
// One chip per channel — they OR together, so dropping one shouldn't drop the rest (same shape
// as tags).
for (const id of filters.channelIds) {
out.push({
key: `channel:${id}`,
// Fall back to the id, not a generic label: several chips reading "This channel" while
// the name cache loads would be indistinguishable, and each ✕ drops a different one.
label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
remove: () =>
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
});
}
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
const label = filters.maxAgeDays
? t("feed.chips.maxAge", { count: filters.maxAgeDays })
: [filters.dateFrom, filters.dateTo].filter(Boolean).join(" ");
out.push({
key: "date",
label,
remove: () =>
setFilters({ ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined }),
});
}
if (filters.scope === "all") {
out.push({
key: "scope",
label: t("header.scope.all"),
// Source is a library-only concept, so it goes back to the default alongside the scope —
// otherwise it would linger as a chip with nothing to apply to.
remove: () => setFilters({ ...filters, scope: "my", librarySource: "organic" }),
});
}
if (filters.scope === "all" && filters.librarySource && filters.librarySource !== "organic") {
// The wire values and the label keys don't line up 1:1 ("search" reads as "only").
const LABEL = { all: "all", search: "only", plex: "plex" } as const;
out.push({
key: "source",
label: t("feed.source." + LABEL[filters.librarySource]),
remove: () => setFilters({ ...filters, librarySource: "organic" }),
});
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState, type FocusEvent } from "react";
/**
* "Peek open on hover or keyboard focus" for a container — the shared brain behind the nav rail
* and the floating side panels, which sit slim until you point at them or tab into them.
*
* Spread `handlers` on the container; `active` is true while the pointer is over it (or any
* descendant) or focus is inside it.
*
* Focus is tracked through the REACT tree, so a portaled menu rendered by a child (the language
* switcher, the account popover) still counts as "inside" and holds the container open while you
* use it. The one case that misses: such a portal unmounts while focused, and browsers fire NO blur
* when a focused node is removed — focus silently falls to the body and `active` would latch on
* forever. Hence the document-level safety net: when focus has landed nowhere, clear it.
*/
export function useHoverFocusWithin(): {
active: boolean;
handlers: {
onMouseEnter: () => void;
onMouseLeave: () => void;
onFocus: () => void;
onBlur: (e: FocusEvent) => void;
};
} {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
useEffect(() => {
let timer: number | undefined;
const clearIfFocusLandedNowhere = () => {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) setFocused(false);
};
// Deferred a tick so activeElement has settled, and rescheduled rather than queued — only the
// latest state matters.
const soon = () => {
window.clearTimeout(timer);
timer = window.setTimeout(clearIfFocusLandedNowhere, 0);
};
document.addEventListener("focusout", soon);
document.addEventListener("click", soon);
return () => {
document.removeEventListener("focusout", soon);
document.removeEventListener("click", soon);
window.clearTimeout(timer);
};
}, []);
return {
active: hovered || focused,
handlers: {
onMouseEnter: () => setHovered(true),
onMouseLeave: () => setHovered(false),
onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
},
},
};
}