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:
npeter83 2026-06-16 02:34:13 +02:00
parent f6efe44e37
commit d96b47ae5d
6 changed files with 95 additions and 84 deletions

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
@ -9,6 +10,25 @@ import PlayerModal from "./PlayerModal";
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 {
switch (show) {
case "hidden":
@ -166,10 +186,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) {
// 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")
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
// with no toolbar (content-type/sort would be meaningless here).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
@ -190,20 +209,73 @@ export default function Feed({
</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 (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
<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>
{view === "grid" ? (
</div>
);
return (
<div className="p-4">
{toolbar}
{items.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
) : view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard

View file

@ -215,9 +215,7 @@ export default function Sidebar({
const available: Record<WidgetId, boolean> = {
show: true,
sort: true,
date: true,
content: true,
language: languages.length > 0,
topic: topics.length > 0,
};
@ -257,41 +255,6 @@ export default function Sidebar({
))}
</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":
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": {
const chips = visibleChips(languages);
return (

View file

@ -8,6 +8,7 @@
"noMatches": "Keine Videos entsprechen diesen Filtern.",
"videoCount_one": "{{formattedCount}} Video",
"videoCount_other": "{{formattedCount}} Videos",
"sortLabel": "Sortierung",
"loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”",
"hidden": "Video ausgeblendet",

View file

@ -8,6 +8,7 @@
"noMatches": "No videos match these filters.",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”",
"hidden": "Video hidden",

View file

@ -8,6 +8,7 @@
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”",
"hidden": "Videó elrejtve",

View file

@ -2,22 +2,15 @@
// collapsed, and which are hidden. Persisted to localStorage and the server-side
// `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[] = [
"show",
"sort",
"date",
"content",
"language",
"topic",
];
export const ALL_WIDGETS: WidgetId[] = ["show", "date", "language", "topic"];
export const WIDGET_TITLES: Record<WidgetId, string> = {
show: "Show",
sort: "Sort",
date: "Upload date",
content: "Content type",
language: "Language",
topic: "Topic",
};