288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
import React from 'react'
|
||
|
||
// ── Pagination ────────────────────────────────────────────────────────────────
|
||
function Pagination({ meta, onPageChange }) {
|
||
if (!meta || meta.last_page <= 1) return null
|
||
|
||
const { current_page, last_page } = meta
|
||
const pages = []
|
||
|
||
if (last_page <= 7) {
|
||
for (let i = 1; i <= last_page; i++) pages.push(i)
|
||
} else {
|
||
const around = new Set(
|
||
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
|
||
(p) => p >= 1 && p <= last_page
|
||
)
|
||
)
|
||
const sorted = [...around].sort((a, b) => a - b)
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) pages.push('…')
|
||
pages.push(sorted[i])
|
||
}
|
||
}
|
||
|
||
return (
|
||
<nav
|
||
aria-label="Pagination"
|
||
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
|
||
>
|
||
<button
|
||
disabled={current_page <= 1}
|
||
onClick={() => onPageChange(current_page - 1)}
|
||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||
aria-label="Previous page"
|
||
>
|
||
‹ Prev
|
||
</button>
|
||
|
||
{pages.map((p, i) =>
|
||
p === '…' ? (
|
||
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
|
||
…
|
||
</span>
|
||
) : (
|
||
<button
|
||
key={p}
|
||
onClick={() => p !== current_page && onPageChange(p)}
|
||
aria-current={p === current_page ? 'page' : undefined}
|
||
className={[
|
||
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||
p === current_page
|
||
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
|
||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
|
||
].join(' ')}
|
||
>
|
||
{p}
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
disabled={current_page >= last_page}
|
||
onClick={() => onPageChange(current_page + 1)}
|
||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||
aria-label="Next page"
|
||
>
|
||
Next ›
|
||
</button>
|
||
</nav>
|
||
)
|
||
}
|
||
|
||
// ── Pin icon (for artwork reference) ─────────────────────────────────────────
|
||
function PinIcon() {
|
||
return (
|
||
<svg
|
||
className="w-3.5 h-3.5 shrink-0 text-white/30"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||
/>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ── Artwork image icon (for right panel label) ────────────────────────────────
|
||
function ImageIcon() {
|
||
return (
|
||
<svg
|
||
className="w-3 h-3 shrink-0 text-white/25"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ── Single comment row ────────────────────────────────────────────────────────
|
||
function CommentItem({ comment }) {
|
||
const { commenter, artwork, comment_text, time_ago, created_at } = comment
|
||
|
||
return (
|
||
<article className="flex gap-4 p-4 sm:p-5 rounded-xl border border-white/[0.065] bg-white/[0.025] hover:bg-white/[0.04] transition-colors">
|
||
|
||
{/* ── Avatar ── */}
|
||
<a
|
||
href={commenter.profile_url}
|
||
className="shrink-0 mt-0.5"
|
||
aria-label={`View ${commenter.display}'s profile`}
|
||
>
|
||
<img
|
||
src={commenter.avatar_url}
|
||
alt={commenter.display}
|
||
width={48}
|
||
height={48}
|
||
className="w-12 h-12 rounded-full object-cover ring-1 ring-white/[0.1]"
|
||
loading="lazy"
|
||
onError={(e) => {
|
||
e.currentTarget.onerror = null
|
||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
|
||
}}
|
||
/>
|
||
</a>
|
||
|
||
{/* ── Main content ── */}
|
||
<div className="min-w-0 flex-1">
|
||
|
||
{/* Author + time */}
|
||
<div className="flex items-baseline gap-2 flex-wrap mb-1">
|
||
<a
|
||
href={commenter.profile_url}
|
||
className="text-sm font-bold text-white hover:text-white/80 transition-colors"
|
||
>
|
||
{commenter.display}
|
||
</a>
|
||
<time
|
||
dateTime={created_at}
|
||
title={created_at ? new Date(created_at).toLocaleString() : ''}
|
||
className="text-xs text-white/40"
|
||
>
|
||
{time_ago}
|
||
</time>
|
||
</div>
|
||
|
||
{/* Comment text — primary visual element */}
|
||
<p className="text-base text-white leading-relaxed whitespace-pre-line break-words mb-3">
|
||
{comment_text}
|
||
</p>
|
||
|
||
{/* Artwork reference link */}
|
||
{artwork && (
|
||
<a
|
||
href={artwork.url}
|
||
className="inline-flex items-center gap-1.5 text-xs text-sky-400 hover:text-sky-300 transition-colors group"
|
||
>
|
||
<PinIcon />
|
||
<span className="font-medium">{artwork.title}</span>
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Right: artwork thumbnail ── */}
|
||
{artwork?.thumb && (
|
||
<a
|
||
href={artwork.url}
|
||
className="shrink-0 self-start group"
|
||
tabIndex={-1}
|
||
aria-hidden="true"
|
||
>
|
||
<div className="w-[220px] overflow-hidden rounded-lg ring-1 ring-white/[0.07] group-hover:ring-white/20 transition-all">
|
||
<img
|
||
src={artwork.thumb}
|
||
alt={artwork.title ?? 'Artwork'}
|
||
width={220}
|
||
height={96}
|
||
className="w-full h-24 object-cover"
|
||
loading="lazy"
|
||
onError={(e) => { e.currentTarget.closest('a').style.display = 'none' }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1.5 px-0.5">
|
||
<div className="flex items-center gap-1 mb-0.5">
|
||
<ImageIcon />
|
||
<span className="text-[11px] text-white/45 truncate max-w-[200px]">{artwork.title}</span>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
)}
|
||
|
||
</article>
|
||
)
|
||
}
|
||
|
||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
||
function FeedSkeleton() {
|
||
return (
|
||
<div className="space-y-3 animate-pulse">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex gap-4 p-5 rounded-xl border border-white/[0.06] bg-white/[0.02]"
|
||
>
|
||
{/* avatar */}
|
||
<div className="w-12 h-12 rounded-full bg-white/[0.07] shrink-0" />
|
||
{/* content */}
|
||
<div className="flex-1 space-y-2 pt-1">
|
||
<div className="h-3 bg-white/[0.07] rounded w-32" />
|
||
<div className="h-4 bg-white/[0.06] rounded w-full" />
|
||
<div className="h-4 bg-white/[0.05] rounded w-3/4" />
|
||
<div className="h-3 bg-white/[0.04] rounded w-24 mt-2" />
|
||
</div>
|
||
{/* thumbnail */}
|
||
<div className="w-[220px] h-24 rounded-lg bg-white/[0.05] shrink-0" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||
function EmptyState() {
|
||
return (
|
||
<div className="py-16 text-center">
|
||
<svg
|
||
className="mx-auto w-10 h-10 text-white/15 mb-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M8.625 9.75a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||
/>
|
||
</svg>
|
||
<p className="text-white/30 text-sm">No comments found.</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main export ───────────────────────────────────────────────────────────────
|
||
export default function CommentsFeed({
|
||
comments = [],
|
||
meta = {},
|
||
loading = false,
|
||
error = null,
|
||
onPageChange,
|
||
}) {
|
||
if (loading) return <FeedSkeleton />
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="rounded-xl border border-red-500/20 bg-red-900/10 px-6 py-5 text-sm text-red-400">
|
||
{error}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!comments || comments.length === 0) return <EmptyState />
|
||
|
||
return (
|
||
<div>
|
||
<div role="feed" aria-live="polite" aria-busy={loading} className="space-y-3">
|
||
{comments.map((comment) => (
|
||
<CommentItem key={comment.comment_id} comment={comment} />
|
||
))}
|
||
</div>
|
||
|
||
<Pagination meta={meta} onPageChange={onPageChange} />
|
||
</div>
|
||
)
|
||
}
|