173 lines
8.1 KiB
JavaScript
173 lines
8.1 KiB
JavaScript
import React from 'react'
|
|
|
|
export default function CategoryCard({ category }) {
|
|
const name = category?.name ?? 'Untitled'
|
|
const slug = category?.slug
|
|
const categoryHref = slug ? `/forum/category/${slug}` : null
|
|
const threads = category?.thread_count ?? 0
|
|
const posts = category?.post_count ?? 0
|
|
const lastActivity = category?.last_activity_at
|
|
const preview = category?.preview_image ?? '/images/forum-default.jpg'
|
|
const boards = category?.boards ?? []
|
|
const boardCount = boards.length
|
|
const activeBoards = boards.filter((board) => Number(board?.topics_count ?? 0) > 0).length
|
|
const latestBoard = boards
|
|
.filter((board) => board?.latest_topic?.last_post_at)
|
|
.sort((a, b) => new Date(b.latest_topic.last_post_at) - new Date(a.latest_topic.last_post_at))[0]
|
|
|
|
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
|
|
|
|
return (
|
|
<div className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus-within:ring-2 focus-within:ring-cyan-400">
|
|
{/* Image */}
|
|
<div className="relative aspect-[16/9]">
|
|
{categoryHref ? (
|
|
<a href={categoryHref} className="block h-full">
|
|
<img
|
|
src={preview}
|
|
alt={`${name} preview`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
|
|
|
<div className="absolute inset-x-0 bottom-0 p-5">
|
|
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
</div>
|
|
|
|
<h3 className="text-lg font-bold leading-snug text-white transition group-hover:text-cyan-200">
|
|
{name}
|
|
</h3>
|
|
|
|
{category?.description && (
|
|
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
|
|
)}
|
|
|
|
{timeAgo && (
|
|
<p className="mt-1 text-xs text-white/50">
|
|
Last activity: <span className="text-white/70">{timeAgo}</span>
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-3 flex items-center gap-4 text-sm">
|
|
<span className="flex items-center gap-1.5 text-cyan-300">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
{number(posts)} posts
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-cyan-300/70">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
{number(threads)} topics
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200 transition group-hover:text-cyan-100">
|
|
View section
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
) : (
|
|
<>
|
|
<img
|
|
src={preview}
|
|
alt={`${name} preview`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
|
|
|
<div className="absolute inset-x-0 bottom-0 p-5">
|
|
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
</div>
|
|
|
|
<h3 className="text-lg font-bold leading-snug text-white">{name}</h3>
|
|
|
|
{category?.description && (
|
|
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
|
|
)}
|
|
|
|
{timeAgo && (
|
|
<p className="mt-1 text-xs text-white/50">
|
|
Last activity: <span className="text-white/70">{timeAgo}</span>
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-3 flex items-center gap-4 text-sm">
|
|
<span className="flex items-center gap-1.5 text-cyan-300">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
{number(posts)} posts
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-cyan-300/70">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
{number(threads)} topics
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-white/8 p-4">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Boards</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{number(boardCount)}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Topics</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{number(threads)}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Posts</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{number(posts)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-between text-xs text-white/50">
|
|
<span>{number(activeBoards)} active boards</span>
|
|
{latestBoard?.title ? <span>Latest: {latestBoard.title}</span> : <span>No recent board activity</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function number(n) {
|
|
return (n ?? 0).toLocaleString()
|
|
}
|
|
|
|
function formatTimeAgo(dateStr) {
|
|
try {
|
|
const date = new Date(dateStr)
|
|
const now = new Date()
|
|
const diff = Math.floor((now - date) / 1000)
|
|
|
|
if (diff < 60) return 'just now'
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
|
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|