feat(feed): 1D — content-type chips + sort in a toolbar above the cards
Move content type and ordering out of the filter sidebar into a feed toolbar (approved proposal 2): a prominent content-type chip row (Normal/Shorts/Live toggle chips) with the sort control + count + reshuffle in a quieter row beneath it. The toolbar renders even on 'no matches' so content-type can be turned back on after it filtered everything out. The sidebar keeps Show / Upload date / Language / Topic; 'sort' and 'content' widgets removed from the layout (normalizeLayout drops them from any saved layout). New feed.sortLabel string (HU/EN/DE).
This commit is contained in:
parent
f6efe44e37
commit
d96b47ae5d
6 changed files with 95 additions and 84 deletions
|
|
@ -1,6 +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 { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
|
@ -9,6 +10,25 @@ import PlayerModal from "./PlayerModal";
|
||||||
|
|
||||||
const PAGE = 60;
|
const PAGE = 60;
|
||||||
|
|
||||||
|
// Sort + content-type live in the feed toolbar (above the cards), not the filter sidebar.
|
||||||
|
const SORT_IDS = [
|
||||||
|
"newest",
|
||||||
|
"oldest",
|
||||||
|
"views",
|
||||||
|
"duration_desc",
|
||||||
|
"duration_asc",
|
||||||
|
"title",
|
||||||
|
"subscribers",
|
||||||
|
"priority",
|
||||||
|
"shuffle",
|
||||||
|
];
|
||||||
|
const CONTENT = [
|
||||||
|
{ key: "includeNormal", label: "sidebar.content.normal" },
|
||||||
|
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
||||||
|
{ key: "includeLive", label: "sidebar.content.live" },
|
||||||
|
] as const;
|
||||||
|
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||||
|
|
||||||
function matchesView(status: string, show: string): boolean {
|
function matchesView(status: string, show: string): boolean {
|
||||||
switch (show) {
|
switch (show) {
|
||||||
case "hidden":
|
case "hidden":
|
||||||
|
|
@ -166,11 +186,10 @@ 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) {
|
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
|
||||||
// No YouTube connection and looking at their own (empty) subscriptions: offer both
|
// with no toolbar (content-type/sort would be meaningless here).
|
||||||
// connecting YouTube and just browsing the shared library (no read scope needed).
|
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||||
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">
|
||||||
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
||||||
|
|
@ -190,20 +209,73 @@ export default function Feed({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return <div className="p-8 text-muted">{t("feed.noMatches")}</div>;
|
|
||||||
}
|
const toolbar = (
|
||||||
|
<div className="pb-3">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mb-2.5">
|
||||||
|
{CONTENT.map((c) => {
|
||||||
|
const on = filters[c.key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||||
|
aria-pressed={on}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||||
|
on
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(c.label)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 border-t border-border pt-2.5">
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{countQuery.data
|
||||||
|
? t("feed.videoCount", {
|
||||||
|
count: countQuery.data.count,
|
||||||
|
formattedCount: countQuery.data.count.toLocaleString(),
|
||||||
|
})
|
||||||
|
: " "}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={filters.sort}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sort = e.target.value;
|
||||||
|
setFilters({ ...filters, sort, seed: sort === "shuffle" ? rollSeed() : undefined });
|
||||||
|
}}
|
||||||
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{SORT_IDS.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{t("sidebar.sort." + id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{filters.sort === "shuffle" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||||
|
title={t("sidebar.reshuffle")}
|
||||||
|
aria-label={t("sidebar.reshuffle")}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="pb-3 text-sm text-muted">
|
{toolbar}
|
||||||
{countQuery.data
|
{items.length === 0 ? (
|
||||||
? t("feed.videoCount", {
|
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
|
||||||
count: countQuery.data.count,
|
) : view === "grid" ? (
|
||||||
formattedCount: countQuery.data.count.toLocaleString(),
|
|
||||||
})
|
|
||||||
: " "}
|
|
||||||
</div>
|
|
||||||
{view === "grid" ? (
|
|
||||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||||
{items.map((v) => (
|
{items.map((v) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
|
|
|
||||||
|
|
@ -215,9 +215,7 @@ export default function Sidebar({
|
||||||
|
|
||||||
const available: Record<WidgetId, boolean> = {
|
const available: Record<WidgetId, boolean> = {
|
||||||
show: true,
|
show: true,
|
||||||
sort: true,
|
|
||||||
date: true,
|
date: true,
|
||||||
content: true,
|
|
||||||
language: languages.length > 0,
|
language: languages.length > 0,
|
||||||
topic: topics.length > 0,
|
topic: topics.length > 0,
|
||||||
};
|
};
|
||||||
|
|
@ -257,41 +255,6 @@ export default function Sidebar({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "sort":
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<select
|
|
||||||
value={filters.sort}
|
|
||||||
onChange={(e) => {
|
|
||||||
const sort = e.target.value;
|
|
||||||
// Seed shuffle on selection so it lands on a fresh order, not the
|
|
||||||
// deterministic seed-0 one every time.
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
sort,
|
|
||||||
seed: sort === "shuffle" ? rollSeed() : undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{SORT_IDS.map((id) => (
|
|
||||||
<option key={id} value={id}>
|
|
||||||
{t("sidebar.sort." + id)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{filters.sort === "shuffle" && (
|
|
||||||
<button
|
|
||||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
|
||||||
title={t("sidebar.reshuffle")}
|
|
||||||
aria-label={t("sidebar.reshuffle")}
|
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg shadow-sm hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -381,26 +344,6 @@ export default function Sidebar({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "content":
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toggle
|
|
||||||
label={t("sidebar.content.normal")}
|
|
||||||
checked={filters.includeNormal}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("sidebar.content.shorts")}
|
|
||||||
checked={filters.includeShorts}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("sidebar.content.live")}
|
|
||||||
checked={filters.includeLive}
|
|
||||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case "language": {
|
case "language": {
|
||||||
const chips = visibleChips(languages);
|
const chips = visibleChips(languages);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"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",
|
||||||
|
"sortLabel": "Sortierung",
|
||||||
"loadingMore": "Mehr wird geladen…",
|
"loadingMore": "Mehr wird geladen…",
|
||||||
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
||||||
"hidden": "Video ausgeblendet",
|
"hidden": "Video ausgeblendet",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"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",
|
||||||
|
"sortLabel": "Sort",
|
||||||
"loadingMore": "Loading more…",
|
"loadingMore": "Loading more…",
|
||||||
"hiddenNamed": "Hidden “{{title}}”",
|
"hiddenNamed": "Hidden “{{title}}”",
|
||||||
"hidden": "Video hidden",
|
"hidden": "Video hidden",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"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ó",
|
||||||
|
"sortLabel": "Rendezés",
|
||||||
"loadingMore": "Továbbiak betöltése…",
|
"loadingMore": "Továbbiak betöltése…",
|
||||||
"hiddenNamed": "Elrejtve: „{{title}}”",
|
"hiddenNamed": "Elrejtve: „{{title}}”",
|
||||||
"hidden": "Videó elrejtve",
|
"hidden": "Videó elrejtve",
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,15 @@
|
||||||
// collapsed, and which are hidden. Persisted to localStorage and the server-side
|
// collapsed, and which are hidden. Persisted to localStorage and the server-side
|
||||||
// `preferences` blob so it follows the account.
|
// `preferences` blob so it follows the account.
|
||||||
|
|
||||||
export type WidgetId = "show" | "sort" | "date" | "content" | "language" | "topic";
|
// `sort` and `content` moved to the feed toolbar (above the cards); they are no longer
|
||||||
|
// sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||||
|
export type WidgetId = "show" | "date" | "language" | "topic";
|
||||||
|
|
||||||
export const ALL_WIDGETS: WidgetId[] = [
|
export const ALL_WIDGETS: WidgetId[] = ["show", "date", "language", "topic"];
|
||||||
"show",
|
|
||||||
"sort",
|
|
||||||
"date",
|
|
||||||
"content",
|
|
||||||
"language",
|
|
||||||
"topic",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WIDGET_TITLES: Record<WidgetId, string> = {
|
export const WIDGET_TITLES: Record<WidgetId, string> = {
|
||||||
show: "Show",
|
show: "Show",
|
||||||
sort: "Sort",
|
|
||||||
date: "Upload date",
|
date: "Upload date",
|
||||||
content: "Content type",
|
|
||||||
language: "Language",
|
language: "Language",
|
||||||
topic: "Topic",
|
topic: "Topic",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue