Compare commits

..

3 Commits

Author SHA1 Message Date
Mattias Tall
aaa9d0145e Fix description text clipping in video cards
Card wrapper overflow-hidden was clipping description text at the card
boundary. Move overflow-hidden + rounding to the thumbnail only so the
card body text has room to render fully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:03:56 +02:00
Mattias Tall
f5a35cd1f2 Add view count and description snippet to grid video cards
Date and views share a meta line; description shows up to 2 lines below —
gives enough context to judge a video during discovery without opening it.
Both fields are optional so cards without them stay compact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:02:50 +02:00
Mattias Tall
1df396590f Redesign video cards with channel avatar; fix mobile bottom nav
VideoCard grid: YouTube-style layout — channel avatar circle (falls back
to letter) left of title/meta, avatar is clickable to go to channel page,
date on its own line for breathing room, badges inline with channel name

Bottom nav: env(safe-area-inset-bottom) so it clears iOS home indicator,
active tab gets a soft accent pill behind the icon, slightly bolder label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:01:43 +02:00
2 changed files with 76 additions and 35 deletions

View File

@@ -29,7 +29,10 @@ function BottomNav({ newCount }) {
]; ];
return ( return (
<nav className="sm:hidden fixed bottom-0 inset-x-0 z-50 bg-zinc-950/95 backdrop-blur border-t border-zinc-800"> <nav
className="sm:hidden fixed bottom-0 inset-x-0 z-50 bg-zinc-950/98 backdrop-blur border-t border-zinc-800"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-stretch h-16"> <div className="flex items-stretch h-16">
{tabs.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
@@ -37,22 +40,31 @@ 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-1 transition-colors ${
isActive ? "text-accent" : "text-zinc-500" isActive ? "text-accent" : "text-zinc-500"
}` }`
} }
> >
<div className="relative"> {({ isActive }) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <>
{tab.icon} <div className="relative">
</svg> {isActive && (
{tab.badge > 0 && ( <span className="absolute -inset-2 rounded-xl bg-accent/10" />
<span className="absolute -top-1 -right-1.5 min-w-[14px] h-3.5 bg-accent text-black text-[9px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none"> )}
{tab.badge > 99 ? "99+" : tab.badge} <svg className="w-5 h-5 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{tab.icon}
</svg>
{tab.badge > 0 && (
<span className="absolute -top-1 -right-1.5 min-w-[14px] h-3.5 bg-accent text-black text-[9px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{tab.badge > 99 ? "99+" : tab.badge}
</span>
)}
</div>
<span className={`text-[10px] font-medium leading-none ${isActive ? "font-semibold" : ""}`}>
{tab.label}
</span> </span>
)} </>
</div> )}
<span className="text-[10px] font-medium leading-none">{tab.label}</span>
</NavLink> </NavLink>
))} ))}
</div> </div>
@@ -257,7 +269,7 @@ export default function Layout() {
</header> </header>
{/* Page content */} {/* Page content */}
<main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6 pb-24 sm:pb-6"> <main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6 pb-28 sm:pb-6" style={{ paddingBottom: "calc(7rem + env(safe-area-inset-bottom, 0px))" }}>
<Outlet /> <Outlet />
</main> </main>

View File

@@ -19,6 +19,14 @@ function formatDate(dateStr) {
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
} }
function formatViews(n) {
if (!n) return null;
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B views`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 100_000_000 ? 0 : 1)}M views`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K views`;
return `${n} views`;
}
function IconBtn({ onClick, title, active, pending, children }) { function IconBtn({ onClick, title, active, pending, children }) {
return ( return (
<button <button
@@ -306,11 +314,14 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
} }
// ── Grid variant ───────────────────────────────────────────────────────── // ── Grid variant ─────────────────────────────────────────────────────────
const avatarUrl = channelMeta?.thumbnail_url ?? null;
const avatarLetter = video.channel_name?.[0]?.toUpperCase() ?? "?";
return ( return (
<div <div
onClick={() => navigate(`/watch/${video.youtube_video_id}`)} onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
className={clsx( className={clsx(
"group relative flex flex-col cursor-pointer rounded-2xl overflow-hidden", "group relative flex flex-col cursor-pointer rounded-2xl",
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150", "bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
size === "sm" && "text-xs", size === "sm" && "text-xs",
)} )}
@@ -321,19 +332,29 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
duration={duration} duration={duration}
calmMode={calmMode} calmMode={calmMode}
onDismiss={() => dismissMut.mutate()} onDismiss={() => dismissMut.mutate()}
className="aspect-video" className="aspect-video rounded-t-2xl overflow-hidden"
/> />
<div className="flex flex-col gap-2 p-3 flex-1"> <div className="flex gap-2.5 p-3 flex-1">
{/* Title */} {/* Channel avatar */}
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2"> <div className="shrink-0 mt-0.5" onClick={(e) => { e.stopPropagation(); navigate(`/channels/${video.channel_id}`); }}>
{video.title} {avatarUrl ? (
</p> <img src={avatarUrl} alt="" className="w-8 h-8 rounded-full object-cover hover:ring-2 hover:ring-accent/50 transition-all" />
) : (
<div className="w-8 h-8 rounded-full bg-zinc-700 flex items-center justify-center text-xs font-bold text-zinc-400 hover:ring-2 hover:ring-accent/50 transition-all">
{avatarLetter}
</div>
)}
</div>
{/* Channel + date */} {/* Text + actions */}
<div className="flex items-center justify-between gap-2"> <div className="flex flex-col gap-1 min-w-0 flex-1">
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span> <p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
<div className="flex items-center gap-1.5 shrink-0"> {video.title}
</p>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
{channelNote && ( {channelNote && (
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default"> <span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
@@ -344,20 +365,28 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
{isDormant && !calmMode && ( {isDormant && !calmMode && (
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span> <span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
)} )}
{date && <span className="text-[11px] text-zinc-700">{date}</span>} {video.is_recommended && !calmMode && (
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
Discover
</span>
)}
</div> </div>
</div>
{/* Badges */} <div className="flex items-center gap-1.5 flex-wrap text-[11px] text-zinc-600">
{(video.is_recommended && !calmMode) && ( {date && <span>{date}</span>}
<span className="self-start text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full"> {video.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
Discover </div>
</span>
)}
{/* Actions — fade in on hover */} {video.description && (
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 border-t border-zinc-800/80"> <p className="text-[11px] leading-relaxed text-zinc-500 line-clamp-2 mt-0.5">
{actions} {video.description.replace(/\n+/g, " ")}
</p>
)}
{/* Actions — fade in on hover */}
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150">
{actions}
</div>
</div> </div>
</div> </div>
</div> </div>