Merge feature/e2-panel-overlay: E2 side-panel overlay, active-filter chips, header sync chip, reachable + multi channel filter
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
"country": "Country",
|
||||
"language": "Language",
|
||||
"topics": "Topics",
|
||||
"keywords": "Keywords"
|
||||
"keywords": "Keywords",
|
||||
"showInFeed": "Show in my feed"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
"country": "Ország",
|
||||
"language": "Nyelv",
|
||||
"topics": "Témák",
|
||||
"keywords": "Kulcsszavak"
|
||||
"keywords": "Kulcsszavak",
|
||||
"showInFeed": "Mutasd a hírfolyamomban"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user