feat: feedback round 3 — content-type toggles, channel filter, card polish

- Content type is now three independent toggles (Normal / Shorts / Live·Upcoming);
  the feed is the union of enabled types (backend show_normal + include_shorts +
  include_live)
- Per-channel filter: a button on each card scopes the feed to that channel, shown
  as a removable chip in the sidebar
- Hide now refetches the feed once the change is persisted, so a hidden video shows
  up in the Hidden view immediately (no refresh needed); optimistic updates respect
  the current view
- Grid cards are now distinct panels (card background, border, shadow) that lift on
  hover; clickable channel name in list view too
This commit is contained in:
npeter83 2026-06-11 03:47:51 +02:00
parent e07a37622d
commit ecbecbb9f4
6 changed files with 115 additions and 30 deletions

View file

@ -19,6 +19,7 @@ const DEFAULT_FILTERS: FeedFilters = {
tagMode: "or",
q: "",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
@ -96,7 +97,7 @@ export default function App() {
<div className="flex flex-1 min-h-0">
<Sidebar filters={filters} setFilters={setFilters} />
<main className="flex-1 min-w-0 overflow-y-auto">
<Feed filters={filters} view={view} />
<Feed filters={filters} setFilters={setFilters} view={view} />
</main>
</div>
<Toaster />

View file

@ -1,19 +1,37 @@
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import { toast } from "../lib/toast";
import VideoCard from "./VideoCard";
const PAGE = 60;
function matchesView(status: string, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
case "watched":
return status === "watched";
case "saved":
return status === "saved";
case "unwatched":
return status !== "watched" && status !== "hidden";
default:
return status !== "hidden"; // all
}
}
export default function Feed({
filters,
setFilters,
view,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
}) {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const qc = useQueryClient();
const query = useInfiniteQuery({
queryKey: ["feed", filters],
@ -43,20 +61,24 @@ export default function Feed({
function onState(id: string, status: string) {
setOverrides((o) => ({ ...o, [id]: status }));
api.setState(id, status).catch(() => {});
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() => {});
if (status === "hidden") {
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
}
}
function onChannelFilter(channelId: string, channelName: string) {
setFilters({ ...filters, channelId, channelName });
}
const items: Video[] = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => {
const ov = overrides[v.id];
// In the Hidden view, drop just-unhidden items; elsewhere drop just-hidden ones.
return filters.show === "hidden" ? ov !== "new" : ov !== "hidden";
});
.filter((v) => matchesView(v.status, filters.show));
if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>;
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
@ -66,15 +88,27 @@ export default function Feed({
return (
<div className="p-4">
{view === "grid" ? (
<div className="grid gap-x-4 gap-y-6 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard key={v.id} video={v} view="grid" onState={onState} />
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onChannelFilter={onChannelFilter}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto flex flex-col gap-1">
{items.map((v) => (
<VideoCard key={v.id} video={v} view="list" onState={onState} />
<VideoCard
key={v.id}
video={v}
view="list"
onState={onState}
onChannelFilter={onChannelFilter}
/>
))}
</div>
)}

View file

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
const SORTS = [
@ -64,13 +65,27 @@ export default function Sidebar({
const active =
filters.tags.length > 0 ||
!filters.includeNormal ||
filters.includeShorts ||
filters.includeLive ||
filters.show !== "unwatched" ||
filters.sort !== "newest";
filters.sort !== "newest" ||
!!filters.channelId;
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
{filters.channelId && (
<Section title="Channel">
<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">{filters.channelName ?? "This channel"}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</Section>
)}
<Section title="Show">
<div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => (
@ -103,14 +118,19 @@ export default function Sidebar({
</select>
</Section>
<Section title="Content">
<Section title="Content type">
<Toggle
label="Include Shorts"
label="Normal"
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label="Shorts"
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label="Include live / upcoming"
label="Live / Upcoming"
checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })}
/>
@ -167,6 +187,7 @@ export default function Sidebar({
tagMode: "or",
q: filters.q,
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",

View file

@ -1,4 +1,4 @@
import { Bookmark, Check, EyeOff } from "lucide-react";
import { Bookmark, Check, EyeOff, ListFilter } from "lucide-react";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -6,9 +6,11 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
@ -21,7 +23,7 @@ function Actions({
onClick={act("watched")}
title="Mark watched"
className={clsx(
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
)}
>
@ -31,7 +33,7 @@ function Actions({
onClick={act("saved")}
title="Save for later"
className={clsx(
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "text-accent"
)}
>
@ -40,10 +42,23 @@ function Actions({
<button
onClick={act("hidden")}
title="Hide"
className="p-1.5 rounded-md hover:bg-card text-muted hover:text-fg"
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<EyeOff className="w-4 h-4" />
</button>
{onChannelFilter && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
}}
title="Only this channel"
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
</button>
)}
</div>
);
}
@ -93,10 +108,12 @@ export default function VideoCard({
video,
view,
onState,
onChannelFilter,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const watched = video.status === "watched";
const meta = (
@ -138,7 +155,7 @@ export default function VideoCard({
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} />
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
</div>
);
}
@ -146,12 +163,12 @@ export default function VideoCard({
return (
<div
className={clsx(
"group transition-transform duration-150 hover:-translate-y-1",
"group rounded-2xl border border-border bg-card/50 p-2.5 shadow-sm transition-all duration-150 hover:-translate-y-1 hover:shadow-2xl hover:bg-card hover:border-accent/40",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" />
<div className="flex gap-3 mt-2">
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
{video.channel_thumbnail && (
<img
src={video.channel_thumbnail}
@ -172,7 +189,7 @@ export default function VideoCard({
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} />
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
</div>
</div>
</div>

View file

@ -45,10 +45,12 @@ export interface FeedFilters {
tagMode: "or" | "and";
q: string;
sort: string;
includeNormal: boolean;
includeShorts: boolean;
includeLive: boolean;
show: string;
channelId?: string;
channelName?: string;
maxAgeDays?: number;
minDuration?: number;
maxDuration?: number;
@ -78,6 +80,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
p.set("tag_mode", f.tagMode);
if (f.q) p.set("q", f.q);
p.set("sort", f.sort);
p.set("show_normal", String(f.includeNormal));
p.set("include_shorts", String(f.includeShorts));
p.set("include_live", String(f.includeLive));
p.set("show", f.show);