diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 54f6b49f..e5d29525 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95ccd926..a0828f20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(readAccount(LS.navCollapsed, true)) ); + // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. const [filterCollapsed, setFilterCollapsedState] = useState(() => - 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(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"); + }} /> ) : ( @@ -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 }) }); }} diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx new file mode 100644 index 00000000..6709e687 --- /dev/null +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -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 ( +
+ + {t("feed.chips.label")} + + {filters.map((f) => ( + + ))} + {filters.length > 1 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 703c2870..18785948 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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({ ))} -
+
+ + {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* 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. */} +
+ + +
+ {ch?.subscribed && ( + + )} {!me.is_demo && (
- - {exploring && ( -
- - {t("channel.loadingVideos")} -
- )} - - {/* Tabs */} -
- - -
{/* Tab content */} diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx new file mode 100644 index 00000000..38bdb1a7 --- /dev/null +++ b/frontend/src/components/ChannelPicker.tsx @@ -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 && ( +
+ {picked.map((id) => ( + + ))} +
+ )} + 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 && ( +
{t("sidebar.channelOrder")}
+ )} +
+ {matches.length === 0 ? ( +
+ {channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")} +
+ ) : ( + matches.map((c) => { + const on = picked.includes(c.id); + return ( + + ); + }) + )} +
+ + ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a2a76e53..4b1ce9a8 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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, { 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, "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 (
{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 && ( + setFilters(clearedFilters(filters))} + /> + )} {items.length === 0 ? (

{t("feed.noMatches")}

diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index cf498256..48f13c28 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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") && ( + + )}
); } diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index edd30a23..476e2274 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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(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). -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - onFocus={() => setFocused(true)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); - }} - > +
- {/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */} -
- -
-
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)} diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx index 5ca05934..783db256 100644 --- a/frontend/src/components/SidePanel.tsx +++ b/frontend/src/components/SidePanel.tsx @@ -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. +
+ {/* 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. */} - ); - } - return ( - +
); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3b0ee85f..a243997d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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.") 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 = { - 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 = { 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 ; + case "channel": + return ; case "date": return ( <> @@ -436,25 +401,6 @@ export default function Sidebar({ ))}
- {filters.channelId && !editing && ( -
-
- {t("sidebar.channel")} -
-
- -
-
- )} - {tagManagerOpen && ( diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index c3d7bcf6..c9a846fb 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -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(null); + const popRef = useRef(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 ? ( {t("header.sync.paused")} ) : active ? ( @@ -93,136 +96,83 @@ export default function SyncStatus({ ); - // 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 ( - -
- {active ? ( - - ) : ( - - )} - {(data.paused || notFull > 0) && ( - - )} -
-
- ); - } - return ( -
- - - - - {formatViews(data.my_videos)}{" "} - {t("header.sync.yours")} - / - {formatViews(data.total_videos)} {t("header.sync.total")} - - - - {/* 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 && ( -
- {stateNode} - {pauseBtn} -
- )} - {notFull > 0 && ( -
- - - - {!showMain && pauseBtn} -
- )} -
- ); - } - + // 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 ( -
- - - - - {formatViews(data.my_videos)}{" "} - {t("header.sync.yours")} - - / - - {formatViews(data.total_videos)} {t("header.sync.total")} - - - - {showMain && ( - <> - · - {data.paused ? ( - {t("header.sync.paused")} - ) : active ? ( - // A sync job is running right now — spin and name what it's doing. - - - {syncing > 0 - ? t("header.sync.syncing", { count: syncing }) - : notFull > 0 - ? t("header.sync.backfillingHistory") - : t("header.sync.working")} - - ) : syncing > 0 ? ( - // Recent uploads queued for the next run, but nothing running now — static. - - - {t("header.sync.recentQueued", { count: syncing })} - - ) : ( - {t("header.sync.allSynced")} - )} - - )} - {notFull > 0 && ( - <> - · - - + + {open && + createPortal( +
+
- - {t("header.sync.withoutFullHistory", { count: notFull })} - - - - )} - {/* 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) && ( - - )} + + + {formatViews(data.my_videos)}{" "} + {t("header.sync.yours")} + / + {formatViews(data.total_videos)} {t("header.sync.total")} + +
+ {showMain && ( +
+ {stateNode} + {pauseBtn} +
+ )} + {notFull > 0 && ( +
+ + {!showMain && pauseBtn} +
+ )} +
, + document.body + )}
); } diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json index 612ebd3f..d97ed328 100644 --- a/frontend/src/i18n/locales/en/channel.json +++ b/frontend/src/i18n/locales/en/channel.json @@ -23,5 +23,6 @@ "country": "Country", "language": "Language", "topics": "Topics", - "keywords": "Keywords" + "keywords": "Keywords", + "showInFeed": "Show in my feed" } diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 6901be16..26a99707 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -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", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 94bc154a..4bdb2ec8 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -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.", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 7f169421..ffcc83d9 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -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 A–Z", + "channelRemove": "Remove channel: {{name}}" } diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json index ee0715b7..af98aaea 100644 --- a/frontend/src/i18n/locales/hu/channel.json +++ b/frontend/src/i18n/locales/hu/channel.json @@ -23,5 +23,6 @@ "country": "Ország", "language": "Nyelv", "topics": "Témák", - "keywords": "Kulcsszavak" + "keywords": "Kulcsszavak", + "showInFeed": "Mutasd a hírfolyamomban" } diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 396fedda..bba45c7b 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 6aea6c4a..50b9aac1 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -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.", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 2ab5126a..0049936a 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -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 A–Z", + "channelRemove": "Csatorna eltávolítása: {{name}}" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b329329c..7b225357 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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); diff --git a/frontend/src/lib/feedSort.ts b/frontend/src/lib/feedSort.ts new file mode 100644 index 00000000..edebb9ee --- /dev/null +++ b/frontend/src/lib/feedSort.ts @@ -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, { 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, + "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]; +} diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts index cc8b1e0e..a8aad5c9 100644 --- a/frontend/src/lib/panelLayout.ts +++ b/frontend/src/lib/panelLayout.ts @@ -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 = { - feed: ["savedviews", "date", "tags", "language", "topic"], + feed: ["savedviews", "date", "channel", "tags", "language", "topic"], plex: [ "scope", "playlists", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index c9100da1..4af6dc15 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -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; diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts new file mode 100644 index 00000000..8964c9b9 --- /dev/null +++ b/frontend/src/lib/useActiveFilters.ts @@ -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 = { + 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; +} diff --git a/frontend/src/lib/useHoverFocusWithin.ts b/frontend/src/lib/useHoverFocusWithin.ts new file mode 100644 index 00000000..308b7d0f --- /dev/null +++ b/frontend/src/lib/useHoverFocusWithin.ts @@ -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); + }, + }, + }; +}