UX: list view everywhere, mobile polish, affinity dismissal fix

- Default list view across all pages (Home, Following, History, Queue,
  ContinueWatching, Liked, Discovery, SearchResults, Channel)
- Watch.jsx mobile: smaller chips/title/avatar/meta, hide tags + keyboard
  hint on mobile, tighter gaps, compact description padding
- Fix mobile bottom nav showing focus outline on tap
- Fix _update_affinity to write negative entries (not just positive) so
  dislikes/dismissals on unseen content actually register
- Dismissing a discovery video now fires -3.0 affinity against its tags,
  matching the dislike weight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattias Tall
2026-05-26 16:41:22 +02:00
parent fc05a40f02
commit 6f600c9a5c
13 changed files with 35 additions and 32 deletions

View File

@@ -207,6 +207,10 @@ def dismiss_discovery_video(
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first() dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
if dq: if dq:
dq.seen = True dq.seen = True
from ..routers.videos import _update_affinity
_update_affinity(db, current_user.id, video, -3.0)
db.commit() db.commit()

View File

@@ -37,7 +37,6 @@ def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
existing.score = max(existing.score + delta, -20.0) existing.score = max(existing.score + delta, -20.0)
existing.updated_at = datetime.utcnow() existing.updated_at = datetime.utcnow()
else: else:
if delta > 0:
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta)) db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))

View File

@@ -40,7 +40,7 @@ function BottomNav({ newCount }) {
to={tab.to} to={tab.to}
end={tab.end} end={tab.end}
className={({ isActive }) => className={({ isActive }) =>
`relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${ `relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors outline-none ${
isActive ? "text-accent" : "text-zinc-500" isActive ? "text-accent" : "text-zinc-500"
}` }`
} }

View File

@@ -164,9 +164,9 @@ export default function ChannelPage() {
<div className="flex justify-end"> <div className="flex justify-end">
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} /> <SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="flex flex-col gap-2">
{sortVideos(videos, videoSort).map((v) => ( {sortVideos(videos, videoSort).map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} /> <VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))} ))}
</div> </div>
</> </>

View File

@@ -34,14 +34,12 @@ export default function ContinueWatchingPage() {
) : ( ) : (
<> <>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p> <p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={{ video={{ ...v, is_watched: false }}
...v, variant="list"
is_watched: false,
}}
/> />
))} ))}
</div> </div>

View File

@@ -324,11 +324,12 @@ export default function DiscoveryPage() {
</> </>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{visibleVideos.map((v) => ( {visibleVideos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={{ ...v, is_recommended: true }} video={{ ...v, is_recommended: true }}
variant="list"
onDismiss={handleDismissVideo} onDismiss={handleDismissVideo}
/> />
))} ))}

View File

