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:
parent
e07a37622d
commit
ecbecbb9f4
6 changed files with 115 additions and 30 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue