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:
parent
e91ded61bb
commit
db4a7c0440
11 changed files with 71 additions and 5 deletions
|
|
@ -39,6 +39,7 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tagMode: "or",
|
tagMode: "or",
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
includeNormal: true,
|
includeNormal: true,
|
||||||
includeShorts: false,
|
includeShorts: false,
|
||||||
includeLive: false,
|
includeLive: false,
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ export default function Feed({
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
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 (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||||
if (items.length === 0) {
|
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 (
|
return (
|
||||||
<div className="p-8 grid place-items-center text-center">
|
<div className="p-8 grid place-items-center text-center">
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
|
|
@ -160,6 +162,12 @@ export default function Feed({
|
||||||
>
|
>
|
||||||
{t("feed.setUp")}
|
{t("feed.setUp")}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
|
|
@ -49,6 +49,35 @@ export default function Header({
|
||||||
|
|
||||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
<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" ? (
|
{page === "feed" ? (
|
||||||
<div className="flex-1 max-w-xl mx-auto relative">
|
<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" />
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,9 @@ 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`).
|
// Filter values owned by the sidebar (everything except the header search `q` and the
|
||||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
|
// `scope` mode, both preserved across a "Clear all").
|
||||||
|
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagMode: "or",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
|
@ -170,7 +171,7 @@ export default function Sidebar({
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q });
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
||||||
}
|
}
|
||||||
|
|
||||||
const available: Record<WidgetId, boolean> = {
|
const available: Record<WidgetId, boolean> = {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "Dein Feed ist leer",
|
"emptyTitle": "Dein Feed ist leer",
|
||||||
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
||||||
"setUp": "Meinen Feed einrichten",
|
"setUp": "Meinen Feed einrichten",
|
||||||
|
"browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen",
|
||||||
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||||
"videoCount_one": "{{formattedCount}} Video",
|
"videoCount_one": "{{formattedCount}} Video",
|
||||||
"videoCount_other": "{{formattedCount}} Videos",
|
"videoCount_other": "{{formattedCount}} Videos",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Deine Abos durchsuchen…",
|
"searchPlaceholder": "Deine Abos durchsuchen…",
|
||||||
"channelManager": "Kanalverwaltung",
|
"channelManager": "Kanalverwaltung",
|
||||||
"usageStats": "Nutzung & Statistik",
|
"usageStats": "Nutzung & Statistik",
|
||||||
|
"scope": {
|
||||||
|
"label": "Feed-Quelle",
|
||||||
|
"my": "Meine",
|
||||||
|
"myTip": "Nur Videos aus deinen eigenen Abos",
|
||||||
|
"all": "Bibliothek",
|
||||||
|
"allTip": "Alle Videos im gemeinsamen Katalog"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "Your feed is empty",
|
"emptyTitle": "Your feed is empty",
|
||||||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
||||||
"setUp": "Set up my feed",
|
"setUp": "Set up my feed",
|
||||||
|
"browseShared": "Or just browse the shared library",
|
||||||
"noMatches": "No videos match these filters.",
|
"noMatches": "No videos match these filters.",
|
||||||
"videoCount_one": "{{formattedCount}} video",
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
"videoCount_other": "{{formattedCount}} videos",
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Search your subscriptions…",
|
"searchPlaceholder": "Search your subscriptions…",
|
||||||
"channelManager": "Channel manager",
|
"channelManager": "Channel manager",
|
||||||
"usageStats": "Usage & stats",
|
"usageStats": "Usage & stats",
|
||||||
|
"scope": {
|
||||||
|
"label": "Feed source",
|
||||||
|
"my": "Mine",
|
||||||
|
"myTip": "Only videos from your own subscriptions",
|
||||||
|
"all": "Library",
|
||||||
|
"allTip": "Every video in the shared catalog"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "A hírfolyamod üres",
|
"emptyTitle": "A hírfolyamod üres",
|
||||||
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
||||||
"setUp": "Hírfolyam beállítása",
|
"setUp": "Hírfolyam beállítása",
|
||||||
|
"browseShared": "Vagy csak böngészd a közös könyvtárat",
|
||||||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||||
"videoCount_one": "{{formattedCount}} videó",
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
"videoCount_other": "{{formattedCount}} videó",
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Keresés a feliratkozásaid között…",
|
"searchPlaceholder": "Keresés a feliratkozásaid között…",
|
||||||
"channelManager": "Csatornakezelő",
|
"channelManager": "Csatornakezelő",
|
||||||
"usageStats": "Használat és statisztika",
|
"usageStats": "Használat és statisztika",
|
||||||
|
"scope": {
|
||||||
|
"label": "Hírfolyam forrása",
|
||||||
|
"my": "Sajátom",
|
||||||
|
"myTip": "Csak a saját feliratkozásaid videói",
|
||||||
|
"all": "Könyvtár",
|
||||||
|
"allTip": "A közös könyvtár összes videója"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Adminisztrátor",
|
"admin": "Adminisztrátor",
|
||||||
"feed": "Hírfolyam",
|
"feed": "Hírfolyam",
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ export interface FeedFilters {
|
||||||
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
||||||
// feed re-queries with a fresh order. Ignored by every other sort.
|
// feed re-queries with a fresh order. Ignored by every other sort.
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
// "my" = only your subscriptions (default); "all" = the whole shared catalog.
|
||||||
|
scope: "my" | "all";
|
||||||
includeNormal: boolean;
|
includeNormal: boolean;
|
||||||
includeShorts: boolean;
|
includeShorts: boolean;
|
||||||
includeLive: boolean;
|
includeLive: boolean;
|
||||||
|
|
@ -157,6 +159,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
p.set("sort", f.sort);
|
p.set("sort", f.sort);
|
||||||
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
||||||
|
p.set("scope", f.scope);
|
||||||
p.set("show_normal", String(f.includeNormal));
|
p.set("show_normal", String(f.includeNormal));
|
||||||
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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue