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

@ -1,7 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, false, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.auth import current_user from app.auth import current_user
@ -50,6 +50,7 @@ def get_feed(
min_duration: int | None = None, min_duration: int | None = None,
max_duration: int | None = None, max_duration: int | None = None,
max_age_days: int | None = None, max_age_days: int | None = None,
show_normal: bool = True,
include_shorts: bool = False, include_shorts: bool = False,
include_live: bool = False, include_live: bool = False,
show: str = "unwatched", # all | unwatched | watched | saved | hidden show: str = "unwatched", # all | unwatched | watched | saved | hidden
@ -92,13 +93,21 @@ def get_feed(
) )
) )
# In the explicit Watched/Saved/Hidden views, show the complete set regardless of # Content-type filter: Normal (regular videos incl. past-stream VODs), Shorts and
# the Shorts / live default-hiding (so e.g. a hidden Short still shows up there). # Live/Upcoming are independent toggles; the feed is the union of the enabled types.
# The explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "saved", "hidden") explicit_view = show in ("watched", "saved", "hidden")
if not include_shorts and not explicit_view: if not explicit_view:
query = query.where(Video.is_short.is_(False)) type_clauses = []
if not include_live and not explicit_view: if show_normal:
query = query.where(Video.live_status.notin_(HIDDEN_LIVE)) type_clauses.append(
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE))
)
if include_shorts:
type_clauses.append(Video.is_short.is_(True))
if include_live:
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE))
query = query.where(or_(*type_clauses) if type_clauses else false())
if channel_id: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
if min_duration is not None: if min_duration is not None:

View file

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

View file

@ -1,19 +1,37 @@
import { useEffect, useRef, useState } from "react"; 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 { api, type FeedFilters, type Video } from "../lib/api";
import { toast } from "../lib/toast"; import { toast } from "../lib/toast";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
const PAGE = 60; 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({ export default function Feed({
filters, filters,
setFilters,
view, view,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list"; view: "grid" | "list";
}) { }) {
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const qc = useQueryClient();
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", filters], queryKey: ["feed", filters],
@ -43,20 +61,24 @@ export default function Feed({
function onState(id: string, status: string) { function onState(id: string, status: string) {
setOverrides((o) => ({ ...o, [id]: status })); 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") { if (status === "hidden") {
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") }); 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 ?? []) const items: Video[] = (query.data?.pages ?? [])
.flatMap((p) => p.items) .flatMap((p) => p.items)
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => { .filter((v) => matchesView(v.status, filters.show));
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";
});
if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>; 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>; 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 ( return (
<div className="p-4"> <div className="p-4">
{view === "grid" ? ( {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) => ( {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>
) : ( ) : (
<div className="max-w-4xl mx-auto flex flex-col gap-1"> <div className="max-w-4xl mx-auto flex flex-col gap-1">
{items.map((v) => ( {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> </div>
)} )}

View file

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
const SORTS = [ const SORTS = [
@ -64,13 +65,27 @@ export default function Sidebar({
const active = const active =
filters.tags.length > 0 || filters.tags.length > 0 ||
!filters.includeNormal ||
filters.includeShorts || filters.includeShorts ||
filters.includeLive || filters.includeLive ||
filters.show !== "unwatched" || filters.show !== "unwatched" ||
filters.sort !== "newest"; filters.sort !== "newest" ||
!!filters.channelId;
return ( 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"> <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"> <Section title="Show">
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => ( {SHOWS.map((s) => (
@ -103,14 +118,19 @@ export default function Sidebar({
</select> </select>
</Section> </Section>
<Section title="Content"> <Section title="Content type">
<Toggle <Toggle
label="Include Shorts" label="Normal"
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label="Shorts"
checked={filters.includeShorts} checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })} onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/> />
<Toggle <Toggle
label="Include live / upcoming" label="Live / Upcoming"
checked={filters.includeLive} checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })} onChange={(v) => setFilters({ ...filters, includeLive: v })}
/> />
@ -167,6 +187,7 @@ export default function Sidebar({
tagMode: "or", tagMode: "or",
q: filters.q, q: filters.q,
sort: "newest", sort: "newest",
includeNormal: true,
includeShorts: false, includeShorts: false,
includeLive: false, includeLive: false,
show: "unwatched", 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 clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -6,9 +6,11 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({ function Actions({
video, video,
onState, onState,
onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const act = (status: string) => (e: React.MouseEvent) => { const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -21,7 +23,7 @@ function Actions({
onClick={act("watched")} onClick={act("watched")}
title="Mark watched" title="Mark watched"
className={clsx( 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" video.status === "watched" && "text-accent"
)} )}
> >
@ -31,7 +33,7 @@ function Actions({
onClick={act("saved")} onClick={act("saved")}
title="Save for later" title="Save for later"
className={clsx( 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" video.status === "saved" && "text-accent"
)} )}
> >
@ -40,10 +42,23 @@ function Actions({
<button <button
onClick={act("hidden")} onClick={act("hidden")}
title="Hide" 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" /> <EyeOff className="w-4 h-4" />
</button> </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> </div>
); );
} }
@ -93,10 +108,12 @@ export default function VideoCard({
video, video,
view, view,
onState, onState,
onChannelFilter,
}: { }: {
video: Video; video: Video;
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const watched = video.status === "watched"; const watched = video.status === "watched";
const meta = ( const meta = (
@ -138,7 +155,7 @@ export default function VideoCard({
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} /> <Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
</div> </div>
); );
} }
@ -146,12 +163,12 @@ export default function VideoCard({
return ( return (
<div <div
className={clsx( 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" watched && "opacity-55"
)} )}
> >
<Thumb video={video} className="aspect-video" /> <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 && ( {video.channel_thumbnail && (
<img <img
src={video.channel_thumbnail} src={video.channel_thumbnail}
@ -172,7 +189,7 @@ export default function VideoCard({
{video.channel_title} {video.channel_title}
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <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> </div>
</div> </div>

View file

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