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 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:
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue