Compare commits
3 Commits
c7ec8c21f2
...
aaa9d0145e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa9d0145e | ||
|
|
f5a35cd1f2 | ||
|
|
1df396590f |
@@ -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,13 +40,18 @@ 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"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{isActive && (
|
||||||
|
<span className="absolute -inset-2 rounded-xl bg-accent/10" />
|
||||||
|
)}
|
||||||
|
<svg className="w-5 h-5 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
</svg>
|
</svg>
|
||||||
{tab.badge > 0 && (
|
{tab.badge > 0 && (
|
||||||
@@ -52,7 +60,11 @@ function BottomNav({ newCount }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium leading-none">{tab.label}</span>
|
<span className={`text-[10px] font-medium leading-none ${isActive ? "font-semibold" : ""}`}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
<div className="shrink-0 mt-0.5" onClick={(e) => { e.stopPropagation(); navigate(`/channels/${video.channel_id}`); }}>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Text + actions */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||||
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||||
{video.title}
|
{video.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Channel + date */}
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{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,22 +365,30 @@ 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 && (
|
||||||
</div>
|
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges */}
|
|
||||||
{(video.is_recommended && !calmMode) && (
|
|
||||||
<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">
|
|
||||||
Discover
|
Discover
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap text-[11px] text-zinc-600">
|
||||||
|
{date && <span>{date}</span>}
|
||||||
|
{video.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{video.description && (
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-500 line-clamp-2 mt-0.5">
|
||||||
|
{video.description.replace(/\n+/g, " ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions — fade in on hover */}
|
{/* 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 border-t border-zinc-800/80">
|
<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}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user