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],
|
tags: list[int],
|
||||||
tag_mode: str,
|
tag_mode: str,
|
||||||
channel_id: str | None,
|
channel_ids: list[str],
|
||||||
q: str | None,
|
q: str | None,
|
||||||
min_duration: int | None,
|
min_duration: int | None,
|
||||||
max_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
|
# 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
|
# 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
|
# 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 = (
|
accessible_explore = (
|
||||||
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||||
).union(
|
).union(
|
||||||
@@ -227,8 +227,9 @@ def _filtered_query(
|
|||||||
else: # "organic" (default): subscriptions only
|
else: # "organic" (default): subscriptions only
|
||||||
query = query.where(subscribed)
|
query = query.where(subscribed)
|
||||||
|
|
||||||
if channel_id:
|
if channel_ids:
|
||||||
query = query.where(Video.channel_id == channel_id)
|
# 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:
|
if min_duration is not None:
|
||||||
query = query.where(Video.duration_seconds >= min_duration)
|
query = query.where(Video.duration_seconds >= min_duration)
|
||||||
if max_duration is not None:
|
if max_duration is not None:
|
||||||
@@ -354,7 +355,7 @@ def _to_tsquery_str(q: str) -> str | None:
|
|||||||
def _feed_params(
|
def _feed_params(
|
||||||
tags: list[int] = Query(default=[]),
|
tags: list[int] = Query(default=[]),
|
||||||
tag_mode: str = "or",
|
tag_mode: str = "or",
|
||||||
channel_id: str | None = None,
|
channel_ids: list[str] = Query(default=[]),
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
min_duration: int | None = None,
|
min_duration: int | None = None,
|
||||||
max_duration: int | None = None,
|
max_duration: int | None = None,
|
||||||
@@ -371,7 +372,7 @@ def _feed_params(
|
|||||||
return {
|
return {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"tag_mode": tag_mode,
|
"tag_mode": tag_mode,
|
||||||
"channel_id": channel_id,
|
"channel_ids": channel_ids,
|
||||||
"q": q,
|
"q": q,
|
||||||
"min_duration": min_duration,
|
"min_duration": min_duration,
|
||||||
"max_duration": max_duration,
|
"max_duration": max_duration,
|
||||||
|
|||||||
+26
-9
@@ -89,6 +89,7 @@ const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
|||||||
const DEFAULT_FILTERS: FeedFilters = {
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagMode: "or",
|
||||||
|
channelIds: [],
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
scope: "my",
|
scope: "my",
|
||||||
@@ -373,12 +374,13 @@ export default function App() {
|
|||||||
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readAccount(LS.navCollapsed, true))
|
Boolean(readAccount(LS.navCollapsed, true))
|
||||||
);
|
);
|
||||||
|
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
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).
|
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readAccount(LS.playlistsCollapsed, false))
|
Boolean(readAccount(LS.playlistsCollapsed, true))
|
||||||
);
|
);
|
||||||
function setNavCollapsed(next: boolean) {
|
function setNavCollapsed(next: boolean) {
|
||||||
setNavCollapsedState(next);
|
setNavCollapsedState(next);
|
||||||
@@ -785,12 +787,6 @@ export default function App() {
|
|||||||
language={i18n.language as LangCode}
|
language={i18n.language as LangCode}
|
||||||
collapsed={navCollapsed}
|
collapsed={navCollapsed}
|
||||||
onToggleCollapse={() => setNavCollapsed(!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
|
{/* 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. */}
|
content column. Sits beside the nav rail as its own collapsible column. */}
|
||||||
@@ -848,6 +844,21 @@ export default function App() {
|
|||||||
view={view}
|
view={view}
|
||||||
onBack={closeChannel}
|
onBack={closeChannel}
|
||||||
onOpenChannel={openChannel}
|
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>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
@@ -865,6 +876,12 @@ export default function App() {
|
|||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onYtSearch={enterYtSearch}
|
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
|
{/* 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
|
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)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
onViewChannel={(id, name) => openChannel(id, name)}
|
onViewChannel={(id, name) => openChannel(id, name)}
|
||||||
onFilterByTag={(tagId, name) => {
|
onFilterByTag={(tagId, name) => {
|
||||||
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
setFilters({ ...filters, tags: [tagId], channelIds: [] });
|
||||||
setPage("feed");
|
setPage("feed");
|
||||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
@@ -56,12 +56,17 @@ export default function ChannelPage({
|
|||||||
view,
|
view,
|
||||||
onBack,
|
onBack,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
|
onShowInFeed,
|
||||||
}: {
|
}: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
me: Me;
|
me: Me;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
onBack: () => void;
|
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;
|
onOpenChannel: (id: string, name?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -172,8 +177,7 @@ export default function ChannelPage({
|
|||||||
includeShorts: false,
|
includeShorts: false,
|
||||||
includeLive: true,
|
includeLive: true,
|
||||||
show: "all",
|
show: "all",
|
||||||
channelId,
|
channelIds: [channelId],
|
||||||
channelName: initialName,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const name = ch?.title ?? initialName ?? channelId;
|
const name = ch?.title ?? initialName ?? channelId;
|
||||||
@@ -279,7 +283,26 @@ export default function ChannelPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<a
|
||||||
href={ytUrl}
|
href={ytUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -290,6 +313,16 @@ export default function ChannelPage({
|
|||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</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 && (
|
{!me.is_demo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => block.mutate()}
|
onClick={() => block.mutate()}
|
||||||
@@ -329,23 +362,6 @@ export default function ChannelPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* 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 i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
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";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
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
|
// 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.
|
// (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.
|
// "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 {
|
function matchesView(status: string, show: string): boolean {
|
||||||
switch (show) {
|
switch (show) {
|
||||||
@@ -102,6 +86,8 @@ export default function Feed({
|
|||||||
channelScoped?: boolean;
|
channelScoped?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||||
|
const activeFilters = useActiveFilters(filters, setFilters);
|
||||||
const sortKeys = channelScoped
|
const sortKeys = channelScoped
|
||||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||||
: SORT_KEYS;
|
: SORT_KEYS;
|
||||||
@@ -643,6 +629,16 @@ export default function Feed({
|
|||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{toolbar}
|
{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 ? (
|
{items.length === 0 ? (
|
||||||
<div className="py-16 text-center text-muted">
|
<div className="py-16 text-center text-muted">
|
||||||
<p>{t("feed.noMatches")}</p>
|
<p>{t("feed.noMatches")}</p>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import type { Page } from "../lib/urlState";
|
|||||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
import SyncStatus from "./SyncStatus";
|
||||||
|
|
||||||
// The floating top header: a fixed-position row of glass pills over the content — a ModuleName
|
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||||
// pill (label + accent dot + back/forward arrows) and, where a module searches, a SearchBar pill.
|
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||||
// The row's left edge is a constant (as if the nav rail AND filter sidebar were both open), so it
|
// sync chip at the right end.
|
||||||
// never shifts when either collapses or when paging between modules; the right edge leaves ~10%.
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
filters,
|
||||||
@@ -24,6 +24,7 @@ export default function Header({
|
|||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
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,
|
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||||
// which can't spend the shared quota).
|
// which can't spend the shared quota).
|
||||||
onYtSearch: (q: string) => void;
|
onYtSearch: (q: string) => void;
|
||||||
|
// Jump to the channel manager filtered to channels still missing full history.
|
||||||
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||||
@@ -116,6 +119,13 @@ export default function Header({
|
|||||||
canNext={multiModule}
|
canNext={multiModule}
|
||||||
/>
|
/>
|
||||||
{search}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ import { getUnreadCount, subscribe } from "../lib/notifications";
|
|||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
import AvatarImg from "./Avatar";
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
import SyncStatus from "./SyncStatus";
|
|
||||||
|
|
||||||
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
|
// 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
|
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
|
||||||
@@ -47,7 +47,6 @@ export default function NavSidebar({
|
|||||||
language,
|
language,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onGoToFullHistory,
|
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
page: Page;
|
page: Page;
|
||||||
@@ -59,40 +58,14 @@ export default function NavSidebar({
|
|||||||
// it and asks App to flip it.
|
// it and asks App to flip it.
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onGoToFullHistory: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
|
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
|
||||||
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
|
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
||||||
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
|
// `collapsed` (persisted per-account) now means "unpinned".
|
||||||
const [hovered, setHovered] = useState(false);
|
const peek = useHoverFocusWithin();
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const pinned = !collapsed;
|
const pinned = !collapsed;
|
||||||
const expanded = pinned || hovered || focused;
|
const expanded = pinned || peek.active;
|
||||||
const slim = !expanded; // visual: icon-only rail
|
const slim = !expanded; // visual: icon-only rail
|
||||||
const [acctOpen, setAcctOpen] = useState(false);
|
const [acctOpen, setAcctOpen] = useState(false);
|
||||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
@@ -299,16 +272,12 @@ export default function NavSidebar({
|
|||||||
return (
|
return (
|
||||||
// Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
|
// 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).
|
// overlays content instead of pushing it. Pinned reserves the full width (in-flow).
|
||||||
<div
|
<div className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}>
|
||||||
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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<nav
|
<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] ${
|
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"
|
slim ? "w-[56px] px-2" : "w-52 px-3"
|
||||||
}`}
|
}`}
|
||||||
@@ -334,16 +303,6 @@ export default function NavSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
ref={listFade.ref}
|
ref={listFade.ref}
|
||||||
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
|
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({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
||||||
channelId: meta.channelId || undefined,
|
channelIds: meta.channelId ? [meta.channelId] : [],
|
||||||
channelName: meta.channelName || undefined,
|
|
||||||
});
|
});
|
||||||
setPage("feed");
|
setPage("feed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,17 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
// Compact, order-independent signature of a filter set (reuses the share serializer, which
|
// 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
|
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
|
||||||
// saved view that matches the feed's current filters.
|
// 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 {
|
function syncDefaultMirror(views: SavedView[]): void {
|
||||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||||
@@ -99,7 +109,7 @@ export default function SavedViewsWidget({
|
|||||||
|
|
||||||
async function share(v: SavedView) {
|
async function share(v: SavedView) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl(v.filters));
|
await navigator.clipboard.writeText(shareUrl(asFilters(v.filters)));
|
||||||
notify({ level: "success", message: t("views.shareCopied") });
|
notify({ level: "success", message: t("views.shareCopied") });
|
||||||
} catch {
|
} catch {
|
||||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||||
@@ -138,7 +148,7 @@ export default function SavedViewsWidget({
|
|||||||
view={v}
|
view={v}
|
||||||
active={keyOf(v.filters) === currentKey}
|
active={keyOf(v.filters) === currentKey}
|
||||||
renaming={renameId === v.id}
|
renaming={renameId === v.id}
|
||||||
onApply={() => onApply(v.filters)}
|
onApply={() => onApply(asFilters(v.filters))}
|
||||||
onStartRename={() => setRenameId(v.id)}
|
onStartRename={() => setRenameId(v.id)}
|
||||||
onRename={(name) => rename(v.id, name)}
|
onRename={(name) => rename(v.id, name)}
|
||||||
onCancelRename={() => setRenameId(null)}
|
onCancelRename={() => setRenameId(null)}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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
|
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||||
// as one system: a rounded glass card that floats beside the nav (aligned with the header top,
|
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||||
// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a
|
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
|
||||||
// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses
|
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
|
||||||
// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav).
|
// `collapsed` (persisted per-account) therefore means "unpinned".
|
||||||
export default function SidePanel({
|
export default function SidePanel({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
@@ -37,14 +38,31 @@ export default function SidePanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fade = useScrollFade();
|
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
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={t("sidebar.expand")}
|
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
aria-label={t("sidebar.expand")}
|
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 ${
|
||||||
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"
|
expanded ? "opacity-0" : ""
|
||||||
|
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-accent">{icon}</span>
|
<span className="text-accent">{icon}</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
@@ -59,69 +77,73 @@ export default function SidePanel({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{expanded && (
|
||||||
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
|
<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: collapse, icon, title (flexes + truncates last), count, customize.
|
{/* 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. */}
|
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">
|
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={t("sidebar.collapsePanel")}
|
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
aria-label={t("sidebar.collapsePanel")}
|
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"
|
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
<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]">
|
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span
|
<span
|
||||||
title={t("sidebar.activeCount", { count })}
|
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"
|
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}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editing && onReset && (
|
{editing && onReset && (
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
title={t("sidebar.resetDefaults")}
|
title={t("sidebar.resetDefaults")}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onToggleEditing && (
|
{onToggleEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleEditing}
|
onClick={onToggleEditing}
|
||||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
||||||
{!editing && actions && (
|
{!editing && actions && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</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>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
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 { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { shareUrl } from "../lib/urlState";
|
import { shareUrl } from "../lib/urlState";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
import ChannelPicker from "./ChannelPicker";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
@@ -21,18 +23,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
|||||||
{ days: 365, key: "1year" },
|
{ 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({
|
function TagChip({
|
||||||
tag,
|
tag,
|
||||||
count,
|
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 languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
@@ -143,21 +117,9 @@ export default function Sidebar({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
|
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||||
const contentChanged =
|
// disagree about what's applied.
|
||||||
!filters.includeNormal || filters.includeShorts || filters.includeLive;
|
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||||
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);
|
|
||||||
const active = activeCount > 0;
|
const active = activeCount > 0;
|
||||||
|
|
||||||
async function shareView() {
|
async function shareView() {
|
||||||
@@ -171,12 +133,13 @@ export default function Sidebar({
|
|||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" });
|
setFilters(clearedFilters(filters));
|
||||||
}
|
}
|
||||||
|
|
||||||
const available: Record<string, boolean> = {
|
const available: Record<string, boolean> = {
|
||||||
savedviews: !isDemo,
|
savedviews: !isDemo,
|
||||||
date: true,
|
date: true,
|
||||||
|
channel: true,
|
||||||
language: languages.length > 0,
|
language: languages.length > 0,
|
||||||
topic: topics.length > 0,
|
topic: topics.length > 0,
|
||||||
tags: userTags.length > 0,
|
tags: userTags.length > 0,
|
||||||
@@ -186,6 +149,8 @@ export default function Sidebar({
|
|||||||
switch (id) {
|
switch (id) {
|
||||||
case "savedviews":
|
case "savedviews":
|
||||||
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
||||||
|
case "channel":
|
||||||
|
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -436,25 +401,6 @@ export default function Sidebar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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} />
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||||
|
|
||||||
{tagManagerOpen && (
|
{tagManagerOpen && (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||||
import { api, type MyStatus } from "../lib/api";
|
import { api, type MyStatus } from "../lib/api";
|
||||||
import { formatViews } from "../lib/format";
|
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*
|
// 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
|
// 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({
|
export default function SyncStatus({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
variant = "bar",
|
|
||||||
collapsed = false,
|
|
||||||
}: {
|
}: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onGoToFullHistory: () => void;
|
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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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({
|
const { data } = useQuery({
|
||||||
queryKey: ["my-status"],
|
queryKey: ["my-status"],
|
||||||
queryFn: api.myStatus,
|
queryFn: api.myStatus,
|
||||||
@@ -57,7 +60,7 @@ export default function SyncStatus({
|
|||||||
const active = data.sync_active;
|
const active = data.sync_active;
|
||||||
const showMain = data.paused || active || syncing > 0 || notFull === 0;
|
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 ? (
|
const stateNode = data.paused ? (
|
||||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||||
) : active ? (
|
) : active ? (
|
||||||
@@ -93,136 +96,83 @@ export default function SyncStatus({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
|
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
||||||
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
|
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
|
||||||
if (variant === "rail") {
|
// history) so the collapsed chip still tells you when to look.
|
||||||
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
|
const needsAttention = data.paused || notFull > 0;
|
||||||
data.total_videos
|
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
|
||||||
)} ${t("header.sync.total")}`;
|
data.total_videos
|
||||||
if (collapsed) {
|
)} ${t("header.sync.total")}`;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
<div className="pointer-events-auto shrink-0">
|
||||||
<Tooltip hint={t("header.sync.countTooltip")}>
|
<button
|
||||||
<span className="flex items-center gap-1.5 cursor-default">
|
ref={chipRef}
|
||||||
<Database className="w-3.5 h-3.5" />
|
onClick={() => {
|
||||||
<span>
|
const r = chipRef.current?.getBoundingClientRect();
|
||||||
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
|
||||||
{t("header.sync.yours")}
|
setOpen((o) => !o);
|
||||||
</span>
|
}}
|
||||||
<span className="opacity-40">/</span>
|
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||||
<span>
|
// the viewport edge from here.
|
||||||
{formatViews(data.total_videos)} {t("header.sync.total")}
|
title={countsText}
|
||||||
</span>
|
aria-expanded={open}
|
||||||
</span>
|
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
|
||||||
</Tooltip>
|
>
|
||||||
{showMain && (
|
{active ? (
|
||||||
<>
|
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
|
||||||
<span className="opacity-40">·</span>
|
) : (
|
||||||
{data.paused ? (
|
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
)}
|
||||||
) : active ? (
|
<span className="text-fg font-medium tabular-nums">{formatViews(data.my_videos)}</span>
|
||||||
// A sync job is running right now — spin and name what it's doing.
|
{needsAttention && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
)}
|
||||||
{syncing > 0
|
</button>
|
||||||
? t("header.sync.syncing", { count: syncing })
|
|
||||||
: notFull > 0
|
{open &&
|
||||||
? t("header.sync.backfillingHistory")
|
createPortal(
|
||||||
: t("header.sync.working")}
|
<div
|
||||||
</span>
|
ref={popRef}
|
||||||
) : syncing > 0 ? (
|
style={{ position: "fixed", top: pos.top, right: pos.right }}
|
||||||
// Recent uploads queued for the next run, but nothing running now — static.
|
className="glass-menu w-64 rounded-xl p-3 z-50 text-xs text-muted animate-[popIn_0.16s_ease]"
|
||||||
<span className="flex items-center gap-1">
|
>
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<div
|
||||||
{t("header.sync.recentQueued", { count: syncing })}
|
title={t("header.sync.countTooltip")}
|
||||||
</span>
|
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
||||||
) : (
|
|
||||||
<span>{t("header.sync.allSynced")}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{notFull > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-40">·</span>
|
|
||||||
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
|
||||||
<button
|
|
||||||
onClick={onGoToFullHistory}
|
|
||||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<History className="w-3.5 h-3.5" />
|
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
<span>
|
||||||
</button>
|
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
||||||
</Tooltip>
|
{t("header.sync.yours")}
|
||||||
</>
|
<span className="opacity-40"> / </span>
|
||||||
)}
|
{formatViews(data.total_videos)} {t("header.sync.total")}
|
||||||
{/* Pause only makes sense when there's work to pause (recent OR deep backfill);
|
</span>
|
||||||
Resume must always show while paused so it can be lifted. Hidden entirely when
|
</div>
|
||||||
idle and not paused. */}
|
{showMain && (
|
||||||
{isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||||
<button
|
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||||
onClick={() => toggle.mutate()}
|
{pauseBtn}
|
||||||
disabled={toggle.isPending}
|
</div>
|
||||||
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"
|
{notFull > 0 && (
|
||||||
>
|
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||||
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
<button
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,6 @@
|
|||||||
"country": "Country",
|
"country": "Country",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"topics": "Topics",
|
"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…",
|
"loading": "Loading feed…",
|
||||||
"loadError": "Couldn't load the feed.",
|
"loadError": "Couldn't load the feed.",
|
||||||
"emptyTitle": "Your feed is empty",
|
"emptyTitle": "Your feed is empty",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"sync": {
|
"sync": {
|
||||||
"yours": "yours",
|
"yours": "yours",
|
||||||
"total": "total",
|
"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",
|
"paused": "paused",
|
||||||
"syncing": "{{count}} syncing",
|
"syncing": "{{count}} syncing",
|
||||||
"backfillingHistory": "fetching history",
|
"backfillingHistory": "fetching history",
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
"recentQueued": "{{count}} queued",
|
"recentQueued": "{{count}} queued",
|
||||||
"allSynced": "all synced",
|
"allSynced": "all synced",
|
||||||
"withoutFullHistory": "{{count}} without full history",
|
"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.",
|
"fullHistoryTooltip": "Recent uploads only — click to fetch the rest.",
|
||||||
"pause": "Pause background sync",
|
"pause": "Pause sync",
|
||||||
"resume": "Resume background sync"
|
"resume": "Resume sync"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"updated": "Updated to v{{version}} — see what's changed.",
|
"updated": "Updated to v{{version}} — see what's changed.",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"editHint": "Drag to reorder · eye to show/hide",
|
"editHint": "Drag to reorder · eye to show/hide",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"thisChannel": "This channel",
|
|
||||||
"channelCount": "{{count}} channel",
|
"channelCount": "{{count}} channel",
|
||||||
"channelCount_other": "{{count}} channels",
|
"channelCount_other": "{{count}} channels",
|
||||||
"dragToReorder": "Drag to reorder",
|
"dragToReorder": "Drag to reorder",
|
||||||
@@ -16,8 +15,8 @@
|
|||||||
"hideWidget": "Hide widget",
|
"hideWidget": "Hide widget",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"collapsePanel": "Collapse filters",
|
"pin": "Pin panel open",
|
||||||
"expandPanel": "Expand filters",
|
"unpin": "Unpin panel",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"tagModeTooltip": "Match any vs all selected tags",
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
@@ -40,7 +39,8 @@
|
|||||||
"content": "Content type",
|
"content": "Content type",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"topic": "Topic",
|
"topic": "Topic",
|
||||||
"tags": "Your tags"
|
"tags": "Your tags",
|
||||||
|
"channel": "Channel"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"unwatched": "Unwatched",
|
"unwatched": "Unwatched",
|
||||||
@@ -72,5 +72,9 @@
|
|||||||
"1month": "1 month",
|
"1month": "1 month",
|
||||||
"6months": "6 months",
|
"6months": "6 months",
|
||||||
"1year": "1 year"
|
"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",
|
"country": "Ország",
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
"topics": "Témák",
|
"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…",
|
"loading": "Hírfolyam betöltése…",
|
||||||
"loadError": "Nem sikerült betölteni a hírfolyamot.",
|
"loadError": "Nem sikerült betölteni a hírfolyamot.",
|
||||||
"emptyTitle": "A hírfolyamod üres",
|
"emptyTitle": "A hírfolyamod üres",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"sync": {
|
"sync": {
|
||||||
"yours": "tiéd",
|
"yours": "tiéd",
|
||||||
"total": "összes",
|
"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",
|
"paused": "szüneteltetve",
|
||||||
"syncing": "{{count}} szinkronizálás alatt",
|
"syncing": "{{count}} szinkronizálás alatt",
|
||||||
"backfillingHistory": "előzmény letöltése",
|
"backfillingHistory": "előzmény letöltése",
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
"recentQueued": "{{count}} sorban",
|
"recentQueued": "{{count}} sorban",
|
||||||
"allSynced": "minden szinkronban",
|
"allSynced": "minden szinkronban",
|
||||||
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
|
"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.",
|
"fullHistoryTooltip": "Csak a legutóbbi feltöltések — kattints a többiért.",
|
||||||
"pause": "Háttér-szinkronizálás szüneteltetése",
|
"pause": "Szinkron szüneteltetése",
|
||||||
"resume": "Háttér-szinkronizálás folytatása"
|
"resume": "Szinkron folytatása"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
|
"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",
|
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"thisChannel": "Ez a csatorna",
|
|
||||||
"channelCount": "{{count}} csatorna",
|
"channelCount": "{{count}} csatorna",
|
||||||
"channelCount_other": "{{count}} csatorna",
|
"channelCount_other": "{{count}} csatorna",
|
||||||
"dragToReorder": "Húzd az átrendezéshez",
|
"dragToReorder": "Húzd az átrendezéshez",
|
||||||
@@ -16,8 +15,8 @@
|
|||||||
"hideWidget": "Modul elrejtése",
|
"hideWidget": "Modul elrejtése",
|
||||||
"expand": "Kibontás",
|
"expand": "Kibontás",
|
||||||
"collapse": "Összecsukás",
|
"collapse": "Összecsukás",
|
||||||
"collapsePanel": "Szűrők összecsukása",
|
"pin": "Panel rögzítése nyitva",
|
||||||
"expandPanel": "Szűrők kibontása",
|
"unpin": "Panel rögzítésének feloldása",
|
||||||
"any": "Bármelyik",
|
"any": "Bármelyik",
|
||||||
"all": "Összes",
|
"all": "Összes",
|
||||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
@@ -40,7 +39,8 @@
|
|||||||
"content": "Tartalomtípus",
|
"content": "Tartalomtípus",
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
"topic": "Téma",
|
"topic": "Téma",
|
||||||
"tags": "Saját címkék"
|
"tags": "Saját címkék",
|
||||||
|
"channel": "Csatorna"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"unwatched": "Nem nézett",
|
"unwatched": "Nem nézett",
|
||||||
@@ -72,5 +72,9 @@
|
|||||||
"1month": "1 hónap",
|
"1month": "1 hónap",
|
||||||
"6months": "6 hónap",
|
"6months": "6 hónap",
|
||||||
"1year": "1 év"
|
"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-
|
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||||
librarySource?: "organic" | "all" | "search" | "plex";
|
librarySource?: "organic" | "all" | "search" | "plex";
|
||||||
channelId?: string;
|
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||||
channelName?: string;
|
channelIds: string[];
|
||||||
maxAgeDays?: number;
|
maxAgeDays?: number;
|
||||||
minDuration?: number;
|
minDuration?: number;
|
||||||
maxDuration?: number;
|
maxDuration?: number;
|
||||||
@@ -432,7 +432,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
|||||||
p.set("include_shorts", String(f.includeShorts));
|
p.set("include_shorts", String(f.includeShorts));
|
||||||
p.set("include_live", String(f.includeLive));
|
p.set("include_live", String(f.includeLive));
|
||||||
p.set("show", f.show);
|
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.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
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
|
// 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.
|
// appended by normalizeLayout, so nothing silently disappears.
|
||||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||||
feed: ["savedviews", "date", "tags", "language", "topic"],
|
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
|
||||||
plex: [
|
plex: [
|
||||||
"scope",
|
"scope",
|
||||||
"playlists",
|
"playlists",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
|||||||
if (f.includeLive) p.set("live", "1");
|
if (f.includeLive) p.set("live", "1");
|
||||||
if (f.tags.length) p.set("tags", f.tags.join(","));
|
if (f.tags.length) p.set("tags", f.tags.join(","));
|
||||||
if (f.tagMode === "and") p.set("tagmode", "and");
|
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.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||||
if (f.dateFrom) p.set("from", f.dateFrom);
|
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||||
if (f.dateTo) p.set("to", f.dateTo);
|
if (f.dateTo) p.set("to", f.dateTo);
|
||||||
@@ -72,7 +72,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
|||||||
.map((s) => Number(s))
|
.map((s) => Number(s))
|
||||||
.filter((n) => Number.isInteger(n));
|
.filter((n) => Number.isInteger(n));
|
||||||
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
|
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.maxAgeDays = num(params.get("maxage"));
|
||||||
f.dateFrom = params.get("from") || undefined;
|
f.dateFrom = params.get("from") || undefined;
|
||||||
f.dateTo = params.get("to") || 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