feat(feed): shared-library scope toggle in the header

Add a "Mine / Library" segmented toggle (shown on the feed page) that switches
FeedFilters.scope between the user's own subscriptions and the whole shared
catalog. A read-scope-less user (signed in but no YouTube grant) can now browse
and manage the shared library on their own account — the empty "my feed" state
offers a "browse the shared library" shortcut alongside the connect-YouTube CTA.
scope is preserved across "Clear all" (it's a mode, not a filter) and kept out
of the URL state. Trilingual strings (HU/EN/DE) for the toggle and the CTA.
This commit is contained in:
npeter83 2026-06-15 04:06:22 +02:00
parent e91ded61bb
commit db4a7c0440
11 changed files with 71 additions and 5 deletions

View file

@ -148,7 +148,9 @@ export default function Feed({
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
if (items.length === 0) {
if (!canRead)
// No YouTube connection and looking at their own (empty) subscriptions: offer both
// connecting YouTube and just browsing the shared library (no read scope needed).
if (!canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
@ -160,6 +162,12 @@ export default function Feed({
>
{t("feed.setUp")}
</button>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
@ -49,6 +49,35 @@ export default function Header({
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
{page === "feed" && (
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
</button>
))}
</div>
)}
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />

View file

@ -60,8 +60,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 365, key: "1year" },
];
// Filter values owned by the sidebar (everything except the header search `q`).
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
// 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",
@ -170,7 +171,7 @@ export default function Sidebar({
function clearAll() {
setCustomDates(false);
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q });
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
}
const available: Record<WidgetId, boolean> = {