@@ -1006,8 +1006,8 @@ export default function Following() {
<p className="text-zinc-500 text-sm">No videos indexed yet hit Sync all to pull the latest from YouTube.</p> <p className="text-zinc-500 text-sm">No videos indexed yet hit Sync all to pull the latest from YouTube.</p>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="flex flex-col gap-2">
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} />)} {sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} variant="list" />)}
</div> </div>
{hasMoreFeed && ( {hasMoreFeed && (
<div className="flex justify-center mt-2"> <div className="flex justify-center mt-2">

View File

@@ -34,9 +34,9 @@ export default function History() {
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">

View File

@@ -21,7 +21,7 @@ export default function Home() {
const [dismissed, setDismissed] = useState(new Set()); const [dismissed, setDismissed] = useState(new Set());
const [shuffleKey, setShuffleKey] = useState(0); const [shuffleKey, setShuffleKey] = useState(0);
const [duration, setDuration] = useState(""); const [duration, setDuration] = useState("");
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid"); const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "list");
const toggleViewMode = () => { const toggleViewMode = () => {
const next = viewMode === "grid" ? "list" : "grid"; const next = viewMode === "grid" ? "list" : "grid";

View File

@@ -88,9 +88,9 @@ export default function LikedPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{sortLiked(videos, sort).map((v) => ( {sortLiked(videos, sort).map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
)} )}

View File

@@ -36,11 +36,12 @@ export default function QueuePage() {
) : ( ) : (
<> <>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p> <p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{videos.map((v) => ( {videos.map((v) => (
<VideoCard <VideoCard
key={v.youtube_video_id} key={v.youtube_video_id}
video={v} video={v}
variant="list"
onRemoveFromQueue={() => { onRemoveFromQueue={() => {
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] })); toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
}} }}

View File

@@ -108,9 +108,9 @@ export default function SearchResults() {
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length} {hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
</span> </span>
</h2> </h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="flex flex-col gap-2">
{visibleVideos.map((v) => ( {visibleVideos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} /> <VideoCard key={v.youtube_video_id} video={v} variant="list" />
))} ))}
</div> </div>
{hasMore && ( {hasMore && (

View File

@@ -71,10 +71,10 @@ function DescriptionBox({ text }) {
return ( return (
<div <div
className="bg-zinc-900 rounded-xl p-4 cursor-pointer select-none" className="bg-zinc-900 rounded-xl p-3 sm:p-4 cursor-pointer select-none"
onClick={() => hasMore && setExpanded(v => !v)} onClick={() => hasMore && setExpanded(v => !v)}
> >
<p className="text-sm text-zinc-300 whitespace-pre-line leading-relaxed"> <p className="text-[13px] text-zinc-300 whitespace-pre-line leading-relaxed">
{linkify(displayed)} {linkify(displayed)}
</p> </p>
{hasMore && ( {hasMore && (
@@ -94,7 +94,7 @@ function Chip({ onClick, active, disabled, children }) {
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={[ className={[
"flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors", "flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700", active ? "bg-zinc-100 text-zinc-900 hover:bg-white" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700",
disabled && "opacity-40 cursor-not-allowed", disabled && "opacity-40 cursor-not-allowed",
].filter(Boolean).join(" ")} ].filter(Boolean).join(" ")}
@@ -844,7 +844,7 @@ export default function Watch() {
<div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}> <div className={theater ? "flex flex-col gap-6" : "flex flex-col xl:flex-row gap-6"}>
{/* ── Left: video + info ───────────────────────────────────────────── */} {/* ── Left: video + info ───────────────────────────────────────────── */}
<div className={theater ? "w-full flex flex-col gap-4" : "flex-1 min-w-0 flex flex-col gap-4"}> <div className={theater ? "w-full flex flex-col gap-3 sm:gap-4" : "flex-1 min-w-0 flex flex-col gap-3 sm:gap-4"}>
{/* Player */} {/* Player */}
<div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}> <div className={theater ? "w-full aspect-video bg-black overflow-hidden shadow-2xl" : "w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl"}>
@@ -887,11 +887,11 @@ export default function Watch() {
)} )}
{/* Title */} {/* Title */}
<h1 className="text-xl font-bold text-white leading-snug">{title}</h1> <h1 className="text-base sm:text-xl font-bold text-white leading-snug">{title}</h1>
{/* Meta + actions row */} {/* Meta + actions row */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2 text-sm text-zinc-500 flex-wrap"> <div className="flex items-center gap-2 text-xs text-zinc-500 flex-wrap">
{date && <span>{date}</span>} {date && <span>{date}</span>}
{video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>} {video?.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
{video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>} {video?.like_count > 0 && <><span>·</span><span>{formatViews(video.like_count).replace(" views", "")} likes</span></>}
@@ -914,7 +914,7 @@ export default function Watch() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{!dlComplete && !isDownloading && !downloadMut.isPending && ( {!dlComplete && !isDownloading && !downloadMut.isPending && (
<select <select
value={selectedQuality ?? "best"} value={selectedQuality ?? "best"}
@@ -1048,9 +1048,9 @@ export default function Watch() {
<Link to={`/channels/${video?.channel_id}`} className="shrink-0"> <Link to={`/channels/${video?.channel_id}`} className="shrink-0">
{channel?.thumbnail_url ? ( {channel?.thumbnail_url ? (
<img src={channel.thumbnail_url} alt={channelName} <img src={channel.thumbnail_url} alt={channelName}
className="w-11 h-11 rounded-full object-cover" /> className="w-9 h-9 sm:w-11 sm:h-11 rounded-full object-cover" />
) : ( ) : (
<div className="w-11 h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0" <div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full flex items-center justify-center font-bold text-white text-base shrink-0"
style={{ backgroundColor: avatarColor(channelName) }}> style={{ backgroundColor: avatarColor(channelName) }}>
{channelName?.[0]?.toUpperCase()} {channelName?.[0]?.toUpperCase()}
</div> </div>
@@ -1084,7 +1084,7 @@ export default function Watch() {
{/* Tags */} {/* Tags */}
{tags.length > 0 && ( {tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="hidden sm:flex flex-wrap gap-1.5">
{tags.map(tag => ( {tags.map(tag => (
<span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs"> <span key={tag} className="px-2.5 py-1 rounded-full bg-zinc-800/80 text-zinc-500 text-xs">
{tag} {tag}
@@ -1107,7 +1107,7 @@ export default function Watch() {
)} )}
{/* Keyboard shortcuts hint */} {/* Keyboard shortcuts hint */}
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}> <p className={`hidden sm:block text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
Space/K · pause &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; T · theater Space/K · pause &nbsp;·&nbsp; F · fullscreen &nbsp;·&nbsp; M · mute &nbsp;·&nbsp; / seek 5s &nbsp;·&nbsp; / volume &nbsp;·&nbsp; ,/. speed &nbsp;·&nbsp; T · theater
</p> </p>