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,7 +1,7 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
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 app.auth import current_user
|
||||
|
|
@ -50,6 +50,7 @@ def get_feed(
|
|||
min_duration: int | None = None,
|
||||
max_duration: int | None = None,
|
||||
max_age_days: int | None = None,
|
||||
show_normal: bool = True,
|
||||
include_shorts: bool = False,
|
||||
include_live: bool = False,
|
||||
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
|
||||
# the Shorts / live default-hiding (so e.g. a hidden Short still shows up there).
|
||||
# Content-type filter: Normal (regular videos incl. past-stream VODs), Shorts and
|
||||
# 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")
|
||||
if not include_shorts and not explicit_view:
|
||||
query = query.where(Video.is_short.is_(False))
|
||||
if not include_live and not explicit_view:
|
||||
query = query.where(Video.live_status.notin_(HIDDEN_LIVE))
|
||||
if not explicit_view:
|
||||
type_clauses = []
|
||||
if show_normal:
|
||||
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:
|
||||
query = query.where(Video.channel_id == channel_id)
|
||||
if min_duration is not None:
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue