Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,27 @@
import React from 'react'
export default function RisingBadge({ heatScore, rankingScore }) {
if (!heatScore && !rankingScore) return null
const isRising = heatScore > 5
const isTrending = rankingScore > 50
if (!isRising && !isTrending) return null
return (
<span className="inline-flex items-center gap-1">
{isRising && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
<i className="fa-solid fa-fire text-[10px]" />
Rising
</span>
)}
{isTrending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30">
<i className="fa-solid fa-arrow-trend-up text-[10px]" />
Trending
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
const statusConfig = {
published: { label: 'Published', className: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
draft: { label: 'Draft', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
archived: { label: 'Archived', className: 'bg-slate-500/20 text-slate-400 border-slate-500/30' },
scheduled: { label: 'Scheduled', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
}
export default function StatusBadge({ status }) {
const config = statusConfig[status] || statusConfig.draft
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${config.className}`}>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,71 @@
import React from 'react'
/**
* Compact artwork card for embedding inside a PostCard.
* Shows thumbnail, title and original author with attribution.
*/
export default function EmbeddedArtworkCard({ artwork }) {
if (!artwork) return null
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
const authorUrl = `/@${artwork.author.username}`
const handleCardClick = (e) => {
// Don't navigate when clicking the author link
if (e.defaultPrevented) return
window.location.href = artUrl
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
window.location.href = artUrl
}
}
return (
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
<div
role="link"
tabIndex={0}
aria-label={artwork.title}
onClick={handleCardClick}
onKeyDown={handleKeyDown}
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
>
{/* Thumbnail */}
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
{artwork.thumb_url ? (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600">
<i className="fa-solid fa-image" />
</div>
)}
</div>
{/* Meta */}
<div className="flex flex-col justify-center min-w-0">
<p className="text-sm font-medium text-white/90 truncate">{artwork.title}</p>
<a
href={authorUrl}
onClick={(e) => e.stopPropagation()}
className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate"
>
<i className="fa-solid fa-user-circle fa-fw mr-1 opacity-60" />
by {artwork.author.name || `@${artwork.author.username}`}
</a>
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
</div>
</div>
)
}
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}

View File

@@ -0,0 +1,352 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import SuggestedUsersWidget from '../social/SuggestedUsersWidget'
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function fmt(n) {
if (n === null || n === undefined) return '0'
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(n)
}
const SOCIAL_META = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'Twitter / X', prefix: 'https://x.com/' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', prefix: 'https://instagram.com/' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', prefix: 'https://deviantart.com/' },
artstation: { icon: 'fa-brands fa-artstation', label: 'ArtStation', prefix: 'https://artstation.com/' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance', prefix: 'https://behance.net/' },
website: { icon: 'fa-solid fa-globe', label: 'Website', prefix: '' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', prefix: '' },
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch', prefix: '' },
}
function SideCard({ title, icon, children, className = '' }) {
return (
<div className={`rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden ${className}`}>
{title && (
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
{icon && <i className={`${icon} text-slate-500 fa-fw text-[13px]`} />}
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
</div>
)}
{children}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Stats card
// ─────────────────────────────────────────────────────────────────────────────
function StatsCard({ stats, followerCount, user, onTabChange }) {
const items = [
{
label: 'Uploads',
value: fmt(stats?.uploads_count ?? 0),
icon: 'fa-solid fa-image',
color: 'text-sky-400',
tab: 'artworks',
},
{
label: 'Followers',
value: fmt(followerCount ?? stats?.followers_count ?? 0),
icon: 'fa-solid fa-user-group',
color: 'text-violet-400',
tab: null,
},
{
label: 'Following',
value: fmt(stats?.following_count ?? 0),
icon: 'fa-solid fa-user-plus',
color: 'text-emerald-400',
tab: null,
},
{
label: 'Awards',
value: fmt(stats?.awards_received_count ?? 0),
icon: 'fa-solid fa-trophy',
color: 'text-amber-400',
tab: 'stats',
},
]
return (
<SideCard title="Stats" icon="fa-solid fa-chart-simple">
<div className="grid grid-cols-2 divide-x divide-y divide-white/[0.05]">
{items.map((item) => (
<button
key={item.label}
type="button"
onClick={() => item.tab && onTabChange?.(item.tab)}
className={`flex flex-col items-center gap-1 py-4 px-3 transition-colors group ${
item.tab ? 'hover:bg-white/[0.04] cursor-pointer' : 'cursor-default'
}`}
>
<i className={`${item.icon} ${item.color} text-sm fa-fw mb-0.5 group-hover:scale-110 transition-transform`} />
<span className="text-xl font-bold text-white/90 tabular-nums leading-none">{item.value}</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wide">{item.label}</span>
</button>
))}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// About card
// ─────────────────────────────────────────────────────────────────────────────
function AboutCard({ user, profile, socialLinks, countryName }) {
const bio = profile?.bio || profile?.about || profile?.description
const website = profile?.website || user?.website
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
const hasContent = bio || countryName || website || joined || hasSocials
if (!hasContent) return null
return (
<SideCard title="About" icon="fa-solid fa-circle-info">
<div className="px-4 py-3 space-y-3">
{bio && (
<p className="text-sm text-slate-300 leading-relaxed line-clamp-4">{bio}</p>
)}
<div className="space-y-1.5">
{countryName && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Location</span>
<span className="text-slate-300">{countryName}</span>
</div>
)}
{joined && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-calendar-days fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Joined</span>
<span className="text-slate-300">{joined}</span>
</div>
)}
{website && (
<div className="flex items-center gap-2 text-[13px]">
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Website</span>
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-sky-400/80 hover:text-sky-400 transition-colors truncate max-w-[200px]"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
</div>
{hasSocials && (
<div className="flex flex-wrap gap-1.5 pt-1">
{Object.entries(socialLinks).map(([platform, link]) => {
const meta = SOCIAL_META[platform] ?? SOCIAL_META.website
const url = link.url || (meta.prefix ? meta.prefix + link.handle : null)
if (!url) return null
return (
<a
key={platform}
href={url}
target="_blank"
rel="noopener noreferrer nofollow"
title={meta.label}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 hover:bg-sky-500/15 text-slate-400 hover:text-sky-400 transition-all border border-white/[0.06] hover:border-sky-500/30"
>
<i className={`${meta.icon} text-sm`} />
</a>
)
})}
</div>
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Recent followers card
// ─────────────────────────────────────────────────────────────────────────────
function RecentFollowersCard({ recentFollowers, followerCount, onTabChange }) {
const followers = recentFollowers ?? []
if (followers.length === 0) return null
return (
<SideCard title="Recent Followers" icon="fa-solid fa-user-group">
<div className="px-4 py-3 space-y-2.5">
{followers.slice(0, 6).map((f) => (
<a
key={f.id}
href={f.profile_url ?? `/@${f.username}`}
className="flex items-center gap-2.5 group"
>
<img
src={f.avatar_url ?? '/images/avatar_default.webp'}
alt={f.username}
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
{f.name || f.uname || f.username}
</p>
<p className="text-[11px] text-slate-600 truncate">@{f.username}</p>
</div>
</a>
))}
{followerCount > 6 && (
<button
type="button"
onClick={() => onTabChange?.('artworks')}
className="w-full text-center text-[12px] text-slate-500 hover:text-sky-400 transition-colors pt-1"
>
View all {fmt(followerCount)} followers
</button>
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Trending hashtags card
// ─────────────────────────────────────────────────────────────────────────────
function TrendingHashtagsCard() {
const [tags, setTags] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/feed/hashtags/trending', { params: { limit: 8 } })
.then(({ data }) => setTags(Array.isArray(data.hashtags) ? data.hashtags : []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (!loading && tags.length === 0) return null
return (
<SideCard title="Trending Tags" icon="fa-solid fa-hashtag">
<div className="px-4 py-3 space-y-1">
{loading
? [1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse flex items-center justify-between py-1.5">
<div className="h-2.5 bg-white/10 rounded w-20" />
<div className="h-2 bg-white/6 rounded w-10" />
</div>
))
: tags.map((h) => (
<a
key={h.tag}
href={`/tags/${h.tag}`}
className="flex items-center justify-between group py-1.5 px-1 rounded-lg hover:bg-white/[0.04] transition-colors"
>
<span className="text-sm text-slate-300 group-hover:text-sky-400 transition-colors font-medium">
#{h.tag}
</span>
<span className="text-[11px] text-slate-600 tabular-nums">{h.post_count} posts</span>
</a>
))
}
<div className="flex items-center justify-between pt-1">
<a
href="/feed/trending"
className="text-[12px] text-sky-500/70 hover:text-sky-400 transition-colors"
>
See trending
</a>
<a
href="/feed/search"
className="text-[12px] text-slate-500 hover:text-slate-300 transition-colors"
>
<i className="fa-solid fa-magnifying-glass mr-1 text-[10px]" />
Search
</a>
</div>
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Suggested to follow card
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// Main export
// ─────────────────────────────────────────────────────────────────────────────
/**
* FeedSidebar
*
* Props:
* user object { id, username, name, uploads_count, ...}
* profile object { bio, about, country, website, ... }
* stats object from user_statistics
* followerCount number
* recentFollowers array [{ id, username, name, avatar_url, profile_url }]
* socialLinks object keyed by platform
* countryName string|null
* isLoggedIn boolean
* onTabChange function(tab)
*/
export default function FeedSidebar({
user,
profile,
stats,
followerCount,
recentFollowers,
suggestedUsers,
socialLinks,
countryName,
isLoggedIn,
onTabChange,
}) {
return (
<div className="space-y-4">
<AboutCard
user={user}
profile={profile}
socialLinks={socialLinks}
countryName={countryName}
/>
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<RecentFollowersCard
recentFollowers={recentFollowers}
followerCount={followerCount}
onTabChange={onTabChange}
/>
<SuggestedUsersWidget
title="Discover Creators"
excludeUsername={user?.username}
isLoggedIn={isLoggedIn}
initialUsers={suggestedUsers}
limit={4}
/>
<TrendingHashtagsCard />
</div>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
/**
* LinkPreviewCard
* Renders an OG/OpenGraph link preview card.
*
* Props:
* preview { url, title, description, image, site_name }
* onDismiss function|null — if provided, shows a dismiss ✕ button
* loading boolean — shows skeleton while fetching
*/
export default function LinkPreviewCard({ preview, onDismiss, loading = false }) {
if (loading) {
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden flex gap-3 p-3 animate-pulse">
<div className="w-20 h-20 rounded-lg bg-white/10 shrink-0" />
<div className="flex-1 min-w-0 flex flex-col gap-2 justify-center">
<div className="h-3 bg-white/10 rounded w-2/3" />
<div className="h-2.5 bg-white/10 rounded w-full" />
<div className="h-2.5 bg-white/10 rounded w-4/5" />
<div className="h-2 bg-white/[0.06] rounded w-1/3 mt-1" />
</div>
</div>
)
}
if (!preview?.url) return null
const domain = (() => {
try { return new URL(preview.url).hostname.replace(/^www\./, '') }
catch { return preview.site_name ?? '' }
})()
return (
<div className="relative rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden hover:border-white/[0.14] transition-colors group">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex gap-0 items-stretch"
onClick={(e) => e.stopPropagation()}
>
{/* Image */}
{preview.image ? (
<div className="w-24 shrink-0 bg-white/5">
<img
src={preview.image}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.parentElement.style.display = 'none' }}
/>
</div>
) : (
<div className="w-24 shrink-0 bg-white/[0.04] flex items-center justify-center text-slate-600">
<i className="fa-solid fa-link text-xl" />
</div>
)}
{/* Text */}
<div className="flex-1 min-w-0 px-3 py-2.5 flex flex-col justify-center gap-0.5">
{preview.site_name && (
<p className="text-[10px] uppercase tracking-wide text-sky-500/80 font-medium truncate">
{preview.site_name}
</p>
)}
{preview.title && (
<p className="text-sm font-semibold text-white/90 line-clamp-2 leading-snug">
{preview.title}
</p>
)}
{preview.description && (
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed mt-0.5">
{preview.description}
</p>
)}
<p className="text-[10px] text-slate-600 mt-1 truncate">
{domain}
</p>
</div>
</a>
{/* Dismiss button */}
{onDismiss && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDismiss() }}
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-black/60 hover:bg-black/80 text-slate-400 hover:text-white flex items-center justify-center transition-colors text-[10px]"
aria-label="Remove link preview"
>
<i className="fa-solid fa-xmark" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,139 @@
import React, { useState } from 'react'
import axios from 'axios'
/**
* PostActions: Like toggle, Comment toggle, Share menu, Report
*/
export default function PostActions({
post,
isLoggedIn,
onCommentToggle,
onReactionChange,
}) {
const [liked, setLiked] = useState(post.viewer_liked ?? false)
const [likeCount, setLikeCount] = useState(post.reactions_count ?? 0)
const [busy, setBusy] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [shareMsg, setShareMsg] = useState(null)
const handleLike = async () => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (busy) return
setBusy(true)
try {
if (liked) {
await axios.delete(`/api/posts/${post.id}/reactions/like`)
setLiked(false)
setLikeCount((c) => Math.max(0, c - 1))
onReactionChange?.({ liked: false, count: Math.max(0, likeCount - 1) })
} else {
await axios.post(`/api/posts/${post.id}/reactions`, { reaction: 'like' })
setLiked(true)
setLikeCount((c) => c + 1)
onReactionChange?.({ liked: true, count: likeCount + 1 })
}
} catch {
// ignore
} finally {
setBusy(false)
}
}
const handleCopyLink = () => {
const url = `${window.location.origin}/@${post.author.username}/posts?post=${post.id}`
navigator.clipboard?.writeText(url)
setShareMsg('Link copied!')
setTimeout(() => setShareMsg(null), 2000)
setMenuOpen(false)
}
const handleReport = async () => {
setMenuOpen(false)
const reason = window.prompt('Why are you reporting this post? (required)')
if (!reason?.trim()) return
try {
await axios.post(`/api/posts/${post.id}/report`, { reason: reason.trim() })
alert('Report submitted. Thank you!')
} catch (err) {
if (err.response?.data?.message) {
alert(err.response.data.message)
}
}
}
return (
<div className="flex items-center gap-1 text-slate-400 relative">
{/* Like */}
<button
onClick={handleLike}
disabled={busy}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
liked
? 'text-sky-400 bg-sky-500/10 hover:bg-sky-500/20'
: 'hover:bg-white/5 hover:text-slate-200'
}`}
title={liked ? 'Unlike' : 'Like'}
aria-label={liked ? 'Unlike this post' : 'Like this post'}
>
<i className={`fa-${liked ? 'solid' : 'regular'} fa-heart fa-fw text-xs`} />
<span className="tabular-nums">{likeCount > 0 && likeCount}</span>
</button>
{/* Comment toggle */}
<button
onClick={onCommentToggle}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
title="Comment"
aria-label="Show comments"
>
<i className="fa-regular fa-comment fa-fw text-xs" />
<span className="tabular-nums">{post.comments_count > 0 && post.comments_count}</span>
</button>
{/* Share / More menu */}
<div className="relative ml-auto">
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
aria-label="More options"
>
<i className="fa-solid fa-ellipsis-h fa-fw text-xs" />
</button>
{menuOpen && (
<div
className="absolute right-0 bottom-full mb-1 w-44 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden"
onBlur={() => setMenuOpen(false)}
>
<button
onClick={handleCopyLink}
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
>
<i className="fa-solid fa-link fa-fw opacity-60" />
Copy link
</button>
{isLoggedIn && (
<button
onClick={handleReport}
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
>
<i className="fa-solid fa-flag fa-fw opacity-60" />
Report post
</button>
)}
</div>
)}
</div>
{/* Share feedback toast */}
{shareMsg && (
<span className="absolute -top-8 right-0 text-xs bg-slate-800 text-white px-2 py-1 rounded shadow">
{shareMsg}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,404 @@
import React, { useState } from 'react'
import PostActions from './PostActions'
import PostComments from './PostComments'
import ArtworkCard from '../artwork/ArtworkCard'
import VisibilityPill from './VisibilityPill'
import LinkPreviewCard from './LinkPreviewCard'
function formatRelative(isoString) {
const diff = Date.now() - new Date(isoString).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
function formatScheduledDate(isoString) {
const d = new Date(isoString)
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
/** Render plain text body with #hashtag links */
function BodyWithHashtags({ html }) {
// The body may already be sanitised HTML from the server. We replace
// #tag patterns in text nodes (not inside existing anchor elements) with
// anchor links pointing to /tags/{tag}.
const processed = html.replace(
/(?<!["\w])#([A-Za-z][A-Za-z0-9_]{1,63})/g,
(_, tag) => `<a href="/tags/${tag.toLowerCase()}" class="text-sky-400 hover:underline">#${tag}</a>`,
)
return (
<div
className="text-sm text-slate-300 leading-relaxed [&_a]:text-sky-400 [&_a]:hover:underline [&_strong]:text-white/90 [&_em]:text-slate-200"
dangerouslySetInnerHTML={{ __html: processed }}
/>
)
}
/**
* PostCard
* Renders a single post in the feed. Supports text + artwork_share types.
*
* Props:
* post object (formatted by PostFeedService::formatPost)
* isLoggedIn boolean
* viewerUsername string|null
* onDelete function(postId)
* onUnsaved function(postId) — called when viewer unsaves this post
*/
export default function PostCard({ post, isLoggedIn = false, viewerUsername = null, onDelete, onUnsaved }) {
const [showComments, setShowComments] = useState(false)
const [postData, setPostData] = useState(post)
const [editMode, setEditMode] = useState(false)
const [editBody, setEditBody] = useState(post.body ?? '')
const [saving, setSaving] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [saveLoading, setSaveLoading] = useState(false)
const [analyticsOpen, setAnalyticsOpen] = useState(false)
const [analytics, setAnalytics] = useState(null)
const isOwn = viewerUsername && post.author.username === viewerUsername
const handleSaveEdit = async () => {
setSaving(true)
try {
const { default: axios } = await import('axios')
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
setPostData(data.post)
setEditMode(false)
} catch {
//
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!window.confirm('Delete this post?')) return
try {
const { default: axios } = await import('axios')
await axios.delete(`/api/posts/${post.id}`)
onDelete?.(post.id)
} catch {
//
}
}
const handlePin = async () => {
const { default: axios } = await import('axios')
try {
if (postData.is_pinned) {
await axios.delete(`/api/posts/${post.id}/pin`)
setPostData((p) => ({ ...p, is_pinned: false, pinned_order: null }))
} else {
const { data } = await axios.post(`/api/posts/${post.id}/pin`)
setPostData((p) => ({ ...p, is_pinned: true, pinned_order: data.pinned_order ?? 1 }))
}
} catch {
//
}
setMenuOpen(false)
}
const handleSaveToggle = async () => {
if (!isLoggedIn || saveLoading) return
setSaveLoading(true)
const { default: axios } = await import('axios')
try {
if (postData.viewer_saved) {
await axios.delete(`/api/posts/${post.id}/save`)
setPostData((p) => ({ ...p, viewer_saved: false, saves_count: Math.max(0, (p.saves_count ?? 1) - 1) }))
onUnsaved?.(post.id)
} else {
await axios.post(`/api/posts/${post.id}/save`)
setPostData((p) => ({ ...p, viewer_saved: true, saves_count: (p.saves_count ?? 0) + 1 }))
}
} catch {
//
} finally {
setSaveLoading(false)
}
}
const handleOpenAnalytics = async () => {
if (!isOwn) return
setAnalyticsOpen(true)
if (!analytics) {
const { default: axios } = await import('axios')
try {
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
setAnalytics(data)
} catch {
setAnalytics(null)
}
}
}
return (
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.025] hover:border-white/10 transition-colors">
{/* ── Pinned banner ──────────────────────────────────────────────── */}
{postData.is_pinned && (
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-slate-500">
<i className="fa-solid fa-thumbtack fa-fw text-sky-500/60" />
<span>Pinned post</span>
</div>
)}
{/* ── Scheduled banner (owner only) ─────────────────────────────── */}
{isOwn && postData.status === 'scheduled' && postData.publish_at && (
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-500/80">
<i className="fa-regular fa-clock fa-fw" />
<span>Scheduled for {formatScheduledDate(postData.publish_at)}</span>
</div>
)}
{/* ── Achievement badge ──────────────────────────────────────────── */}
{postData.type === 'achievement' && (
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-400/90">
<i className="fa-solid fa-trophy fa-fw text-amber-400" />
<span className="font-medium tracking-wide uppercase">Achievement unlocked</span>
</div>
)}
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 px-5 pt-4 pb-3">
<a href={`/@${post.author.username}`} className="shrink-0">
<img
src={post.author.avatar ?? '/images/avatar_default.webp'}
alt={post.author.name}
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all"
loading="lazy"
/>
</a>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/@${post.author.username}`}
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors"
>
{post.author.name || `@${post.author.username}`}
</a>
<span className="text-slate-600 text-xs">@{post.author.username}</span>
{post.meta?.tagged_users?.length > 0 && (
<span className="text-slate-600 text-xs flex items-center gap-1 flex-wrap">
<span className="text-slate-700">with</span>
{post.meta.tagged_users.map((u, i) => (
<React.Fragment key={u.id}>
{i > 0 && <span className="text-slate-700">,</span>}
<a href={`/@${u.username}`} className="text-sky-500/80 hover:text-sky-400 transition-colors">
@{u.username}
</a>
</React.Fragment>
))}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 mt-0.5">
<span>{formatRelative(post.created_at)}</span>
<span aria-hidden>·</span>
<VisibilityPill visibility={post.visibility} />
</div>
</div>
{/* Right-side actions: save + owner menu */}
<div className="flex items-center gap-1">
{/* Save / bookmark button */}
{isLoggedIn && !isOwn && (
<button
onClick={handleSaveToggle}
disabled={saveLoading}
title={postData.viewer_saved ? 'Remove bookmark' : 'Save post'}
className={`flex items-center justify-center w-8 h-8 rounded-lg transition-colors ${
postData.viewer_saved
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-slate-600 hover:text-slate-300 hover:bg-white/5'
}`}
>
<i className={`${postData.viewer_saved ? 'fa-solid' : 'fa-regular'} fa-bookmark fa-fw text-sm`} />
</button>
)}
{/* Analytics for owner */}
{isOwn && (
<button
onClick={handleOpenAnalytics}
title="Post analytics"
className="flex items-center justify-center w-8 h-8 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
>
<i className="fa-solid fa-chart-simple fa-fw text-sm" />
</button>
)}
{/* Owner menu */}
{isOwn && (
<div className="relative">
<button
onClick={() => setMenuOpen((v) => !v)}
className="text-slate-500 hover:text-slate-300 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors"
aria-label="Post options"
>
<i className="fa-solid fa-ellipsis-v fa-fw text-xs" />
</button>
{menuOpen && (
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
<button
onClick={() => { setEditMode(true); setMenuOpen(false) }}
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
>
<i className="fa-solid fa-pen fa-fw opacity-60" />
Edit
</button>
<button
onClick={handlePin}
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
>
<i className={`fa-solid fa-thumbtack fa-fw opacity-60 ${postData.is_pinned ? 'text-sky-400' : ''}`} />
{postData.is_pinned ? 'Unpin post' : 'Pin post'}
</button>
<button
onClick={() => { handleDelete(); setMenuOpen(false) }}
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
>
<i className="fa-solid fa-trash-can fa-fw opacity-60" />
Delete
</button>
</div>
)}
</div>
)}
</div>
</div>
{/* ── Body ─────────────────────────────────────────────────────────── */}
<div className="px-5 pb-3 space-y-3">
{editMode ? (
<div className="space-y-2">
<textarea
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
maxLength={2000}
rows={4}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none focus:outline-none focus:border-sky-500/50 transition-colors"
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-4 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-xs transition-colors disabled:opacity-40"
>
{saving ? 'Saving…' : 'Save'}
</button>
<button
onClick={() => setEditMode(false)}
className="px-4 py-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 text-xs transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
postData.body && <BodyWithHashtags html={postData.body} />
)}
{/* Hashtag pills */}
{!editMode && postData.hashtags && postData.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-0.5">
{postData.hashtags.map((tag) => (
<a
key={tag}
href={`/tags/${tag}`}
className="text-[11px] text-sky-500/80 hover:text-sky-400 hover:bg-sky-500/10 px-2 py-0.5 rounded-full border border-sky-500/20 hover:border-sky-500/40 transition-all"
>
#{tag}
</a>
))}
</div>
)}
{/* Link preview (stored OG data) */}
{!editMode && postData.meta?.link_preview?.url && (
<LinkPreviewCard preview={postData.meta.link_preview} />
)}
{/* Artwork share embed */}
{postData.type === 'artwork_share' && postData.artwork && (
<ArtworkCard artwork={postData.artwork} variant="embed" showActions={false} showStats={false} />
)}
</div>
{/* ── Actions ─────────────────────────────────────────────────────── */}
<div className="border-t border-white/[0.04] px-5 py-2">
<PostActions
post={postData}
isLoggedIn={isLoggedIn}
onCommentToggle={() => setShowComments((v) => !v)}
onReactionChange={({ liked, count }) =>
setPostData((p) => ({ ...p, viewer_liked: liked, reactions_count: count }))
}
/>
</div>
{/* ── Comments ────────────────────────────────────────────────────── */}
{showComments && (
<div className="border-t border-white/[0.04] px-5 py-4">
<PostComments
postId={post.id}
isLoggedIn={isLoggedIn}
isOwn={isOwn}
initialCount={postData.comments_count}
/>
</div>
)}
{/* ── Analytics modal ─────────────────────────────────────────────── */}
{analyticsOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
onClick={(e) => { if (e.target === e.currentTarget) setAnalyticsOpen(false) }}
>
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#0d1628] p-6 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white">Post Analytics</h3>
<button
onClick={() => setAnalyticsOpen(false)}
className="text-slate-500 hover:text-white w-7 h-7 flex items-center justify-center rounded-lg hover:bg-white/5 transition-colors"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
{!analytics ? (
<div className="flex justify-center py-8">
<i className="fa-solid fa-spinner fa-spin text-slate-500 text-xl" />
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Impressions', value: analytics.impressions?.toLocaleString() ?? '—', icon: 'fa-eye', color: 'text-sky-400' },
{ label: 'Saves', value: analytics.saves?.toLocaleString() ?? '—', icon: 'fa-bookmark', color: 'text-amber-400' },
{ label: 'Reactions', value: analytics.reactions?.toLocaleString() ?? '—', icon: 'fa-heart', color: 'text-rose-400' },
{ label: 'Engagement', value: analytics.engagement_rate ? `${analytics.engagement_rate}%` : '—', icon: 'fa-chart-simple', color: 'text-emerald-400' },
].map((item) => (
<div key={item.label} className="rounded-xl bg-white/[0.04] border border-white/[0.06] px-4 py-3">
<div className={`${item.color} text-sm mb-1`}>
<i className={`fa-solid ${item.icon} fa-fw`} />
</div>
<p className="text-xl font-bold text-white/90 tabular-nums leading-tight">{item.value}</p>
<p className="text-[11px] text-slate-500 mt-0.5">{item.label}</p>
</div>
))}
</div>
)}
</div>
</div>
)}
</article>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
export default function PostCardSkeleton() {
return (
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 animate-pulse space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-white/10 shrink-0" />
<div className="space-y-1.5 flex-1">
<div className="h-3 bg-white/10 rounded w-28" />
<div className="h-2 bg-white/6 rounded w-20" />
</div>
</div>
{/* Body */}
<div className="space-y-2">
<div className="h-3 bg-white/10 rounded w-full" />
<div className="h-3 bg-white/8 rounded w-4/5" />
<div className="h-3 bg-white/6 rounded w-2/3" />
</div>
{/* Artwork embed placeholder */}
<div className="rounded-xl bg-white/5 aspect-[16/9]" />
{/* Actions */}
<div className="flex gap-4 pt-1">
<div className="h-3 bg-white/8 rounded w-12" />
<div className="h-3 bg-white/6 rounded w-16" />
<div className="h-3 bg-white/6 rounded w-10" />
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
import LevelBadge from '../xp/LevelBadge'
function formatRelative(isoString) {
const diff = Date.now() - new Date(isoString).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
export default function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
const textareaRef = useRef(null)
const fetchComments = async (p = 1) => {
setLoading(true)
try {
const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } })
setComments((prev) => p === 1 ? data.data : [...prev, ...data.data])
setHasMore(data.meta.current_page < data.meta.last_page)
setPage(p)
} catch {
//
} finally {
setLoading(false)
setLoaded(true)
}
}
useEffect(() => {
fetchComments(1)
}, [postId])
const handleSubmit = async (e) => {
e.preventDefault()
if (!body.trim()) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body })
setComments((prev) => [...prev, data.comment])
setBody('')
} catch (err) {
setError(err.response?.data?.message ?? 'Failed to post comment.')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (commentId) => {
if (!window.confirm('Delete this comment?')) return
try {
await axios.delete(`/api/posts/${postId}/comments/${commentId}`)
setComments((prev) => prev.filter((c) => c.id !== commentId))
} catch {
//
}
}
const handleHighlight = async (comment) => {
try {
if (comment.is_highlighted) {
await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`)
setComments((prev) =>
prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c),
)
} else {
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`)
// Only one can be highlighted — clear others and set this one
setComments((prev) =>
prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })),
)
}
} catch {
//
}
}
// Highlighted comment always first (server also orders this way, but keep client in sync)
const sorted = [...comments].sort((a, b) =>
(b.is_highlighted ? 1 : 0) - (a.is_highlighted ? 1 : 0),
)
return (
<div className="space-y-3">
{/* Comment list */}
{!loaded && loading && (
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="flex gap-2 animate-pulse">
<div className="w-7 h-7 rounded-full bg-white/10 shrink-0" />
<div className="flex-1 space-y-1">
<div className="h-2.5 bg-white/10 rounded w-24" />
<div className="h-2 bg-white/6 rounded w-3/4" />
</div>
</div>
))}
</div>
)}
{loaded && sorted.map((c) => (
<div
key={c.id}
className={`flex gap-2 group ${c.is_highlighted ? 'rounded-xl bg-sky-500/5 border border-sky-500/15 px-3 py-2 -mx-3' : ''}`}
>
{/* Avatar */}
<a href={`/@${c.author.username}`} className="shrink-0">
<img
src={c.author.avatar ?? '/images/avatar_default.webp'}
alt={c.author.name}
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
loading="lazy"
/>
</a>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<a
href={`/@${c.author.username}`}
className="text-xs font-semibold text-white/80 hover:text-sky-400 transition-colors"
>
{c.author.name || `@${c.author.username}`}
</a>
<LevelBadge level={c.author.level} rank={c.author.rank} compact />
<span className="text-[10px] text-slate-600">{formatRelative(c.created_at)}</span>
{c.is_highlighted && (
<span className="text-[10px] text-sky-400 font-medium flex items-center gap-1">
<i className="fa-solid fa-star fa-xs" />
Highlighted by author
</span>
)}
</div>
<div
className="text-sm text-slate-300 mt-0.5 [&_a]:text-sky-400 [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: c.body }}
/>
</div>
{/* Actions: highlight (owner) + delete */}
<div className="flex items-start gap-1 opacity-0 group-hover:opacity-100 transition-all ml-1">
{isOwn && (
<button
onClick={() => handleHighlight(c)}
title={c.is_highlighted ? 'Remove highlight' : 'Highlight comment'}
className={`text-xs transition-colors px-1 py-0.5 rounded ${
c.is_highlighted
? 'text-sky-400 hover:text-slate-400'
: 'text-slate-600 hover:text-sky-400'
}`}
>
<i className={`${c.is_highlighted ? 'fa-solid' : 'fa-regular'} fa-star`} />
</button>
)}
{isLoggedIn && (
<button
onClick={() => handleDelete(c.id)}
className="text-slate-600 hover:text-rose-400 transition-all text-xs"
title="Delete comment"
>
<i className="fa-solid fa-trash-can" />
</button>
)}
</div>
</div>
))}
{loaded && hasMore && (
<button
onClick={() => fetchComments(page + 1)}
disabled={loading}
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
>
{loading ? 'Loading…' : 'Load more comments'}
</button>
)}
{/* Composer */}
{isLoggedIn ? (
<form onSubmit={handleSubmit} className="flex gap-2 mt-2">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a comment…"
maxLength={1000}
rows={1}
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}}
/>
<button
type="submit"
disabled={submitting || !body.trim()}
className="px-3 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm transition-colors"
>
{submitting ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-paper-plane" />}
</button>
</form>
) : (
<p className="text-xs text-slate-500 mt-2">
<a href="/login" className="text-sky-400 hover:underline">Sign in</a> to comment.
</p>
)}
{error && <p className="text-xs text-rose-400">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,455 @@
import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react'
import axios from 'axios'
import ShareArtworkModal from './ShareArtworkModal'
import LinkPreviewCard from './LinkPreviewCard'
import TagPeopleModal from './TagPeopleModal'
// Lazy-load the heavy emoji picker only when first opened
const EmojiPicker = lazy(() => import('../common/EmojiMartPicker'))
const VISIBILITY_OPTIONS = [
{ value: 'public', icon: 'fa-globe', label: 'Public' },
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
{ value: 'private', icon: 'fa-lock', label: 'Private' },
]
const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi
function extractFirstUrl(text) {
const m = text.match(URL_RE)
return m ? m[0].replace(/[.,;:!?)]+$/, '') : null
}
/**
* PostComposer
*
* Props:
* user object { id, username, name, avatar }
* onPosted function(newPost)
*/
export default function PostComposer({ user, onPosted }) {
const [expanded, setExpanded] = useState(false)
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('public')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [shareModal, setShareModal] = useState(false)
const [linkPreview, setLinkPreview] = useState(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewDismissed, setPreviewDismissed] = useState(false)
const [lastPreviewUrl, setLastPreviewUrl] = useState(null)
const [emojiOpen, setEmojiOpen] = useState(false)
const [emojiData, setEmojiData] = useState(null) // loaded lazily
const [tagModal, setTagModal] = useState(false)
const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }]
const [scheduleOpen, setScheduleOpen] = useState(false)
const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string
const textareaRef = useRef(null)
const debounceTimer = useRef(null)
const emojiWrapRef = useRef(null) // wraps button + popover for outside-click
// Load emoji-mart data lazily the first time the picker opens
const openEmojiPicker = useCallback(async () => {
if (!emojiData) {
const { default: data } = await import('@emoji-mart/data')
setEmojiData(data)
}
setEmojiOpen((v) => !v)
}, [emojiData])
// Close picker on outside click
useEffect(() => {
if (!emojiOpen) return
const handler = (e) => {
if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) {
setEmojiOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [emojiOpen])
// Insert emoji at current cursor position
const insertEmoji = useCallback((emoji) => {
const native = emoji.native ?? emoji.shortcodes ?? ''
const ta = textareaRef.current
if (!ta) {
setBody((b) => b + native)
return
}
const start = ta.selectionStart ?? body.length
const end = ta.selectionEnd ?? body.length
const next = body.slice(0, start) + native + body.slice(end)
setBody(next)
// Restore cursor after the inserted emoji
requestAnimationFrame(() => {
ta.focus()
const pos = start + native.length
ta.setSelectionRange(pos, pos)
})
setEmojiOpen(false)
}, [body])
const handleFocus = () => {
setExpanded(true)
setTimeout(() => textareaRef.current?.focus(), 50)
}
const fetchLinkPreview = useCallback(async (url) => {
setPreviewLoading(true)
try {
const { data } = await axios.get('/api/link-preview', { params: { url } })
if (data?.url) {
setLinkPreview(data)
}
} catch {
// silently ignore preview is optional
} finally {
setPreviewLoading(false)
}
}, [])
const handleBodyChange = (e) => {
const val = e.target.value
setBody(val)
// Detect URLs and auto-fetch preview (debounced)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
const url = extractFirstUrl(val)
if (!url || previewDismissed) return
if (url === lastPreviewUrl) return
setLastPreviewUrl(url)
setLinkPreview(null)
fetchLinkPreview(url)
}, 700)
}
const handleDismissPreview = () => {
setLinkPreview(null)
setPreviewDismissed(true)
}
const resetComposer = () => {
setBody('')
setExpanded(false)
setLinkPreview(null)
setPreviewLoading(false)
setPreviewDismissed(false)
setLastPreviewUrl(null)
setEmojiOpen(false)
setTaggedUsers([])
setTagModal(false)
setScheduleOpen(false)
setScheduledAt('')
}
const handleSubmit = async (e) => {
e?.preventDefault()
if (!body.trim()) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post('/api/posts', {
type: 'text',
visibility,
body,
link_preview: linkPreview ?? undefined,
tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined,
publish_at: scheduledAt || undefined,
})
onPosted?.(data.post)
resetComposer()
} catch (err) {
setError(err.response?.data?.message ?? 'Failed to post.')
} finally {
setSubmitting(false)
}
}
const handleShared = (newPost) => {
onPosted?.(newPost)
setShareModal(false)
}
const showPreview = (linkPreview || previewLoading) && !previewDismissed
return (
<>
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] px-5 py-4">
{/* Collapsed: click-to-expand placeholder */}
{!expanded ? (
<div
onClick={handleFocus}
className="flex items-center gap-3 cursor-text"
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleFocus()}
aria-label="Create a post"
>
<img
src={user.avatar ?? '/images/avatar_default.webp'}
alt={user.name}
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
loading="lazy"
/>
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
Share an update with your followers.
</span>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Textarea */}
<div className="flex gap-3">
<a href={`/@${user.username}`} className="shrink-0" tabIndex={-1}>
<img
src={user.avatar ?? '/images/avatar_default.webp'}
alt={user.name}
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all mt-0.5"
loading="lazy"
/>
</a>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{/* User identity byline */}
<div className="flex items-center gap-1.5">
<a
href={`/@${user.username}`}
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors leading-tight"
tabIndex={-1}
>
{user.name || `@${user.username}`}
</a>
<span className="text-xs text-slate-500 leading-tight">@{user.username}</span>
</div>
<textarea
ref={textareaRef}
value={body}
onChange={handleBodyChange}
maxLength={2000}
rows={3}
placeholder="Share an update with your followers."
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
</div>
</div>
{/* Tagged people pills */}
{taggedUsers.length > 0 && (
<div className="pl-12 flex flex-wrap gap-1.5 items-center">
<span className="text-xs text-slate-500">With:</span>
{taggedUsers.map((u) => (
<span key={u.id} className="flex items-center gap-1 px-2 py-0.5 bg-sky-500/10 border border-sky-500/20 rounded-full text-xs text-sky-400">
<img src={u.avatar_url ?? '/images/avatar_default.webp'} alt="" className="w-3.5 h-3.5 rounded-full object-cover" />
@{u.username}
<button
type="button"
onClick={() => setTaggedUsers((prev) => prev.filter((x) => x.id !== u.id))}
className="opacity-60 hover:opacity-100 ml-0.5"
>
<i className="fa-solid fa-xmark fa-xs" />
</button>
</span>
))}
</div>
)}
{/* Link preview */}
{showPreview && (
<div className="pl-12">
<LinkPreviewCard
preview={linkPreview}
loading={previewLoading && !linkPreview}
onDismiss={handleDismissPreview}
/>
</div>
)}
{/* Schedule date picker */}
{scheduleOpen && (
<div className="pl-12">
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
<div className="flex-1">
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
/>
<p className="text-[10px] text-slate-500 mt-1">
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</p>
</div>
{scheduledAt && (
<button
type="button"
onClick={() => setScheduledAt('')}
className="text-slate-500 hover:text-slate-300 transition-colors"
title="Clear"
>
<i className="fa-solid fa-xmark fa-sm" />
</button>
)}
</div>
</div>
)}
{/* Footer row */}
<div className="flex items-center gap-2 pl-12">
{/* Visibility selector */}
<div className="flex gap-1">
{VISIBILITY_OPTIONS.map((v) => (
<button
key={v.value}
type="button"
onClick={() => setVisibility(v.value)}
title={v.label}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
visibility === v.value
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className={`fa-solid ${v.icon} fa-fw`} />
{visibility === v.value && <span>{v.label}</span>}
</button>
))}
</div>
{/* Emoji picker trigger */}
<div ref={emojiWrapRef} className="relative">
<button
type="button"
onClick={openEmojiPicker}
title="Add emoji"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
emojiOpen
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-regular fa-face-smile fa-fw" />
</button>
{emojiOpen && (
<div className="absolute bottom-full mb-2 left-0 z-50 shadow-2xl">
<Suspense fallback={
<div className="w-[352px] h-[400px] rounded-2xl bg-[#10192e] border border-white/10 flex items-center justify-center text-slate-600">
<i className="fa-solid fa-spinner fa-spin text-xl" />
</div>
}>
{emojiData && (
<EmojiPicker
data={emojiData}
onEmojiSelect={insertEmoji}
theme="dark"
set="native"
previewPosition="none"
skinTonePosition="search"
navPosition="bottom"
perLine={9}
maxFrequentRows={2}
/>
)}
</Suspense>
</div>
)}
</div>
{/* Tag people button */}
<button
type="button"
onClick={() => setTagModal(true)}
title="Tag people"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
taggedUsers.length > 0
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-solid fa-user-tag fa-fw" />
{taggedUsers.length > 0 && <span>{taggedUsers.length}</span>}
</button>
{/* Schedule button */}
<button
type="button"
onClick={() => setScheduleOpen((v) => !v)}
title="Schedule post"
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
scheduleOpen || scheduledAt
? 'bg-violet-500/15 text-violet-400 border border-violet-500/30'
: 'text-slate-500 hover:text-white hover:bg-white/5'
}`}
>
<i className="fa-regular fa-clock fa-fw" />
{scheduledAt && <span className="max-w-[80px] truncate">{new Date(scheduledAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>}
</button>
<div className="ml-auto flex items-center gap-2">
{/* Share artwork button */}
<button
type="button"
onClick={() => setShareModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
title="Share an artwork"
>
<i className="fa-solid fa-share-nodes fa-fw" />
Share artwork
</button>
{/* Cancel */}
<button
type="button"
onClick={resetComposer}
className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
Cancel
</button>
{/* Post */}
<button
type="submit"
disabled={submitting || !body.trim()}
className={`px-4 py-1.5 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors ${
scheduledAt ? 'bg-violet-600 hover:bg-violet-500' : 'bg-sky-600 hover:bg-sky-500'
}`}
>
{submitting ? 'Posting…' : scheduledAt ? 'Schedule' : 'Post'}
</button>
</div>
</div>
{/* Char count */}
{body.length > 1800 && (
<p className="text-right text-[10px] text-amber-400/70 pr-1">{body.length}/2000</p>
)}
{error && (
<p className="text-xs text-rose-400">{error}</p>
)}
</form>
)}
</div>
{/* Share artwork modal */}
<ShareArtworkModal
isOpen={shareModal}
onClose={() => setShareModal(false)}
onShared={handleShared}
/>
{/* Tag people modal */}
<TagPeopleModal
isOpen={tagModal}
onClose={() => setTagModal(false)}
selected={taggedUsers}
onConfirm={(users) => { setTaggedUsers(users); setTagModal(false) }}
/>
</>
)
}

View File

@@ -0,0 +1,284 @@
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
const VISIBILITY_OPTIONS = [
{ value: 'public', icon: 'fa-globe', label: 'Public' },
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
{ value: 'private', icon: 'fa-lock', label: 'Private' },
]
function ArtworkResult({ artwork, onSelect }) {
return (
<button
onClick={() => onSelect(artwork)}
className="w-full flex gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors text-left group"
>
<div className="w-14 h-12 rounded-lg overflow-hidden shrink-0 bg-white/5">
{artwork.thumb_url ? (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600">
<i className="fa-solid fa-image" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate group-hover:text-sky-400 transition-colors">
{artwork.title}
</p>
<p className="text-xs text-slate-500 mt-0.5 truncate">
by {artwork.user?.name ?? artwork.author_name ?? 'Unknown'}
</p>
</div>
</button>
)
}
/**
* ShareArtworkModal
*
* Props:
* isOpen boolean
* onClose function
* onShared function(newPost)
* preselectedArtwork object|null (share from artwork page)
*/
export default function ShareArtworkModal({ isOpen, onClose, onShared, preselectedArtwork = null }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [searching, setSearching] = useState(false)
const [selected, setSelected] = useState(preselectedArtwork)
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('public')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const searchTimer = useRef(null)
const inputRef = useRef(null)
// Focus search on open
useEffect(() => {
if (isOpen && !preselectedArtwork) {
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [isOpen])
useEffect(() => {
setSelected(preselectedArtwork)
}, [preselectedArtwork])
const handleSearch = (q) => {
setQuery(q)
clearTimeout(searchTimer.current)
if (!q.trim()) { setResults([]); return }
searchTimer.current = setTimeout(async () => {
setSearching(true)
try {
const { data } = await axios.get('/api/search/artworks', {
params: { q, shareable: 1, per_page: 12 },
})
setResults(data.data ?? data.hits ?? [])
} catch {
setResults([])
} finally {
setSearching(false)
}
}, 300)
}
const handleSubmit = async () => {
if (!selected) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post(`/api/posts/share/artwork/${selected.id}`, {
body: body.trim() || null,
visibility,
})
onShared?.(data.post)
handleClose()
} catch (err) {
setError(err.response?.data?.errors?.artwork_id?.[0] ?? err.response?.data?.message ?? 'Failed to share.')
} finally {
setSubmitting(false)
}
}
const handleClose = () => {
setQuery('')
setResults([])
setSelected(preselectedArtwork)
setBody('')
setVisibility('public')
setError(null)
onClose?.()
}
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Share artwork"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Panel */}
<div className="relative w-full max-w-lg bg-[#0d1829] border border-white/10 rounded-2xl shadow-2xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<h2 className="text-sm font-semibold text-white/90">
<i className="fa-solid fa-share-nodes mr-2 text-sky-400 opacity-80" />
Share Artwork to Profile
</h2>
<button
onClick={handleClose}
className="text-slate-500 hover:text-white transition-colors"
aria-label="Close"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Artwork search / selected */}
{!selected ? (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
Search for an artwork
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Type artwork name…"
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
{searching && (
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
)}
</div>
{results.length > 0 && (
<div className="mt-2 rounded-xl border border-white/[0.06] bg-black/20 max-h-56 overflow-y-auto">
{results.map((a) => (
<ArtworkResult key={a.id} artwork={a} onSelect={(art) => { setSelected(art); setQuery(''); setResults([]) }} />
))}
</div>
)}
{query && !searching && results.length === 0 && (
<p className="text-xs text-slate-500 mt-2 text-center py-4">No artworks found.</p>
)}
</div>
) : (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">Selected Artwork</label>
<div className="flex gap-3 rounded-xl border border-white/[0.08] bg-black/20 p-3">
<div className="w-16 h-14 rounded-lg overflow-hidden shrink-0 bg-white/5">
<img
src={selected.thumb_url ?? selected.thumb ?? ''}
alt={selected.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90 truncate">{selected.title}</p>
<p className="text-xs text-slate-400 mt-0.5">
by {selected.user?.name ?? selected.author?.name ?? selected.author_name ?? 'Unknown'}
</p>
</div>
{!preselectedArtwork && (
<button
onClick={() => setSelected(null)}
className="text-slate-500 hover:text-white transition-colors self-start"
title="Change artwork"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
)}
</div>
</div>
)}
{/* Commentary */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
Commentary <span className="text-slate-600">(optional)</span>
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
maxLength={2000}
rows={3}
placeholder="Say something about this artwork…"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>
<p className="text-right text-[10px] text-slate-600 mt-0.5">{body.length}/2000</p>
</div>
{/* Visibility */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">Visibility</label>
<div className="flex gap-2">
{VISIBILITY_OPTIONS.map((v) => (
<button
key={v.value}
onClick={() => setVisibility(v.value)}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all border ${
visibility === v.value
? 'border-sky-500/50 bg-sky-500/10 text-sky-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:text-white'
}`}
>
<i className={`fa-solid ${v.icon} fa-fw`} />
{v.label}
</button>
))}
</div>
</div>
{error && (
<p className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-xl px-3 py-2">
<i className="fa-solid fa-circle-exclamation mr-1.5" />
{error}
</p>
)}
</div>
{/* Footer */}
<div className="flex gap-2 px-5 py-4 border-t border-white/[0.06]">
<button
onClick={handleClose}
className="flex-1 px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting || !selected}
className="flex-1 px-4 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{submitting
? <><i className="fa-solid fa-spinner fa-spin" /> Sharing</>
: <><i className="fa-solid fa-share-nodes" /> Share</>
}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,204 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import axios from 'axios'
/**
* TagPeopleModal
*
* Props:
* isOpen boolean
* onClose function()
* selected array [{ id, username, name, avatar_url }]
* onConfirm function(selectedArray)
*/
export default function TagPeopleModal({ isOpen, onClose, selected = [], onConfirm }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [staged, setStaged] = useState(selected)
const inputRef = useRef(null)
const debounce = useRef(null)
// Re-sync staged list when modal opens
useEffect(() => {
if (isOpen) {
setStaged(selected)
setQuery('')
setResults([])
setTimeout(() => inputRef.current?.focus(), 80)
}
}, [isOpen])
const search = useCallback(async (q) => {
if (q.length < 2) { setResults([]); return }
setLoading(true)
try {
const { data } = await axios.get('/api/search/users', { params: { q, per_page: 8 } })
setResults(data.data ?? [])
} catch {
setResults([])
} finally {
setLoading(false)
}
}, [])
const handleQueryChange = (e) => {
const val = e.target.value
setQuery(val)
clearTimeout(debounce.current)
debounce.current = setTimeout(() => search(val.replace(/^@/, '')), 300)
}
const isSelected = (user) => staged.some((u) => u.id === user.id)
const toggle = (user) => {
setStaged((prev) =>
isSelected(user)
? prev.filter((u) => u.id !== user.id)
: prev.length < 10 ? [...prev, user] : prev
)
}
const handleConfirm = () => {
onConfirm(staged)
onClose()
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0c1525] shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.07]">
<button
type="button"
onClick={onClose}
className="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
aria-label="Back"
>
<i className="fa-solid fa-arrow-left fa-sm" />
</button>
<h2 className="flex-1 text-center text-base font-semibold text-white/90 -ml-8">
Tag people
</h2>
<button
type="button"
onClick={handleConfirm}
className="text-sm font-medium text-sky-400 hover:text-sky-300 transition-colors"
>
Done
</button>
</div>
{/* Search bar */}
<div className="px-4 py-3 border-b border-white/[0.05]">
<div className="flex items-center gap-2 bg-white/[0.06] border border-white/[0.08] rounded-xl px-3 py-2">
<i className="fa-solid fa-magnifying-glass text-slate-500 text-xs fa-fw" />
<input
ref={inputRef}
type="text"
value={query}
onChange={handleQueryChange}
placeholder="Search users…"
className="flex-1 bg-transparent text-sm text-white placeholder-slate-600 focus:outline-none"
/>
{loading && <i className="fa-solid fa-spinner fa-spin text-slate-500 text-xs" />}
{query && !loading && (
<button type="button" onClick={() => { setQuery(''); setResults([]) }} className="text-slate-500 hover:text-white">
<i className="fa-solid fa-xmark text-xs" />
</button>
)}
</div>
</div>
{/* Selected chips */}
{staged.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 py-3 border-b border-white/[0.05]">
{staged.map((u) => (
<span
key={u.id}
className="flex items-center gap-1.5 px-2.5 py-1 bg-sky-500/15 border border-sky-500/30 rounded-full text-xs text-sky-400"
>
<img
src={u.avatar_url ?? '/images/avatar_default.webp'}
alt=""
className="w-4 h-4 rounded-full object-cover"
/>
@{u.username}
<button
type="button"
onClick={() => toggle(u)}
className="ml-0.5 opacity-60 hover:opacity-100 transition-opacity"
aria-label={`Remove @${u.username}`}
>
<i className="fa-solid fa-xmark fa-xs" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="overflow-y-auto flex-1">
{results.length === 0 && query.length >= 2 && !loading && (
<p className="px-5 py-8 text-center text-sm text-slate-600">No users found for "{query}"</p>
)}
{results.length === 0 && query.length < 2 && (
<p className="px-5 py-8 text-center text-sm text-slate-600">Type a name or @username to search</p>
)}
{results.map((u) => {
const checked = isSelected(u)
return (
<button
key={u.id}
type="button"
onClick={() => toggle(u)}
className={`w-full flex items-center gap-3 px-5 py-3 text-left transition-colors ${
checked ? 'bg-sky-500/10' : 'hover:bg-white/[0.04]'
}`}
>
<img
src={u.avatar_url ?? '/images/avatar_default.webp'}
alt={u.username}
className="w-10 h-10 rounded-full object-cover ring-1 ring-white/10 shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white/90 truncate">
{u.name || `@${u.username}`}
</p>
<p className="text-xs text-slate-500 truncate">@{u.username}</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${
checked
? 'bg-sky-500 border-sky-500 text-white'
: 'border-white/20'
}`}>
{checked && <i className="fa-solid fa-check fa-xs" />}
</div>
</button>
)
})}
</div>
{/* Footer confirm */}
<div className="px-5 py-3 border-t border-white/[0.07]">
<button
type="button"
onClick={handleConfirm}
className="w-full py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors disabled:opacity-40"
>
{staged.length === 0
? 'Continue without tagging'
: `Tag ${staged.length} ${staged.length === 1 ? 'person' : 'people'}`}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
const ICONS = {
public: { icon: 'fa-globe', label: 'Public', cls: 'text-slate-500' },
followers: { icon: 'fa-user-friends', label: 'Followers', cls: 'text-sky-500/70' },
private: { icon: 'fa-lock', label: 'Private', cls: 'text-amber-500/70' },
}
export default function VisibilityPill({ visibility, showLabel = false }) {
const v = ICONS[visibility] ?? ICONS.public
return (
<span
className={`inline-flex items-center gap-1 text-xs ${v.cls}`}
title={v.label}
aria-label={`Visibility: ${v.label}`}
>
<i className={`fa-solid ${v.icon} fa-fw`} />
{showLabel && <span>{v.label}</span>}
</span>
)
}

View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react'
import NovaSelect from '../ui/NovaSelect'
const actions = [
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
{ value: 'unpublish', label: 'Unpublish (draft)', icon: 'fa-eye-slash', danger: false },
{ value: 'archive', label: 'Archive', icon: 'fa-box-archive', danger: false },
{ value: 'unarchive', label: 'Unarchive', icon: 'fa-rotate-left', danger: false },
{ value: 'delete', label: 'Delete', icon: 'fa-trash', danger: true },
{ value: 'change_category', label: 'Change category', icon: 'fa-folder', danger: false },
{ value: 'add_tags', label: 'Add tags', icon: 'fa-tag', danger: false },
{ value: 'remove_tags', label: 'Remove tags', icon: 'fa-tags', danger: false },
]
export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
const [action, setAction] = useState('')
if (count === 0) return null
const handleExecute = () => {
if (!action) return
onExecute(action)
setAction('')
}
const selectedAction = actions.find((a) => a.value === action)
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-nova-900/95 backdrop-blur-xl border-t border-white/10 px-4 py-3 shadow-xl shadow-black/20">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent/20 text-accent text-sm font-bold">
{count}
</span>
<span className="text-sm text-slate-300">
{count === 1 ? 'artwork' : 'artworks'} selected
</span>
</div>
<div className="flex items-center gap-2">
<div className="min-w-[180px]">
<NovaSelect
options={actions.map((a) => ({ value: a.value, label: a.label }))}
value={action || null}
onChange={(val) => setAction(val ?? '')}
placeholder="Choose action…"
searchable={false}
/>
</div>
<button
onClick={handleExecute}
disabled={!action}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
action
? selectedAction?.danger
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-accent hover:bg-accent/90 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Apply
</button>
<button
onClick={onClearSelection}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Clear
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react'
/**
* Modal for choosing a category in bulk.
*
* Props:
* - open: boolean
* - categories: array of content types with nested categories
* - onClose: () => void
* - onConfirm: (categoryId: number) => void
*/
export default function BulkCategoryModal({ open, categories = [], onClose, onConfirm }) {
const [selectedId, setSelectedId] = useState('')
useEffect(() => {
if (open) setSelectedId('')
}, [open])
const handleConfirm = () => {
if (!selectedId) return
onConfirm(Number(selectedId))
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Enter' && selectedId) handleConfirm()
}
if (!open) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<i className="fa-solid fa-folder text-accent" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Change category</h3>
<p className="text-sm text-slate-400">Choose a category to assign to the selected artworks.</p>
</div>
</div>
{/* Category select */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="" className="bg-nova-900">Select a category</option>
{categories.map((ct) => (
<optgroup key={ct.id} label={ct.name}>
{ct.categories?.map((cat) => (
<React.Fragment key={cat.id}>
<option value={cat.id} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.id} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))}
</select>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedId}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
selectedId
? 'bg-accent hover:bg-accent/90 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Apply category
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
/**
* Modal for picking tags to add/remove in bulk.
*
* Props:
* - open: boolean
* - mode: 'add' | 'remove'
* - onClose: () => void
* - onConfirm: (tagIds: number[]) => void
*/
export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [selected, setSelected] = useState([]) // { id, name }
const [loading, setLoading] = useState(false)
const inputRef = useRef(null)
const searchTimer = useRef(null)
// Focus input when modal opens
useEffect(() => {
if (open) {
setQuery('')
setResults([])
setSelected([])
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
// Debounced tag search
const searchTags = useCallback(async (q) => {
setLoading(true)
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
const params = new URLSearchParams()
if (q) params.set('q', q)
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': csrfToken },
credentials: 'same-origin',
})
const data = await res.json()
setResults(data || [])
} catch {
setResults([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => searchTags(query), 250)
return () => clearTimeout(searchTimer.current)
}, [query, open, searchTags])
const toggleTag = (tag) => {
setSelected((prev) => {
const exists = prev.find((t) => t.id === tag.id)
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name }]
})
}
const removeSelected = (id) => {
setSelected((prev) => prev.filter((t) => t.id !== id))
}
const handleConfirm = () => {
if (selected.length === 0) return
onConfirm(selected.map((t) => t.id))
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
}
if (!open) return null
const isAdd = mode === 'add'
const title = isAdd ? 'Add tags' : 'Remove tags'
const accentColor = isAdd ? 'accent' : 'amber-500'
const icon = isAdd ? 'fa-tag' : 'fa-tags'
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-lg bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full ${isAdd ? 'bg-accent/20' : 'bg-amber-500/20'} flex items-center justify-center flex-shrink-0`}>
<i className={`fa-solid ${icon} ${isAdd ? 'text-accent' : 'text-amber-400'}`} />
</div>
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
<p className="text-sm text-slate-400">
{isAdd ? 'Search and select tags to add to the selected artworks.' : 'Search and select tags to remove from the selected artworks.'}
</p>
</div>
</div>
{/* Search input */}
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
style={{ paddingLeft: '2.5rem' }}
placeholder="Search tags…"
/>
</div>
{/* Selected tags chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((tag) => (
<span
key={tag.id}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium ${
isAdd ? 'bg-accent/20 text-accent' : 'bg-amber-500/20 text-amber-300'
}`}
>
{tag.name}
<button
onClick={() => removeSelected(tag.id)}
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="max-h-48 overflow-y-auto space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
{loading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && results.length === 0 && (
<p className="text-center text-sm text-slate-500 py-4">
{query ? 'No tags found' : 'Type to search tags'}
</p>
)}
{!loading &&
results.map((tag) => {
const isSelected = selected.some((t) => t.id === tag.id)
const recentClicks = Number(tag.recent_clicks || 0)
const usageCount = Number(tag.usage_count || 0)
return (
<button
key={tag.id}
onClick={() => toggleTag(tag)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
isSelected
? isAdd
? 'bg-accent/10 text-accent'
: 'bg-amber-500/10 text-amber-300'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<i
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
isSelected ? (isAdd ? 'text-accent' : 'text-amber-400') : 'text-slate-500'
}`}
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
</span>
</button>
)
})}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selected.length === 0}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
selected.length > 0
? isAdd
? 'bg-accent hover:bg-accent/90 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
{isAdd ? 'Add' : 'Remove'} {selected.length > 0 ? `${selected.length} tag${selected.length !== 1 ? 's' : ''}` : 'tags'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import BulkTagModal from './BulkTagModal'
describe('BulkTagModal', () => {
beforeEach(() => {
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
global.fetch = vi.fn(async (url) => {
const requestUrl = String(url)
if (requestUrl.includes('?q=high')) {
return {
json: async () => ([
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
]),
}
}
return {
json: async () => ([
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
]),
}
})
})
afterEach(() => {
vi.restoreAllMocks()
document.head.innerHTML = ''
})
it('shows recent click momentum for initial results', async () => {
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
await waitFor(() => {
expect(screen.getByText('Popular Pick')).not.toBeNull()
})
expect(screen.getByText('9 recent clicks')).not.toBeNull()
})
it('returns selected tag ids and shows recent click momentum in search results', async () => {
const onConfirm = vi.fn()
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
const input = screen.getByPlaceholderText('Search tags…')
await userEvent.type(input, 'high')
await waitFor(() => {
expect(screen.getByText('18 recent clicks')).not.toBeNull()
})
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
expect(onConfirm).toHaveBeenCalledWith([2])
})
})

View File

@@ -0,0 +1,76 @@
import React, { useState, useRef, useEffect } from 'react'
export default function ConfirmDangerModal({ open, onClose, onConfirm, title, message, confirmText = 'DELETE' }) {
const [input, setInput] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (open) {
setInput('')
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
if (!open) return null
const canConfirm = input === confirmText
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Enter' && canConfirm) onConfirm()
}
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-nova-900 border border-red-500/30 rounded-2xl shadow-2xl shadow-red-500/10 p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<i className="fa-solid fa-triangle-exclamation text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
<p className="text-sm text-slate-400 mt-1">{message}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">
Type <span className="text-red-400 font-mono">{confirmText}</span> to confirm
</label>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500/50 font-mono"
placeholder={confirmText}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={!canConfirm}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
canConfirm
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Delete permanently
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
/**
* @deprecated Use the unified Checkbox from Components/ui instead.
* This shim exists only for backward compatibility.
*/
export { default } from '../ui/Checkbox'

View File

@@ -0,0 +1,113 @@
import React, { useMemo } from 'react'
import DateRangePicker from '../ui/DateRangePicker'
import NovaSelect from '../ui/NovaSelect'
const statusOptions = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
]
const performanceOptions = [
{ value: 'rising', label: 'Rising (hot)' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
]
export default function StudioFilters({
open,
onClose,
filters,
onFilterChange,
categories = [],
}) {
if (!open) return null
const handleChange = (key, value) => {
onFilterChange({ ...filters, [key]: value })
}
const categoryOptions = useMemo(() => {
const opts = []
categories.forEach((ct) => {
ct.categories?.forEach((cat) => {
opts.push({ value: cat.slug, label: cat.name, group: ct.name })
cat.children?.forEach((ch) => {
opts.push({ value: ch.slug, label: ch.name, group: ct.name })
})
})
})
return opts
}, [categories])
return (
<>
{/* Mobile backdrop */}
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Filter panel */}
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto nova-scrollbar lg:static lg:mb-4">
<div className="flex items-center justify-between lg:hidden">
<h3 className="text-base font-semibold text-white">Filters</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
<i className="fa-solid fa-xmark text-lg" />
</button>
</div>
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
{/* Status */}
<NovaSelect
label="Status"
options={statusOptions}
value={filters.status || null}
onChange={(val) => handleChange('status', val ?? '')}
placeholder="All statuses"
searchable={false}
clearable
/>
{/* Category */}
<NovaSelect
label="Category"
options={categoryOptions}
value={filters.category || null}
onChange={(val) => handleChange('category', val ?? '')}
placeholder="All categories"
clearable
/>
{/* Performance */}
<NovaSelect
label="Performance"
options={performanceOptions}
value={filters.performance || null}
onChange={(val) => handleChange('performance', val ?? '')}
placeholder="All performance"
searchable={false}
clearable
/>
{/* Date range */}
<DateRangePicker
label="Date range"
start={filters.date_from || ''}
end={filters.date_to || ''}
onChange={({ start, end }) => {
onFilterChange({ ...filters, date_from: start, date_to: end })
}}
clearable
placeholder="Any date"
/>
{/* Clear */}
<button
onClick={() => onFilterChange({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })}
className="w-full text-center text-xs text-slate-500 hover:text-white transition-colors py-2"
>
Clear all filters
</button>
</div>
</>
)
}

View File

@@ -0,0 +1,101 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
if (!art.is_public) return 'draft'
return 'published'
}
function statItem(icon, value) {
return (
<span className="flex items-center gap-1 text-xs text-slate-400">
<span>{icon}</span>
<span>{typeof value === 'number' ? value.toLocaleString() : value}</span>
</span>
)
}
export default function StudioGridCard({ artwork, selected, onSelect, onAction }) {
const status = getStatus(artwork)
return (
<div
className={`group relative bg-nova-900/60 border rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/5 ${
selected ? 'border-accent/60 ring-2 ring-accent/20' : 'border-white/10 hover:border-white/20'
}`}
>
{/* Selection checkbox */}
<div className="absolute top-3 left-3 z-10">
<Checkbox
checked={selected}
onChange={() => onSelect(artwork.id)}
aria-label={`Select ${artwork.title}`}
/>
</div>
{/* Thumbnail */}
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
{/* Hover actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-3 right-3 flex gap-1.5">
<ActionBtn icon="fa-eye" title="View public" onClick={() => window.open(`/artworks/${artwork.slug}`, '_blank')} />
<ActionBtn icon="fa-pen" title="Edit" onClick={() => onAction('edit', artwork)} />
{status !== 'archived' ? (
<ActionBtn icon="fa-box-archive" title="Archive" onClick={() => onAction('archive', artwork)} />
) : (
<ActionBtn icon="fa-rotate-left" title="Unarchive" onClick={() => onAction('unarchive', artwork)} />
)}
<ActionBtn icon="fa-trash" title="Delete" onClick={() => onAction('delete', artwork)} danger />
</div>
</div>
</div>
{/* Info */}
<div className="p-3 space-y-2">
<h3 className="text-sm font-semibold text-white truncate" title={artwork.title}>
{artwork.title}
</h3>
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={status} />
<RisingBadge heatScore={artwork.heat_score} rankingScore={artwork.ranking_score} />
</div>
<div className="flex flex-wrap items-center gap-3">
{statItem('👁', artwork.views)}
{statItem('❤️', artwork.favourites)}
{statItem('🔗', artwork.shares)}
{statItem('💬', artwork.comments)}
{statItem('⬇', artwork.downloads)}
</div>
</div>
</div>
)
}
function ActionBtn({ icon, title, onClick, danger }) {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick() }}
title={title}
className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all backdrop-blur-sm ${
danger
? 'bg-red-500/20 text-red-400 hover:bg-red-500/40'
: 'bg-white/10 text-white hover:bg-white/20'
}`}
aria-label={title}
>
<i className={`fa-solid ${icon}`} />
</button>
)
}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
import Checkbox from '../ui/Checkbox'
function getStatus(art) {
if (art.deleted_at) return 'archived'
if (!art.is_public) return 'draft'
return 'published'
}
export default function StudioTable({ artworks, selectedIds, onSelect, onSelectAll, onAction, onSort, currentSort }) {
const allSelected = artworks.length > 0 && artworks.every((a) => selectedIds.includes(a.id))
const columns = [
{ key: 'title', label: 'Title', sortable: false },
{ key: 'status', label: 'Status', sortable: false },
{ key: 'category', label: 'Category', sortable: false },
{ key: 'created_at', label: 'Created', sortable: true, sort: 'created_at' },
{ key: 'views', label: 'Views', sortable: true, sort: 'views' },
{ key: 'favourites', label: 'Favs', sortable: true, sort: 'favorites_count' },
{ key: 'shares', label: 'Shares', sortable: true, sort: 'shares_count' },
{ key: 'comments', label: 'Comments', sortable: true, sort: 'comments_count' },
{ key: 'downloads', label: 'Downloads', sortable: true, sort: 'downloads' },
{ key: 'ranking_score', label: 'Rank', sortable: true, sort: 'ranking_score' },
{ key: 'heat_score', label: 'Heat', sortable: true, sort: 'heat_score' },
]
const handleSort = (col) => {
if (!col.sortable) return
const field = col.sort
const [currentField, currentDir] = (currentSort || '').split(':')
const dir = currentField === field && currentDir === 'desc' ? 'asc' : 'desc'
onSort(`${field}:${dir}`)
}
const getSortIcon = (col) => {
if (!col.sortable) return null
const [currentField, currentDir] = (currentSort || '').split(':')
if (currentField !== col.sort) return <i className="fa-solid fa-sort text-slate-600 ml-1 text-[10px]" />
return <i className={`fa-solid fa-sort-${currentDir === 'asc' ? 'up' : 'down'} text-accent ml-1 text-[10px]`} />
}
return (
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-nova-900/40">
<table className="w-full text-sm text-left">
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
<tr>
<th className="p-3 w-10">
<Checkbox
checked={allSelected}
onChange={onSelectAll}
aria-label="Select all artworks"
/>
</th>
<th className="p-3 w-12"></th>
{columns.map((col) => (
<th
key={col.key}
className={`p-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap ${col.sortable ? 'cursor-pointer hover:text-white select-none' : ''}`}
onClick={() => handleSort(col)}
>
{col.label}
{getSortIcon(col)}
</th>
))}
<th className="p-3 w-20 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{artworks.map((art) => (
<tr
key={art.id}
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
>
<td className="p-3">
<Checkbox
checked={selectedIds.includes(art.id)}
onChange={() => onSelect(art.id)}
aria-label={`Select ${art.title}`}
/>
</td>
<td className="p-3">
<img
src={art.thumb_url}
alt=""
className="w-10 h-10 rounded-lg object-cover bg-nova-800"
loading="lazy"
/>
</td>
<td className="p-3">
<span className="text-white font-medium truncate block max-w-[200px]" title={art.title}>{art.title}</span>
</td>
<td className="p-3"><StatusBadge status={getStatus(art)} /></td>
<td className="p-3 text-slate-400">{art.category || '—'}</td>
<td className="p-3 text-slate-400 whitespace-nowrap">{art.created_at ? new Date(art.created_at).toLocaleDateString() : '—'}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.comments.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
<td className="p-3">
<RisingBadge heatScore={0} rankingScore={art.ranking_score} />
<span className="text-slate-400 text-xs">{art.ranking_score.toFixed(1)}</span>
</td>
<td className="p-3">
<RisingBadge heatScore={art.heat_score} rankingScore={0} />
<span className="text-slate-400 text-xs">{art.heat_score.toFixed(1)}</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<button
onClick={() => onAction('edit', art)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-slate-400 hover:text-white hover:bg-white/10 transition-all"
title="Edit"
aria-label={`Edit ${art.title}`}
>
<i className="fa-solid fa-pen" />
</button>
<button
onClick={() => onAction('delete', art)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-all"
title="Delete"
aria-label={`Delete ${art.title}`}
>
<i className="fa-solid fa-trash" />
</button>
</div>
</td>
</tr>
))}
{artworks.length === 0 && (
<tr>
<td colSpan={14} className="p-12 text-center text-slate-500">
No artworks found
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import NovaSelect from '../ui/NovaSelect'
const sortOptions = [
{ value: 'created_at:desc', label: 'Latest' },
{ value: 'ranking_score:desc', label: 'Trending' },
{ value: 'heat_score:desc', label: 'Rising' },
{ value: 'views:desc', label: 'Most viewed' },
{ value: 'favorites_count:desc', label: 'Most favourited' },
{ value: 'shares_count:desc', label: 'Most shared' },
{ value: 'downloads:desc', label: 'Most downloaded' },
]
export default function StudioToolbar({
search,
onSearchChange,
sort,
onSortChange,
viewMode,
onViewModeChange,
onFilterToggle,
selectedCount,
onUpload,
}) {
return (
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search title or tags…"
style={{ paddingLeft: '3rem' }}
className="w-full pr-4 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
/>
</div>
{/* Sort */}
<div className="min-w-[160px]">
<NovaSelect
options={sortOptions}
value={sort}
onChange={onSortChange}
searchable={false}
/>
</div>
{/* Filter toggle */}
<button
onClick={onFilterToggle}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
aria-label="Toggle filters"
>
<i className="fa-solid fa-filter" />
<span className="hidden sm:inline">Filters</span>
</button>
{/* View toggle */}
<div className="flex items-center bg-nova-900/60 border border-white/10 rounded-xl overflow-hidden">
<button
onClick={() => onViewModeChange('grid')}
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'grid' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
aria-label="Grid view"
>
<i className="fa-solid fa-table-cells" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'list' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
aria-label="List view"
>
<i className="fa-solid fa-list" />
</button>
</div>
{/* Upload */}
<a
href="/upload"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25"
>
<i className="fa-solid fa-cloud-arrow-up" />
<span className="hidden sm:inline">Upload</span>
</a>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import SearchBar from '../Search/SearchBar'
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
export default function Topbar({ user = null }) {
const [menuOpen, setMenuOpen] = useState(false)
return (
<header className="fixed top-0 left-0 right-0 h-16 bg-neutral-900 border-b border-neutral-800 z-50">
<div className="h-full px-5 flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button aria-label="Toggle menu" className="md:hidden text-neutral-200 hover:text-sky-400">
<i className="fas fa-bars text-lg" aria-hidden="true"></i>
</button>
<a href="/" className="text-sky-400 font-semibold text-xl">Skinbase</a>
</div>
<div className="hidden md:block flex-1 max-w-xl">
<SearchBar />
</div>
<div className="flex items-center gap-3 sm:gap-4">
<a href="/groups" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Groups</a>
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
{user ? (
<div className="relative">
<button
onClick={() => setMenuOpen(o => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1 hover:bg-white/5 transition-colors"
aria-label="User menu"
>
<img
src={user.avatarUrl || DEFAULT_AVATAR}
alt=""
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
onError={(e) => { e.currentTarget.src = DEFAULT_AVATAR }}
/>
<span className="hidden sm:inline text-sm text-white/90">{user.displayName}</span>
<i className="fas fa-chevron-down text-xs text-white/50" aria-hidden="true"></i>
</button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-lg bg-neutral-800 border border-neutral-700 shadow-xl overflow-hidden z-50">
<a href={`/@${user.username}`} className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-white/5">
<img
src={user.avatarUrl || DEFAULT_AVATAR}
alt=""
className="w-6 h-6 rounded-full object-cover"
onError={(e) => { e.currentTarget.src = DEFAULT_AVATAR }}
/>
<span className="truncate">{user.displayName}</span>
</a>
<div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
onClick={(e) => { e.preventDefault(); document.getElementById('logout-form')?.submit() }}>
Sign out
</a>
</div>
)}
</div>
) : (
<a href="/login" className="text-sm text-neutral-300 hover:text-sky-400 transition-colors">
<i className="fas fa-user" aria-hidden="true"></i>
</a>
)}
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
const TYPE_TONES = {
Uploads: 'border-amber-400/40 bg-amber-500/10 text-amber-100',
Engagement: 'border-rose-400/40 bg-rose-500/10 text-rose-100',
Social: 'border-sky-400/40 bg-sky-500/10 text-sky-100',
Stories: 'border-emerald-400/40 bg-emerald-500/10 text-emerald-100',
Milestones: 'border-violet-400/40 bg-violet-500/10 text-violet-100',
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function AchievementBadge({ achievement, compact = false, className = '' }) {
const tone = TYPE_TONES[achievement?.type] || TYPE_TONES.Milestones
const unlocked = Boolean(achievement?.unlocked)
return (
<span
className={cx(
'inline-flex items-center gap-2 rounded-full border px-2.5 py-1 font-semibold tracking-[0.08em]',
compact ? 'text-[10px] uppercase' : 'text-[11px] uppercase',
unlocked ? tone : 'border-white/10 bg-white/5 text-slate-300',
className,
)}
>
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'} text-[0.9em]`} />
<span>{achievement?.type || 'Achievement'}</span>
</span>
)
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import AchievementBadge from './AchievementBadge'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function AchievementCard({ achievement, className = '' }) {
const unlocked = Boolean(achievement?.unlocked)
const progress = Number(achievement?.progress || 0)
const target = Number(achievement?.condition_value || 0)
const percent = Math.max(0, Math.min(100, Number(achievement?.progress_percent || 0)))
return (
<article
className={cx(
'rounded-2xl border p-4 shadow-lg transition',
unlocked
? 'border-emerald-400/25 bg-[linear-gradient(180deg,rgba(16,185,129,0.08),rgba(15,23,42,0.72))]'
: 'border-white/10 bg-white/[0.04]',
className,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<AchievementBadge achievement={achievement} compact />
<h3 className="mt-3 text-base font-semibold text-white">{achievement?.name}</h3>
<p className="mt-1 text-sm text-slate-300">{achievement?.description}</p>
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-white/90">
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'}`} />
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{achievement?.xp_reward || 0} XP reward</span>
{unlocked ? <span>Unlocked</span> : <span>{progress} / {target}</span>}
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10 ring-1 ring-white/10">
<div
className={cx('h-full rounded-full transition-[width] duration-500', unlocked ? 'bg-emerald-400' : 'bg-sky-400')}
style={{ width: `${unlocked ? 100 : percent}%` }}
/>
</div>
{achievement?.unlocked_at ? (
<p className="mt-3 text-xs text-slate-500">
Unlocked {new Date(achievement.unlocked_at).toLocaleDateString()}
</p>
) : null}
</article>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import AchievementCard from './AchievementCard'
export default function AchievementsList({ unlocked = [], locked = [], limitLocked }) {
const visibleLocked = typeof limitLocked === 'number' ? locked.slice(0, limitLocked) : locked
return (
<div className="space-y-8">
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Unlocked</h2>
<span className="text-xs text-slate-500">{unlocked.length} earned</span>
</div>
{unlocked.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-8 text-sm text-slate-400">
No achievements unlocked yet.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{unlocked.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
)}
</section>
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">In Progress</h2>
<span className="text-xs text-slate-500">{locked.length} still locked</span>
</div>
{visibleLocked.length === 0 ? null : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleLocked.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react'
export default function AdminUploadQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/uploads/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPending()
}, [])
const moderate = async (id, action) => {
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
}
}
return (
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
Refresh
</button>
</div>
{loading ? <p role="status" className="text-sm text-white/70">Loading</p> : null}
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
</div>
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Moderation note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
aria-label={`Approve upload ${item.id}`}
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
aria-label={`Reject upload ${item.id}`}
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,112 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AdminUploadQueue from './AdminUploadQueue'
function makePendingUpload(overrides = {}) {
return {
id: '11111111-1111-1111-1111-111111111111',
title: 'Neon Skyline',
type: 'image',
preview_path: 'tmp/drafts/1111/preview.webp',
...overrides,
}
}
describe('AdminUploadQueue', () => {
beforeEach(() => {
window.axios = {
get: vi.fn(),
post: vi.fn(),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders pending list with accessible controls', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
render(<AdminUploadQueue />)
expect(screen.getByRole('heading', { name: 'Pending Upload Moderation' })).not.toBeNull()
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
})
it('approves upload and removes it from queue', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
await waitFor(() => {
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
})
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/approve`, { note: '' })
})
it('rejects upload with note and removes it from queue', async () => {
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.type(
within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` }),
'Needs better quality screenshots'
)
await userEvent.click(within(item).getByRole('button', { name: `Reject upload ${upload.id}` }))
await waitFor(() => {
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
})
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/reject`, {
note: 'Needs better quality screenshots',
})
})
it('shows API failure message and keeps item when moderation action fails', async () => {
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockRejectedValueOnce({
response: { data: { message: 'Moderation API failed.' } },
})
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Moderation API failed.')
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
})
it('shows empty state when no pending uploads exist', async () => {
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
render(<AdminUploadQueue />)
const empty = await screen.findByText('No pending uploads.')
expect(empty).not.toBeNull()
expect(screen.queryAllByRole('listitem').length).toBe(0)
})
})

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react'
export default function AdminUsernameQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/usernames/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load username moderation queue.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPending()
}, [])
const moderate = async (id, action) => {
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/usernames/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} username request.`)
}
}
return (
<section aria-label="Username moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Username Approvals</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
Refresh
</button>
</div>
{loading ? <p role="status" className="text-sm text-white/70">Loading</p> : null}
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending username requests.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.requested_username}</div>
<div className="mt-1 text-xs text-white/65">Request #{item.id} · {item.context}</div>
{item.similar_to ? <div className="mt-1 text-xs text-amber-200">Similar to reserved: {item.similar_to}</div> : null}
</div>
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for request ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Review note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,463 @@
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ArtworkShareButton from './ArtworkShareButton'
function formatCount(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${n}`
}
/* ── SVG Icons ─────────────────────────────────────────────────────────────── */
function HeartIcon({ filled }) {
return filled ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
)
}
function BookmarkIcon({ filled }) {
return filled ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
</svg>
)
}
function CloudDownIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
)
}
function DownloadArrowIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
)
}
/* ShareIcon removed — now provided by ArtworkShareButton */
function FlagIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
</svg>
)
}
/* ── Report Modal ──────────────────────────────────────────────────────────── */
const REPORT_REASONS = [
'Inappropriate content',
'Copyright violation',
'Spam or misleading',
'Offensive or abusive',
]
function ReportModal({ open, onClose, onSubmit, submitting }) {
const [selected, setSelected] = useState('')
const [details, setDetails] = useState('')
const backdropRef = useRef(null)
const inputRef = useRef(null)
// Reset & focus when opening
useEffect(() => {
if (open) {
setSelected('')
setDetails('')
const t = setTimeout(() => inputRef.current?.focus(), 80)
return () => clearTimeout(t)
}
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onClose])
if (!open) return null
const trimmedDetails = details.trim()
const canSubmit = selected.length > 0 && trimmedDetails.length >= 10 && !submitting
const fullReason = `${selected}: ${trimmedDetails}`
return createPortal(
<div
ref={backdropRef}
onClick={(e) => { if (e.target === backdropRef.current) onClose() }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div className="w-full max-w-md rounded-2xl border border-white/[0.08] bg-nova-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
<h3 className="text-base font-semibold text-white">Report Artwork</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="space-y-4 px-6 py-5">
{/* Step 1 — pick a reason */}
<div className="space-y-2">
<label className="text-sm font-medium text-white/60">Reason <span className="text-red-400">*</span></label>
<div className="flex flex-wrap gap-2">
{REPORT_REASONS.map((r) => (
<button
key={r}
type="button"
onClick={() => setSelected(r)}
className={[
'rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all',
selected === r
? 'border-red-500/50 bg-red-500/15 text-red-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
].join(' ')}
>
{r}
</button>
))}
</div>
</div>
{/* Step 2 — describe & prove */}
<div className="space-y-2">
<label className="text-sm font-medium text-white/60">
Details &amp; proof <span className="text-red-400">*</span>
</label>
<textarea
ref={inputRef}
value={details}
onChange={(e) => setDetails(e.target.value)}
maxLength={1000}
rows={4}
placeholder="Please describe the issue and provide any links or evidence that support your report…"
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-sm text-white placeholder-white/30 outline-none transition focus:border-white/[0.15] focus:ring-1 focus:ring-white/[0.1]"
/>
<p className="text-xs text-white/30">
{trimmedDetails.length < 10
? `At least 10 characters required (${trimmedDetails.length}/10)`
: `${details.length}/1000`}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.07] hover:text-white/80"
>
Cancel
</button>
<button
type="button"
disabled={!canSubmit}
onClick={() => onSubmit(fullReason)}
className="rounded-full bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-red-600/20 transition hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? 'Sending…' : 'Submit Report'}
</button>
</div>
</div>
</div>,
document.body
)
}
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [bookmarked, setBookmarked] = useState(Boolean(artwork?.viewer?.is_bookmarked))
const [bookmarkCount, setBookmarkCount] = useState(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
const [downloading, setDownloading] = useState(false)
const [reporting, setReporting] = useState(false)
const [reported, setReported] = useState(false)
const [reportOpen, setReportOpen] = useState(false)
const isLoggedIn = Boolean(artwork?.viewer?.is_authenticated)
useEffect(() => {
setFavorited(Boolean(artwork?.viewer?.is_favorited))
}, [artwork?.id, artwork?.viewer?.is_favorited])
useEffect(() => {
setBookmarked(Boolean(artwork?.viewer?.is_bookmarked))
}, [artwork?.id, artwork?.viewer?.is_bookmarked])
useEffect(() => {
setBookmarkCount(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
// Track view
useEffect(() => {
if (!artwork?.id) return
const key = `sb_viewed_${artwork.id}`
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
}).then(res => {
if (res.ok && typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
}).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const postInteraction = async (url, body) => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!response.ok) throw new Error('Request failed')
return response.json()
}
const handleDownload = async () => {
if (downloading || !artwork?.id) return
setDownloading(true)
try {
const a = document.createElement('a')
a.href = `/download/artwork/${artwork.id}`
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}
}
const onToggleFavorite = async () => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
const nextState = !favorited
setFavorited(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
onStatsChange?.({ favorites: nextState ? 1 : -1 })
} catch { setFavorited(!nextState) }
}
const onToggleBookmark = async () => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
const nextState = !bookmarked
setBookmarked(nextState)
setBookmarkCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
try {
const payload = await postInteraction(`/api/artworks/${artwork.id}/bookmark`, { state: nextState })
setBookmarked(Boolean(payload?.is_bookmarked))
setBookmarkCount(Number(payload?.stats?.bookmarks ?? 0))
} catch {
setBookmarked(!nextState)
setBookmarkCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
}
}
const openReport = () => {
if (reported) return
setReportOpen(true)
}
const submitReport = async (reason) => {
if (reporting) return
setReporting(true)
try {
await postInteraction(`/api/artworks/${artwork.id}/report`, { reason })
setReported(true)
setReportOpen(false)
} catch { /* noop */ }
finally { setReporting(false) }
}
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
const savedCount = formatCount(bookmarkCount)
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
return (
<>
{/* ── Desktop centered bar ────────────────────────────────────── */}
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
{/* Favourite (heart) stat pill */}
<button
type="button"
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
onClick={onToggleFavorite}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
favorited
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
</button>
<button
type="button"
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
onClick={onToggleBookmark}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
bookmarked
? 'border-amber-400/35 bg-amber-400/14 text-amber-200 shadow-lg shadow-amber-500/10 hover:bg-amber-400/18'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
</button>
{/* Views stat pill */}
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
<CloudDownIcon />
<span className="tabular-nums">{viewCount}</span>
</div>
{/* Share pill */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" isLoggedIn={isLoggedIn} />
{/* Report pill */}
<button
type="button"
aria-label="Report artwork"
onClick={openReport}
disabled={reported}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
reported
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400',
].join(' ')}
>
<FlagIcon />
{reported ? 'Reported' : 'Report'}
</button>
{/* Download button */}
<button
type="button"
aria-label="Download artwork"
onClick={handleDownload}
disabled={downloading}
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-2.5 text-sm font-bold text-deep shadow-lg shadow-accent/25 transition-all duration-200 hover:brightness-110 hover:shadow-xl hover:shadow-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-wait disabled:opacity-60"
>
<DownloadArrowIcon />
{downloading ? 'Downloading…' : 'Download'}
</button>
</div>
{/* ── Mobile fixed bottom bar ─────────────────────────────────── */}
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/[0.08] bg-nova-900/95 px-3 py-2.5 backdrop-blur-md lg:hidden">
<div className="flex items-center justify-center gap-2">
<button
type="button"
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
onClick={onToggleFavorite}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
favorited
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
</button>
<button
type="button"
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
onClick={onToggleBookmark}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
bookmarked
? 'border-amber-400/35 bg-amber-400/14 text-amber-200'
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
</button>
{/* Share */}
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
{/* Report */}
<button
type="button"
aria-label="Report"
onClick={openReport}
disabled={reported}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
reported
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:text-red-400',
].join(' ')}
>
<FlagIcon />
</button>
<button
type="button"
aria-label="Download artwork"
onClick={handleDownload}
disabled={downloading}
className="inline-flex items-center gap-1.5 rounded-full bg-accent px-5 py-2 text-xs font-bold text-deep transition hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
>
<DownloadArrowIcon />
{downloading ? '…' : 'Download'}
</button>
</div>
</div>
{/* Report modal */}
<ReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
onSubmit={submitReport}
submitting={reporting}
/>
</>
)
}

View File

@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react'
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false, onStatsChange }) {
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false)
const [downloading, setDownloading] = useState(false)
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
// Track the view once per browser session (sessionStorage prevents re-firing).
useEffect(() => {
if (!artwork?.id) return
const key = `sb_viewed_${artwork.id}`
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
}).then(res => {
// Only mark as seen after a confirmed success — if the POST fails the
// next page load will retry rather than silently skipping forever.
if (res.ok && typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(key, '1')
}
}).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
// Download through the secure Laravel route so original files are never exposed directly.
const handleDownload = async (e) => {
e.preventDefault()
if (downloading || !artwork?.id) return
setDownloading(true)
try {
const a = document.createElement('a')
a.href = `/download/artwork/${artwork.id}`
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}
}
const postInteraction = async (url, body) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!response.ok) throw new Error('Request failed')
return response.json()
}
const onToggleLike = async () => {
const nextState = !liked
setLiked(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
onStatsChange?.({ likes: nextState ? 1 : -1 })
} catch {
setLiked(!nextState)
}
}
const onToggleFavorite = async () => {
const nextState = !favorited
setFavorited(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
onStatsChange?.({ favorites: nextState ? 1 : -1 })
} catch {
setFavorited(!nextState)
}
}
const onShare = async () => {
try {
if (navigator.share) {
await navigator.share({
title: artwork?.title || 'Artwork',
url: shareUrl,
})
return
}
await navigator.clipboard.writeText(shareUrl)
} catch {
// noop
}
}
const onReport = async () => {
if (reporting) return
setReporting(true)
try {
await postInteraction(`/api/artworks/${artwork.id}/report`, {
reason: 'Reported from artwork page',
})
} catch {
// noop
} finally {
setReporting(false)
}
}
return (
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Actions</h2>
<div className="mt-4 flex flex-col gap-3">
{/* Download — full-width primary CTA */}
<button
type="button"
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep transition hover:brightness-110 disabled:opacity-60 disabled:cursor-wait"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? (
<>
<svg className="h-4 w-4 animate-spin shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 100 16v-4l-3 3 3 3v-4a8 8 0 01-8-8z" />
</svg>
Downloading
</>
) : (
<>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
Download
</>
)}
</button>
{/* Secondary actions — icon row */}
<div className="grid grid-cols-4 gap-2">
{/* Like */}
<button
type="button"
title={liked ? 'Unlike' : 'Like'}
onClick={onToggleLike}
className={`flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition
${liked
? 'border-rose-500/60 bg-rose-500/10 text-rose-400 hover:bg-rose-500/20'
: 'border-nova-600 text-soft hover:bg-nova-800 hover:text-white'}`}
>
<svg className="h-5 w-5 shrink-0" fill={liked ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 016.364 0L12 7.636l1.318-1.318a4.5 4.5 0 116.364 6.364L12 20.364l-7.682-7.682a4.5 4.5 0 010-6.364z" />
</svg>
<span>{liked ? 'Liked' : 'Like'}</span>
</button>
{/* Favorite */}
<button
type="button"
title={favorited ? 'Remove from favorites' : 'Favorite'}
onClick={onToggleFavorite}
className={`flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition
${favorited
? 'border-amber-500/60 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20'
: 'border-nova-600 text-soft hover:bg-nova-800 hover:text-white'}`}
>
<svg className="h-5 w-5 shrink-0" fill={favorited ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
<span>{favorited ? 'Saved' : 'Save'}</span>
</button>
{/* Share */}
<button
type="button"
title="Share"
onClick={onShare}
className="flex flex-col items-center justify-center gap-1 rounded-lg border border-nova-600 px-2 py-3 text-xs font-medium text-soft transition hover:bg-nova-800 hover:text-white"
>
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
<span>Share</span>
</button>
{/* Report */}
<button
type="button"
title="Report"
onClick={onReport}
disabled={reporting}
className="flex flex-col items-center justify-center gap-1 rounded-lg border border-nova-600 px-2 py-3 text-xs font-medium text-soft transition hover:border-red-500/50 hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait"
>
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
</svg>
<span>{reporting ? '…' : 'Report'}</span>
</button>
</div>
</div>
{mobilePriority && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 border-t border-nova-700 bg-panel/95 p-3 backdrop-blur lg:hidden">
<button
type="button"
onClick={handleDownload}
disabled={downloading}
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep transition hover:brightness-110 disabled:opacity-60 disabled:cursor-wait"
>
{downloading ? 'Downloading…' : 'Download'}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
export default function ArtworkAuthor({ artwork, presentSq }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
const user = artwork?.user || {}
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
const authorName = user.name || user.username || 'Artist'
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
return (
<>
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
<div className="mt-4 flex items-center gap-4">
<img
src={avatar}
alt={authorName}
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
<div className="min-w-0">
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
{authorName}
</a>
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
</div>
</div>
<div className={`mt-4 grid gap-3 ${isOwnArtwork ? 'grid-cols-1' : 'grid-cols-2'}`}>
<a
href={profileUrl}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
</a>
{!isOwnArtwork ? (
<FollowButton
username={user.username}
initialFollowing={following}
initialCount={followersCount}
showCount={false}
className="min-h-11"
sizeClassName="px-3 py-2 text-sm"
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
setFollowing(nextFollowing)
setFollowersCount(nextFollowersCount)
}}
/>
) : null}
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,371 @@
import React, { useState, useCallback } from 'react'
import Modal from '../ui/Modal'
import Button from '../ui/Button'
const MEDALS = [
{ key: 'gold', label: 'Gold', emoji: '🥇', weight: 5 },
{ key: 'silver', label: 'Silver', emoji: '🥈', weight: 3 },
{ key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 },
]
function getMedalMeta(medalKey) {
return MEDALS.find((medal) => medal.key === medalKey) ?? null
}
function getMedalWeight(medalKey) {
return getMedalMeta(medalKey)?.weight ?? 0
}
function buildConfirmationContent(pendingConfirmation) {
if (!pendingConfirmation) {
return null
}
const nextMedal = getMedalMeta(pendingConfirmation.medal)
const previousMedal = getMedalMeta(pendingConfirmation.previousMedal)
if (pendingConfirmation.action === 'remove') {
return {
title: `Remove ${nextMedal?.label ?? 'medal'} medal?`,
summary: `This will remove your ${nextMedal?.label ?? ''} medal from this artwork.`,
details: 'Your contribution to the medal score will be removed immediately after confirmation.',
confirmLabel: 'Remove medal',
confirmVariant: 'danger',
modalVariant: 'danger',
}
}
return {
title: `Change medal to ${nextMedal?.label ?? 'selected medal'}?`,
summary: `You already awarded ${previousMedal?.label ?? 'a medal'} to this artwork.`,
details: `Confirm to switch your medal from ${previousMedal?.label ?? 'the current medal'} to ${nextMedal?.label ?? 'the selected medal'}.`,
confirmLabel: `Change to ${nextMedal?.label ?? 'selected medal'}`,
confirmVariant: 'accent',
modalVariant: 'default',
}
}
function describeMedalError(message) {
const normalized = String(message || '').trim()
const lower = normalized.toLowerCase()
if (lower.includes('verify your email')) {
return {
title: 'Email verification required',
summary: 'Medals are limited to verified accounts to reduce abuse and low-quality vote spam.',
details: 'Open your account email, use the verification link, then reload this page and try again.',
}
}
if (lower.includes('at least') && lower.includes('hours old')) {
return {
title: 'Account is too new',
summary: normalized,
details: 'This cooldown is there to stop throwaway accounts from mass-awarding artworks immediately after signup.',
}
}
if (lower.includes('your own artwork')) {
return {
title: 'Own artwork cannot be medaled',
summary: 'Creators cannot add medals to their own work.',
details: 'Only other community members can award medals so the score stays community-driven.',
}
}
if (lower.includes('not published yet')) {
return {
title: 'Artwork is not published yet',
summary: 'This artwork has not reached a public, medal-eligible state yet.',
details: 'Medals are only available after the artwork is published and visible publicly.',
}
}
if (lower.includes('not eligible for medals')) {
return {
title: 'Artwork is not eligible for medals',
summary: 'This artwork is currently blocked from medal voting.',
details: 'That usually means it is private, unapproved, or otherwise not available for public medal activity.',
}
}
if (lower.includes('no longer available')) {
return {
title: 'Artwork is unavailable',
summary: 'This artwork can no longer receive medals.',
details: 'The artwork may have been removed or is no longer publicly available.',
}
}
if (lower.includes('disabled')) {
return {
title: 'Medals are temporarily unavailable',
summary: normalized,
details: 'This is a site-wide setting, not a problem with your account.',
}
}
return {
title: 'Unable to add medal',
summary: normalized || 'The medal request could not be completed.',
details: 'Check that you are signed in with an eligible account and that the artwork is publicly medal-eligible.',
}
}
export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) {
const artworkId = artwork?.id
const isOwnArtwork = Boolean(artwork?.viewer?.id && artwork?.user?.id && artwork.viewer.id === artwork.user.id)
const [awards, setAwards] = useState({
gold: initialAwards?.gold ?? 0,
silver: initialAwards?.silver ?? 0,
bronze: initialAwards?.bronze ?? 0,
score: initialAwards?.score ?? 0,
})
const [viewerAward, setViewerAward] = useState(initialAwards?.current_user_medal ?? initialAwards?.viewer_award ?? null)
const [loading, setLoading] = useState(null) // which medal is pending
const [error, setError] = useState(null)
const [pendingConfirmation, setPendingConfirmation] = useState(null)
const errorDetails = error ? describeMedalError(error) : null
const confirmationContent = buildConfirmationContent(pendingConfirmation)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const apiFetch = useCallback(async (method, body = null) => {
const res = await fetch(`/api/artworks/${artworkId}/medal`, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed')
}
return res.json()
}, [artworkId, csrfToken])
const applyServerResponse = useCallback((data) => {
const payload = data?.medals || data?.awards || null
if (payload) {
setAwards({
gold: payload.gold ?? 0,
silver: payload.silver ?? 0,
bronze: payload.bronze ?? 0,
score: payload.score ?? 0,
})
}
setViewerAward(data?.current_user_medal ?? data?.viewer_award ?? null)
}, [])
const handleMedalAction = useCallback(async ({ action, medal, previousMedal = null }) => {
if (!isAuthenticated || isOwnArtwork) return
if (loading) return
setError(null)
// Optimistic update
const prevAwards = { ...awards }
const prevViewer = viewerAward
if (action === 'remove') {
// Undo: remove award
setAwards(a => ({
...a,
[medal]: Math.max(0, a[medal] - 1),
score: Math.max(0, a.score - getMedalWeight(medal)),
}))
setViewerAward(null)
setLoading(medal)
try {
const data = await apiFetch('DELETE')
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
return
}
if (action === 'change' && previousMedal) {
// Change: swap medals
setAwards(a => ({
...a,
[previousMedal]: Math.max(0, a[previousMedal] - 1),
[medal]: a[medal] + 1,
score: a.score - getMedalWeight(previousMedal) + getMedalWeight(medal),
}))
setViewerAward(medal)
setLoading(medal)
try {
const data = await apiFetch('POST', { medal_type: medal })
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
return
}
// New award
setAwards(a => ({
...a,
[medal]: a[medal] + 1,
score: a.score + getMedalWeight(medal),
}))
setViewerAward(medal)
setLoading(medal)
try {
const data = await apiFetch('POST', { medal_type: medal })
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
}, [isAuthenticated, isOwnArtwork, loading, awards, viewerAward, apiFetch, applyServerResponse])
const handleMedalClick = useCallback((medal) => {
if (!isAuthenticated || isOwnArtwork) return
if (loading) return
setError(null)
if (viewerAward === medal) {
setPendingConfirmation({ action: 'remove', medal, previousMedal: medal })
return
}
if (viewerAward) {
setPendingConfirmation({ action: 'change', medal, previousMedal: viewerAward })
return
}
void handleMedalAction({ action: 'add', medal })
}, [isAuthenticated, isOwnArtwork, loading, viewerAward, handleMedalAction])
const closeConfirmation = useCallback(() => {
if (loading) return
setPendingConfirmation(null)
}, [loading])
const confirmPendingAction = useCallback(async () => {
if (!pendingConfirmation || loading) return
const action = pendingConfirmation
setPendingConfirmation(null)
await handleMedalAction(action)
}, [pendingConfirmation, loading, handleMedalAction])
return (
<>
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/30">Medals</h2>
{errorDetails && (
<div className="mt-3 rounded-xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-left">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-red-300/90">{errorDetails.title}</div>
<p className="mt-2 text-sm leading-6 text-red-200">{errorDetails.summary}</p>
<p className="mt-2 text-xs leading-5 text-red-100/75">{errorDetails.details}</p>
</div>
)}
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
{MEDALS.map(({ key, label, emoji }) => {
const isActive = viewerAward === key
const isPending = loading === key
return (
<button
key={key}
type="button"
disabled={!isAuthenticated || isOwnArtwork || loading !== null}
onClick={() => handleMedalClick(key)}
title={!isAuthenticated ? 'Sign in to medal' : isOwnArtwork ? 'You cannot medal your own artwork' : isActive ? `Remove ${label} medal` : viewerAward ? `Change medal to ${label}` : `Give ${label} medal`}
className={[
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
isActive
? 'border-accent/40 bg-accent/10 font-semibold text-accent shadow-lg shadow-accent/10'
: 'border-white/[0.08] bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:border-white/[0.12]',
(!isAuthenticated || isOwnArtwork || loading !== null) && 'cursor-not-allowed opacity-60',
].filter(Boolean).join(' ')}
>
<span className="text-xl leading-none" aria-hidden="true">
{isPending ? '…' : emoji}
</span>
<span className="text-xs font-medium leading-none">{label}</span>
<span className="text-xs text-soft tabular-nums">
{awards[key]}
</span>
</button>
)
})}
</div>
{awards.score > 0 && (
<p className="mt-3 text-right text-xs text-soft">
Score: <span className="font-semibold text-white">{awards.score}</span>
</p>
)}
{!isAuthenticated && (
<p className="mt-3 text-center text-xs text-soft">
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
</p>
)}
{isAuthenticated && isOwnArtwork && (
<p className="mt-3 text-center text-xs text-soft">
You cannot medal your own artwork.
</p>
)}
</div>
<Modal
open={Boolean(confirmationContent)}
onClose={closeConfirmation}
title={confirmationContent?.title}
size="sm"
variant={confirmationContent?.modalVariant}
footer={confirmationContent ? (
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={closeConfirmation} disabled={loading !== null}>
Cancel
</Button>
<Button variant={confirmationContent.confirmVariant} size="sm" onClick={confirmPendingAction} loading={loading !== null}>
{confirmationContent.confirmLabel}
</Button>
</div>
) : null}
>
{confirmationContent ? (
<div className="space-y-3">
<p className="text-sm leading-6 text-slate-200">{confirmationContent.summary}</p>
<p className="text-xs leading-5 text-slate-400">{confirmationContent.details}</p>
</div>
) : null}
</Modal>
</>
)
}

View File

@@ -0,0 +1,125 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ArtworkAwards from './ArtworkAwards'
describe('ArtworkAwards medal confirmations', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('asks for confirmation before removing the active medal', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
medals: { gold: 0, silver: 0, bronze: 0, score: 0 },
current_user_medal: null,
}),
})
render(
<ArtworkAwards
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
isAuthenticated
initialAwards={{ gold: 1, silver: 0, bronze: 0, score: 5, current_user_medal: 'gold' }}
/>,
)
await user.click(screen.getByRole('button', { name: /gold/i }))
expect(screen.getByRole('dialog', { name: /remove gold medal\?/i })).not.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /remove medal/i }))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/artworks/69461/medal',
expect.objectContaining({
method: 'DELETE',
}),
)
})
it('asks for confirmation before changing an existing medal', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
medals: { gold: 0, silver: 1, bronze: 0, score: 3 },
current_user_medal: 'silver',
}),
})
render(
<ArtworkAwards
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
isAuthenticated
initialAwards={{ gold: 1, silver: 0, bronze: 0, score: 5, current_user_medal: 'gold' }}
/>,
)
await user.click(screen.getByRole('button', { name: /silver/i }))
expect(screen.getByRole('dialog', { name: /change medal to silver\?/i })).not.toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /change to silver/i }))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/artworks/69461/medal',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ medal_type: 'silver' }),
}),
)
})
it('still awards a new medal immediately when the viewer has not voted yet', async () => {
const user = userEvent.setup()
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
medals: { gold: 0, silver: 1, bronze: 0, score: 3 },
current_user_medal: 'silver',
}),
})
render(
<ArtworkAwards
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
isAuthenticated
initialAwards={{ gold: 0, silver: 0, bronze: 0, score: 0, current_user_medal: null }}
/>,
)
await user.click(screen.getByRole('button', { name: /silver/i }))
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
expect(screen.queryByRole('dialog')).toBeNull()
})
})

View File

@@ -0,0 +1,88 @@
import React from 'react'
function Separator() {
return (
<svg
className="h-3 w-3 flex-shrink-0 text-white/15"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)
}
function Crumb({ href, children, current = false }) {
const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]'
if (current) {
return (
<span
className={`${base} text-white/30`}
aria-current="page"
>
{children}
</span>
)
}
return (
<a
href={href}
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
>
{children}
</a>
)
}
export default function ArtworkBreadcrumbs({ artwork }) {
if (!artwork) return null
// Use the first category for the content-type + category crumbs
const firstCategory = artwork.categories?.[0] ?? null
const contentTypeSlug = firstCategory?.content_type_slug || null
const contentTypeName = contentTypeSlug
? contentTypeSlug.charAt(0).toUpperCase() + contentTypeSlug.slice(1)
: null
const categorySlug = firstCategory?.slug || null
const categoryName = firstCategory?.name || null
const categoryUrl = contentTypeSlug && categorySlug
? `/${contentTypeSlug}/${categorySlug}`
: null
return (
<nav aria-label="Breadcrumb" className="mt-1.5 mb-0">
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
{/* Home */}
<li className="flex items-center gap-x-1.5">
<Crumb href="/">Home</Crumb>
</li>
{/* Content type e.g. Photography */}
{contentTypeSlug && (
<>
<li className="flex items-center"><Separator /></li>
<li className="flex items-center gap-x-1.5">
<Crumb href={`/${contentTypeSlug}`}>{contentTypeName}</Crumb>
</li>
</>
)}
{/* Category e.g. Landscapes */}
{categoryUrl && (
<>
<li className="flex items-center"><Separator /></li>
<li className="flex items-center gap-x-1.5">
<Crumb href={categoryUrl}>{categoryName}</Crumb>
</li>
</>
)}
{/* Current artwork title — omitted: shown as h1 above */}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,988 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react'
import LevelBadge from '../xp/LevelBadge'
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
const numberFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function formatCount(value) {
const numeric = Number(value ?? 0)
if (!Number.isFinite(numeric)) return '0'
return numberFormatter.format(numeric)
}
function formatRelativeTime(value) {
if (!value) return ''
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffSeconds = Math.round(diffMs / 1000)
const absSeconds = Math.abs(diffSeconds)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
const diffMinutes = Math.round(diffSeconds / 60)
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
const diffHours = Math.round(diffSeconds / 3600)
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
const diffDays = Math.round(diffSeconds / 86400)
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
const diffWeeks = Math.round(diffSeconds / 604800)
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
const diffMonths = Math.round(diffSeconds / 2629800)
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
const diffYears = Math.round(diffSeconds / 31557600)
return rtf.format(diffYears, 'year')
}
function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function decodeHtml(value) {
const text = String(value ?? '')
if (!text.includes('&')) return text
let decoded = text
for (let index = 0; index < 3; index += 1) {
decoded = decoded
.replace(/&amp;/gi, '&')
.replace(/&(apos|#39);/gi, "'")
.replace(/&(acute|#180|#x00B4);/gi, "'")
.replace(/&(quot|#34);/gi, '"')
.replace(/&(nbsp|#160);/gi, ' ')
if (typeof document === 'undefined') {
break
}
const textarea = document.createElement('textarea')
textarea.innerHTML = decoded
const nextValue = textarea.value
if (nextValue === decoded) break
decoded = nextValue
}
return decoded
}
function normalizeContentTypeLabel(value) {
const raw = decodeHtml(value).trim()
if (!raw) return ''
const normalized = raw.toLowerCase()
const knownLabels = {
artworks: 'Artwork',
artwork: 'Artwork',
wallpapers: 'Wallpaper',
wallpaper: 'Wallpaper',
skins: 'Skin',
skin: 'Skin',
photography: 'Photography',
photo: 'Photography',
photos: 'Photography',
other: 'Other',
}
if (knownLabels[normalized]) {
return knownLabels[normalized]
}
return raw
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function swapImageToFallbackOnce(event, fallbackSrc, { clearResponsive = false } = {}) {
const image = event.currentTarget
if (!image || image.dataset.fallbackApplied === '1') {
return
}
image.dataset.fallbackApplied = '1'
image.onerror = null
if (clearResponsive) {
image.removeAttribute('srcset')
image.removeAttribute('sizes')
}
image.src = fallbackSrc
}
function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return
void fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
}).catch(() => {})
}
async function sendFeedbackSignal(endpoint, payload) {
if (!endpoint) {
throw new Error('missing_feedback_endpoint')
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('feedback_request_failed')
}
return response.json().catch(() => null)
}
async function requestJson(endpoint, { method = 'GET', body } = {}) {
const response = await fetch(endpoint, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(payload?.message || 'Request failed.')
error.payload = payload
throw error
}
return payload
}
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
const endpoint = item?.discovery_endpoint
const artworkId = Number(item?.id ?? 0)
if (!endpoint || artworkId <= 0) return
sendDiscoveryEvent(endpoint, {
event_type: eventType,
artwork_id: artworkId,
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
source: item?.recommendation_source || null,
reason: item?.recommendation_reason || null,
score: item?.recommendation_score ?? null,
...extraMeta,
},
})
}
function HeartIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c-4.97-3.12-8.25-6.16-8.25-10.03A4.72 4.72 0 0 1 8.5 5.5c1.5 0 2.93.7 3.84 1.92A4.8 4.8 0 0 1 16.18 5.5a4.72 4.72 0 0 1 4.82 4.72c0 3.87-3.28 6.91-8.25 10.03Z" />
</svg>
)
}
function DownloadIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3.75v10.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 10.5 3.75 3.75 3.75-3.75" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18.75h15" />
</svg>
)
}
function ViewIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 14.75A2.75 2.75 0 1 0 12 9.25a2.75 2.75 0 0 0 0 5.5Z" />
</svg>
)
}
function HideIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
</svg>
)
}
function TagIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
</svg>
)
}
function CollectionIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
</svg>
)
}
function ActionLink({ href, label, children, onClick }) {
return (
<a
href={href || '#'}
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</a>
)
}
function ActionButton({ label, children, onClick }) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</button>
)
}
function BadgePill({ className = '', iconClass = '', children }) {
return (
<span
className={cx(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] backdrop-blur-sm ring-1 shadow-[0_8px_24px_rgba(2,6,23,0.28)]',
className
)}
>
{iconClass ? <i className={iconClass} aria-hidden="true" /> : null}
{children}
</span>
)
}
function CollectionPickerModal({
open,
artworkTitle,
collections,
loading,
error,
notice,
createUrl,
attachingCollectionId,
onAttach,
onClose,
}) {
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
<button
type="button"
aria-label="Close add to collection dialog"
onClick={onClose}
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
/>
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
<div className="border-b border-white/10 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
<div className="space-y-4 px-6 py-6">
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
{loading ? (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
) : collections.length ? (
<div className="grid gap-3 md:grid-cols-2">
{collections.map((collection) => (
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks {collection.visibility}</div>
</div>
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onAttach(collection)}
disabled={collection.already_attached || attachingCollectionId === collection.id}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
>
<CollectionIcon className="h-3.5 w-3.5" />
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
</button>
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
</div>
</div>
))}
</div>
) : (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
<CollectionIcon className="h-7 w-7" />
</div>
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
<a href={createUrl} className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-plus" />
Create Collection
</a>
</div>
)}
</div>
</div>
</div>
)
}
export default function ArtworkCard({
artwork,
variant = 'default',
compact = false,
showStats = true,
showAuthor = true,
className = '',
articleClassName = '',
frameClassName = '',
mediaClassName = '',
mediaStyle,
articleStyle,
imageClassName = '',
imageSizes,
imageSrcSet,
imageWidth,
imageHeight,
loading = 'lazy',
decoding = 'async',
fetchPriority,
onLike,
onDismissed,
showActions = true,
metricBadge = null,
}) {
let inertiaProps = {}
try {
inertiaProps = usePage()?.props || {}
} catch {
inertiaProps = {}
}
const item = artwork || {}
const rawAuthor = item.author || item.creator
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null
const isGroupPublisher = (publisher?.type === 'group') || item.published_as_type === 'group'
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
const author = decodeHtml(
(isGroupPublisher ? publisher?.name : null)
|| (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|| item.author_name
|| item.uname
|| 'Skinbase Artist'
)
const username = isGroupPublisher ? null : (rawAuthor?.username || item.author_username || item.username || null)
const authorLevel = isGroupPublisher ? 0 : Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
const authorRank = isGroupPublisher ? '' : (rawAuthor?.rank || item.author_rank || item.creator?.rank || '')
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
const responsiveImageSrcSet = imageSrcSet || item.thumb_srcset || item.thumbnail_srcset || undefined
const responsiveImageSizes = imageSizes || (variant === 'embed'
? '80px'
: compact
? '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 280px'
: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1536px) 25vw, 320px')
const avatar = (isGroupPublisher ? publisher?.avatar_url : null)
|| rawAuthor?.avatar_url
|| rawAuthor?.avatar
|| item.avatar
|| item.author_avatar
|| item.avatar_url
|| AVATAR_FALLBACK
const likes = item.likes ?? item.favourites ?? 0
const views = item.views ?? item.views_count ?? item.view_count ?? 0
const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0
const contentType = normalizeContentTypeLabel(
item.content_type
|| item.content_type_name
|| item.contentType
|| item.contentTypeName
|| item.content_type_slug
|| ''
)
const category = decodeHtml(item.category || item.category_name || '')
const width = Number(item.width ?? 0)
const height = Number(item.height ?? 0)
const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : ''))
const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href)
const cardLabel = `${title} by ${author}`
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
const authorHref = publisher?.profile_url || rawAuthor?.profile_url || item.profile_url || item.author_url || (username ? `/@${username}` : null)
const resolvedMetricBadge = metricBadge || item.metric_badge || null
const relativePublishedAt = useMemo(
() => formatRelativeTime(item.published_at || item.publishedAt || null),
[item.published_at, item.publishedAt]
)
const maturity = item.maturity && typeof item.maturity === 'object' ? item.maturity : {}
const shouldBlurMature = Boolean(maturity.should_blur)
const isMatureArtwork = Boolean(maturity.is_mature_effective)
const initialLiked = Boolean(item.viewer?.is_liked)
const [liked, setLiked] = useState(initialLiked)
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
const [likeBusy, setLikeBusy] = useState(false)
const [downloadBusy, setDownloadBusy] = useState(false)
const [hideBusy, setHideBusy] = useState(false)
const [dislikeBusy, setDislikeBusy] = useState(false)
const [dismissed, setDismissed] = useState(false)
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
const [collectionOptions, setCollectionOptions] = useState([])
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
const [collectionPickerError, setCollectionPickerError] = useState('')
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
const openTrackedRef = useRef(false)
const primaryTag = useMemo(() => {
if (item?.primary_tag && typeof item.primary_tag === 'object') {
return item.primary_tag
}
if (Array.isArray(item?.tags) && item.tags.length > 0) {
return item.tags[0]
}
return null
}, [item.primary_tag, item.tags])
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
const collectionOptionsEndpoint = canAddToCollection
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
: null
useEffect(() => {
setLiked(Boolean(item.viewer?.is_liked))
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
setDismissed(false)
setCollectionPickerOpen(false)
setCollectionOptionsLoading(false)
setCollectionOptionsLoaded(false)
setCollectionOptions([])
setCollectionPickerError('')
setCollectionPickerNotice('')
setAttachingCollectionId(null)
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
const articleData = useMemo(() => ({
'data-art-id': item.id ?? undefined,
'data-art-url': href !== '#' ? href : undefined,
'data-art-title': title,
'data-art-img': image,
}), [href, image, item.id, title])
const handleOpen = () => {
if (openTrackedRef.current) return
openTrackedRef.current = true
trackRecommendationFeedback(item, 'click', {
interaction_origin: 'artwork-card-open',
target_url: href,
})
}
const handleLike = async () => {
if (!item.id || likeBusy) {
onLike?.(item)
return
}
const nextState = !liked
setLikeBusy(true)
setLiked(nextState)
setLikeCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
try {
const response = await fetch(`/api/artworks/${item.id}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({ state: nextState }),
})
if (!response.ok) {
throw new Error('like_request_failed')
}
if (nextState) {
trackRecommendationFeedback(item, 'favorite', {
interaction_origin: 'artwork-card-like',
})
}
onLike?.(item)
} catch {
setLiked(!nextState)
setLikeCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
} finally {
setLikeBusy(false)
}
}
const handleDownload = async (event) => {
event.preventDefault()
if (!item.id || downloadBusy) return
setDownloadBusy(true)
try {
trackRecommendationFeedback(item, 'download', {
interaction_origin: 'artwork-card-download',
target_url: downloadHref,
})
const link = document.createElement('a')
link.href = downloadHref
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch {
window.open(downloadHref, '_blank', 'noopener,noreferrer')
} finally {
setDownloadBusy(false)
}
}
const dismissArtwork = (kind) => {
setDismissed(true)
onDismissed?.(item, kind)
}
const handleHideArtwork = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canHideRecommendation || hideBusy) return
setHideBusy(true)
try {
await sendFeedbackSignal(hideArtworkEndpoint, {
artwork_id: Number(item.id),
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
primary_tag_slug: primaryTag?.slug || null,
interaction_origin: 'artwork-card-hide',
},
})
dismissArtwork('hide-artwork')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setHideBusy(false)
}
}
const handleDislikePrimaryTag = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canDislikePrimaryTag || dislikeBusy) return
setDislikeBusy(true)
try {
await sendFeedbackSignal(dislikeTagEndpoint, {
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
tag_slug: primaryTag?.slug || undefined,
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
artwork_id: Number(item.id),
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
interaction_origin: 'artwork-card-dislike-tag',
},
})
dismissArtwork('dislike-tag')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setDislikeBusy(false)
}
}
const handleOpenCollectionPicker = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
setCollectionPickerOpen(true)
setCollectionPickerError('')
setCollectionPickerNotice('')
if (collectionOptionsLoaded) {
return
}
setCollectionOptionsLoading(true)
try {
const payload = await requestJson(collectionOptionsEndpoint)
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
setCollectionOptionsLoaded(true)
} catch (error) {
setCollectionPickerError(error.message || 'Unable to load collections.')
} finally {
setCollectionOptionsLoading(false)
}
}
const handleAttachToCollection = async (collection) => {
if (!collection?.attach_url || attachingCollectionId === collection.id) return
setAttachingCollectionId(collection.id)
setCollectionPickerError('')
setCollectionPickerNotice('')
try {
await requestJson(collection.attach_url, {
method: 'POST',
body: { artwork_ids: [Number(item.id)] },
})
setCollectionOptions((current) => current.map((entry) => (
entry.id === collection.id
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
: entry
)))
setCollectionPickerNotice(`Added to ${collection.title}.`)
} catch (error) {
const firstError = error?.payload?.errors
? Object.values(error.payload.errors).flat().find(Boolean)
: null
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
} finally {
setAttachingCollectionId(null)
}
}
if (dismissed) {
return null
}
if (maturity.should_hide) {
return null
}
if (variant === 'embed') {
return (
<article
className={cx('group overflow-hidden rounded-xl border border-white/[0.08] bg-black/30 transition-colors hover:border-sky-500/30', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<a
href={href}
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
aria-label={`Open artwork: ${cardLabel}`}
onClick={handleOpen}
>
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
<img
src={image}
srcSet={responsiveImageSrcSet}
sizes={responsiveImageSizes}
alt={title}
loading={loading}
decoding={decoding}
className={cx('h-full w-full object-cover transition-transform duration-300 group-hover:scale-105', shouldBlurMature ? 'scale-[1.02] blur-xl' : '')}
onError={(event) => {
swapImageToFallbackOnce(event, IMAGE_FALLBACK)
}}
/>
{isMatureArtwork ? <div className="absolute inset-x-2 bottom-2 rounded-lg border border-amber-300/20 bg-black/65 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature content</div> : null}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white/90">{title}</p>
{showAuthor && (
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
{authorHref ? (
<span>
by {author} {username ? <span className="text-slate-500">@{username}</span> : null}
</span>
) : (
<span>by {author}</span>
)}
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact /> : null}
</div>
)}
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
{contentType || 'Artwork'}
</p>
</div>
</a>
</article>
)
}
return (
<>
<article
className={cx('group relative', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<a
href={href}
aria-label={`Open artwork: ${cardLabel}`}
onClick={handleOpen}
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
<span className="sr-only">{cardLabel}</span>
</a>
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<img
src={image}
srcSet={responsiveImageSrcSet}
sizes={responsiveImageSizes}
alt={title}
width={imageWidth || undefined}
height={imageHeight || undefined}
loading={loading}
decoding={decoding}
fetchPriority={fetchPriority || undefined}
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', shouldBlurMature ? 'scale-[1.02] blur-xl' : '', imageClassName)}
onError={(event) => {
swapImageToFallbackOnce(event, IMAGE_FALLBACK, { clearResponsive: true })
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
<div>
{resolvedMetricBadge?.label ? (
<BadgePill className={resolvedMetricBadge.className || 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30'} iconClass={resolvedMetricBadge.iconClass}>
{resolvedMetricBadge.label}
</BadgePill>
) : null}
{isMatureArtwork ? (
<BadgePill className="mt-2 bg-amber-500/16 text-amber-100 ring-amber-300/30" iconClass="fa-solid fa-triangle-exclamation text-[10px]">
Mature content
</BadgePill>
) : null}
</div>
{relativePublishedAt ? (
<BadgePill className="bg-black/45 text-white/75 ring-white/12" iconClass="fa-regular fa-clock text-[10px]">
{relativePublishedAt}
</BadgePill>
) : null}
</div>
) : null}
{showActions && (
<div className={cx(
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
relativePublishedAt ? 'top-12' : 'top-3'
)}>
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
</ActionButton>
<ActionLink href={downloadHref} label={downloadBusy ? 'Downloading artwork' : 'Download artwork'} onClick={handleDownload}>
<DownloadIcon className={cx('h-4 w-4', downloadBusy ? 'animate-pulse text-emerald-300' : '')} />
</ActionLink>
<ActionLink href={href} label="View artwork">
<ViewIcon className="h-4 w-4" />
</ActionLink>
{canAddToCollection ? (
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
<CollectionIcon className="h-4 w-4" />
</ActionButton>
) : null}
{canHideRecommendation ? (
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
</ActionButton>
) : null}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
{shouldBlurMature ? <div className="mb-2 inline-flex rounded-full border border-amber-300/20 bg-black/55 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your content settings</div> : null}
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
{title}
</h3>
{showAuthor ? (
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-white/80">
<span className="flex min-w-0 items-start gap-3">
<img
src={avatar}
alt={`Avatar of ${author}`}
loading="lazy"
decoding="async"
className="h-9 w-9 shrink-0 rounded-full object-cover"
onError={(event) => {
swapImageToFallbackOnce(event, AVATAR_FALLBACK)
}}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
{author}
{username ? <span className="text-[11px] text-white/60"> @{username}</span> : null}
</span>
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
</span>
{showStats && metadataLine && (
<span className="mt-0.5 block truncate text-[11px] text-white/70">
{metadataLine}
</span>
)}
</span>
</span>
</div>
) : showStats && metadataLine ? (
<div className="mt-1 text-[11px] text-white/70">
{metadataLine}
</div>
) : null}
{canDislikePrimaryTag ? (
<div className="pointer-events-auto mt-2">
<button
type="button"
onClick={handleDislikePrimaryTag}
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
</button>
</div>
) : null}
</div>
</div>
</div>
</article>
<CollectionPickerModal
open={collectionPickerOpen}
artworkTitle={title}
collections={collectionOptions}
loading={collectionOptionsLoading}
error={collectionPickerError}
notice={collectionPickerNotice}
createUrl={collectionCreateUrl}
attachingCollectionId={attachingCollectionId}
onAttach={handleAttachToCollection}
onClose={() => setCollectionPickerOpen(false)}
/>
</>
)
}

View File

@@ -0,0 +1,590 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import CommentForm from '../comments/CommentForm'
import ReactionBar from '../comments/ReactionBar'
import LevelBadge from '../xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 365) return `${days}d ago`
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/* ── Icons ─────────────────────────────────────────────────────────────────── */
function ReplyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
)
}
function ChatBubbleIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
)
}
function ChevronDownIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
)
}
/* ── Avatar ─────────────────────────────────────────────────────────────────── */
function Avatar({ user, size = 36 }) {
if (user?.avatar_url) {
return (
<img
src={user.avatar_url}
alt={user.name || user.username || ''}
width={size}
height={size}
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
style={{ width: size, height: size }}
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
}
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<span
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
style={{ width: size, height: size }}
>
{initials}
</span>
)
}
// ── Reply item (nested under a parent) ────────────────────────────────────────
function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, depth = 1 }) {
const user = reply.user
const html = reply.rendered_content ?? null
const plain = reply.content ?? reply.raw_content ?? ''
const profileLabel = user?.display || user?.username || user?.name || 'Member'
const replies = reply.replies || []
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
useEffect(() => {
if (reply.reactions || !reply.id) return
axios
.get(`/api/comments/${reply.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [reply.id, reply.reactions])
const handleReplyPosted = useCallback((newReply) => {
// Reply posts under THIS reply's id as parent
onReplyPosted?.(reply.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [reply.id, onReplyPosted])
// Show first 2 nested replies, expand to show all
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
// Shrink avatar at deeper levels
const avatarSize = depth >= 3 ? 22 : 28
return (
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
<div className="flex gap-2.5">
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
<Avatar user={user} size={avatarSize} />
</a>
) : (
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
{profileLabel}
</a>
) : (
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
)}
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={reply.created_at}
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
>
{reply.time_ago || timeAgo(reply.created_at)}
</time>
</div>
{html ? (
<div
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
)}
{/* Actions — Reply + React inline */}
<div className="flex items-center gap-1.5 pt-1">
{isLoggedIn && (
<button
type="button"
onClick={() => setShowReplyForm(v => !v)}
className={[
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
showReplyForm
? 'bg-accent/10 text-accent'
: 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
].join(' ')}
>
<ReplyIcon />
Reply
</button>
)}
<ReactionBar
entityType="comment"
entityId={reply.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
</div>
{/* Inline reply form */}
{showReplyForm && (
<div className="mt-2">
<CommentForm
artworkId={artworkId}
parentId={reply.id}
replyTo={profileLabel}
onCancelReply={() => setShowReplyForm(false)}
onPosted={handleReplyPosted}
isLoggedIn={isLoggedIn}
compact
/>
</div>
)}
{/* Nested replies (tree) */}
{replies.length > 0 && (
<div className="mt-2">
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
{visibleReplies.map((child) => (
<ReplyItem
key={child.id}
reply={child}
parentId={reply.id}
artworkId={artworkId}
isLoggedIn={isLoggedIn}
onReplyPosted={onReplyPosted}
depth={depth + 1}
/>
))}
</ul>
{!showAllReplies && hiddenReplyCount > 0 && (
<button
type="button"
onClick={() => setShowAllReplies(true)}
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
>
<ChevronDownIcon className="h-3 w-3" />
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
</button>
)}
</div>
)}
</div>
</div>
</li>
)
}
// ── Single comment (top-level) ────────────────────────────────────────────────
function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.content ?? comment.raw_content ?? ''
const profileLabel = user?.display || user?.username || user?.name || 'Member'
const replies = comment.replies || []
const flood = isFlood(plain)
const [expanded, setExpanded] = useState(!flood)
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
.get(`/api/comments/${comment.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [comment.id, comment.reactions])
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(comment.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [comment.id, onReplyPosted])
// Show first 2 replies by default, expand to show all
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
return (
<li
id={`comment-${comment.id}`}
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
>
<div className="p-4 sm:p-5">
<div className="flex gap-3.5">
{/* Avatar */}
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={38} />
</a>
) : (
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
)}
{/* Content */}
<div className="min-w-0 flex-1 space-y-2">
{/* Header */}
<div className="flex items-center gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
{profileLabel}
</a>
) : (
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
)}
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
>
{comment.time_ago || timeAgo(comment.created_at)}
</time>
</div>
{/* Body */}
<div
className={!expanded ? 'overflow-hidden relative' : undefined}
style={!expanded ? { maxHeight: '5em' } : undefined}
>
{html ? (
<div
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
)}
{flood && !expanded && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
)}
</div>
{flood && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
aria-expanded={expanded}
>
{expanded ? '↑ Collapse' : '↓ Show full comment'}
</button>
)}
{/* Actions */}
<div className="flex items-center gap-1.5 pt-0.5">
{isLoggedIn && (
<button
type="button"
onClick={() => setShowReplyForm(v => !v)}
className={[
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
showReplyForm
? 'bg-accent/10 text-accent'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
].join(' ')}
>
<ReplyIcon />
Reply
</button>
)}
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
</div>
</div>
</div>
</div>
{/* ── Replies thread ───────────────────────────────────────────────── */}
{(replies.length > 0 || showReplyForm) && (
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
{replies.length > 0 && (
<>
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
{visibleReplies.map((reply) => (
<ReplyItem
key={reply.id}
reply={reply}
parentId={comment.id}
artworkId={artworkId}
isLoggedIn={isLoggedIn}
onReplyPosted={onReplyPosted}
/>
))}
</ul>
{!showAllReplies && hiddenReplyCount > 0 && (
<button
type="button"
onClick={() => setShowAllReplies(true)}
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
>
<ChevronDownIcon className="h-3.5 w-3.5" />
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
</button>
)}
</>
)}
{/* Inline reply form */}
{showReplyForm && (
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
<CommentForm
artworkId={artworkId}
parentId={comment.id}
replyTo={profileLabel}
onCancelReply={() => setShowReplyForm(false)}
onPosted={handleReplyPosted}
isLoggedIn={isLoggedIn}
compact
/>
</div>
)}
</div>
)}
</li>
)
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function Skeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
style={{ animationDelay: `${i * 120}ms` }}
>
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
<div className="flex-1 space-y-3 pt-1">
<div className="flex gap-2.5">
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
</div>
<div className="space-y-2">
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
</div>
</div>
</div>
))}
</div>
)
}
// ── Main export ───────────────────────────────────────────────────────────────
export default function ArtworkComments({
artworkId,
comments: initialComments = [],
isLoggedIn = false,
loginUrl = '/login',
}) {
const [comments, setComments] = useState(initialComments)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [lastPage, setLastPage] = useState(1)
const [total, setTotal] = useState(initialComments.length)
const initialized = useRef(false)
const loadComments = useCallback(
async (p = 1) => {
if (!artworkId) return
setLoading(true)
try {
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
if (p === 1) {
setComments(data.data ?? [])
} else {
setComments((prev) => [...prev, ...(data.data ?? [])])
}
setPage(data.meta?.current_page ?? p)
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// keep existing
} finally {
setLoading(false)
}
},
[artworkId],
)
useEffect(() => {
if (initialized.current) return
initialized.current = true
if (artworkId && initialComments.length === 0) {
loadComments(1)
} else {
setTotal(initialComments.length)
}
}, [artworkId, initialComments.length, loadComments])
// New top-level comment posted
const handlePosted = useCallback((newComment) => {
// Ensure it has a replies array
const comment = { ...newComment, replies: newComment.replies || [] }
setComments((prev) => [comment, ...prev])
setTotal((t) => t + 1)
}, [])
// Reply posted under a parent comment (works at any nesting depth)
const handleReplyPosted = useCallback((parentId, newReply) => {
// Recursively find the parent node and append the reply
const insertReply = (nodes) =>
nodes.map((c) => {
if (c.id === parentId) {
return { ...c, replies: [...(c.replies || []), { ...newReply, replies: [] }] }
}
if (c.replies?.length) {
return { ...c, replies: insertReply(c.replies) }
}
return c
})
setComments((prev) => insertReply(prev))
setTotal((t) => t + 1)
}, [])
return (
<section aria-label="Comments" className="space-y-6">
{/* Section header */}
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
Comments
</h2>
{total > 0 && (
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
{total}
</span>
)}
</div>
{/* Comment list */}
{loading && comments.length === 0 ? (
<Skeleton />
) : comments.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
<ChatBubbleIcon />
<p className="text-sm font-medium text-white/40">No comments yet</p>
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
</div>
) : (
<>
<ul className="space-y-3 sm:space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
artworkId={artworkId}
isLoggedIn={isLoggedIn}
onReplyPosted={handleReplyPosted}
/>
))}
</ul>
{page < lastPage && (
<div className="flex justify-center pt-3">
<button
type="button"
disabled={loading}
onClick={() => loadComments(page + 1)}
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading
</span>
) : (
'Load more comments'
)}
</button>
</div>
)}
</>
)}
{/* Comment form — after all comments */}
{artworkId && (
<CommentForm
artworkId={artworkId}
onPosted={handlePosted}
isLoggedIn={isLoggedIn}
loginUrl={loginUrl}
/>
)}
</section>
)
}

View File

@@ -0,0 +1,38 @@
import React, { useState } from 'react'
const COLLAPSE_AT = 560
export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim()
const contentHtml = (artwork?.description_html || '').trim()
const collapsed = content.length > COLLAPSE_AT && !expanded
if (content.length === 0) return null
return (
<div>
<div
className={[
'max-w-[720px] overflow-hidden transition-[max-height] duration-300',
collapsed ? 'max-h-[11.5rem]' : 'max-h-[100rem]',
].join(' ')}
>
<div
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</div>
{content.length > COLLAPSE_AT && (
<button
type="button"
className="mt-3 text-sm font-medium text-accent transition-colors hover:text-accent/80"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import React, { useMemo } from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${number}`
}
function formatDate(value) {
if (!value) return '—'
try {
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return '—'
}
}
export default function ArtworkDetailsDrawer({ isOpen, onClose, artwork, stats }) {
const width = artwork?.dimensions?.width || artwork?.width || 0
const height = artwork?.dimensions?.height || artwork?.height || 0
const fileType = useMemo(() => {
const mime = artwork?.file?.mime_type || artwork?.mime_type || ''
if (mime) return mime
const url = artwork?.file?.url || artwork?.thumbs?.xl?.url || ''
const ext = url.split('.').pop()
return ext ? ext.toUpperCase() : '—'
}, [artwork])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[70]">
<button
type="button"
aria-label="Close details"
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-x-0 bottom-0 max-h-[90vh] overflow-y-auto rounded-t-3xl border border-white/10 bg-nova-900/85 p-5 backdrop-blur xl:inset-auto xl:right-6 xl:top-24 xl:w-[34rem] xl:rounded-3xl xl:border-white/15 xl:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-white">Details</h2>
<button
type="button"
aria-label="Close details drawer"
onClick={onClose}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="rounded-2xl border border-white/10 bg-black/15 p-4">
<ArtworkBreadcrumbs artwork={artwork} />
</div>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Upload date</dt>
<dd className="mt-1 font-medium text-white">{formatDate(artwork?.published_at)}</dd>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">File type</dt>
<dd className="mt-1 font-medium text-white">{fileType}</dd>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Views</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats?.views)}</dd>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Downloads</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats?.downloads)}</dd>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
<dt className="text-soft">Favorites</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats?.favorites)}</dd>
</div>
</dl>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import React from 'react'
function formatCount(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return n.toLocaleString()
}
function formatDate(value) {
if (!value) return '—'
try {
const d = new Date(value)
const now = Date.now()
const diff = now - d.getTime()
const days = Math.floor(diff / 86_400_000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 30) return `${days} days ago`
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return '—'
}
}
/* ── Stat tile shown in the 2-col grid ─────────────────────────────────── */
function StatTile({ icon, label, value }) {
return (
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] px-3 py-3.5">
<span className="text-white/30">{icon}</span>
<span className="text-base font-semibold tabular-nums text-white/90">{value}</span>
<span className="text-[11px] uppercase tracking-wider text-white/35">{label}</span>
</div>
)
}
/* ── Key-value row ─────────────────────────────────────────────────────── */
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-2">
<span className="text-xs uppercase tracking-wider text-white/35">{label}</span>
<span className="text-sm font-medium text-white/80">{value}</span>
</div>
)
}
export default function ArtworkDetailsPanel({ artwork, stats }) {
const width = artwork?.dimensions?.width || artwork?.width || 0
const height = artwork?.dimensions?.height || artwork?.height || 0
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
{/* Stats grid */}
<div className="grid grid-cols-2 gap-2.5">
<StatTile
icon={
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
}
label="Views"
value={formatCount(stats?.views)}
/>
<StatTile
icon={
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
}
label="Downloads"
value={formatCount(stats?.downloads)}
/>
</div>
{/* Info rows */}
<div className="mt-4 divide-y divide-white/[0.05]">
{resolution && <InfoRow label="Resolution" value={resolution} />}
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
</div>
</section>
)
}

View File

@@ -0,0 +1,240 @@
import React, { useState } from 'react'
import Modal from '../ui/Modal'
function EvolutionArtworkCard({ card }) {
if (!card) return null
const shouldBlur = Boolean(card?.maturity?.should_blur)
return (
<a
href={card.url}
className="group block overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.03))] transition hover:border-white/20 hover:bg-white/[0.07]"
>
<div className="relative aspect-[1.08/1] overflow-hidden bg-slate-950">
{card.thumbnail ? (
<img
src={card.thumbnail}
alt={card.title}
className={`h-full w-full object-cover transition duration-500 group-hover:scale-[1.03] ${shouldBlur ? 'scale-[1.03] blur-xl' : ''}`}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
<i className="fa-solid fa-image text-3xl" />
</div>
)}
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
<span className="h-1.5 w-1.5 rounded-full bg-sky-300" />
{card.role_label}
</div>
{shouldBlur ? (
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(15,23,42,0),rgba(15,23,42,0.9))] px-4 py-4 text-sm text-white/85">
Mature artwork preview is softened for your current viewer settings.
</div>
) : null}
</div>
<div className="space-y-2 px-4 py-4 sm:px-5">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
{card.content_type ? <span>{card.content_type}</span> : null}
{card.category ? <span className="text-slate-500">{card.category}</span> : null}
</div>
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{card.title}</h3>
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-300">
<span>{card.publisher}</span>
{card.year ? <span className="text-slate-500">{card.year}</span> : null}
</div>
</div>
</a>
)
}
function ComparisonModal({ item, open, onClose }) {
if (!item) return null
return (
<Modal open={open} onClose={onClose} title={item.compare?.title || 'Compare versions'} size="full">
<div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{item.heading}</div>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{item.relation_label}</h3>
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">{item.summary}</p>
</div>
{item.years_apart_label ? (
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-slate-200">
<i className="fa-regular fa-clock" aria-hidden="true" />
{item.years_apart_label}
</div>
) : null}
</div>
<div className="grid gap-5 lg:grid-cols-2">
{[item.before, item.after].map((card) => (
<div key={`${item.id}-${card.id}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{card.role_label}</div>
<div className="mt-1 text-base font-semibold text-white">{card.title}</div>
</div>
<a href={card.url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
Open
<i className="fa-solid fa-arrow-up-right-from-square text-slate-500" aria-hidden="true" />
</a>
</div>
<div className="relative aspect-[4/3] overflow-hidden bg-slate-950">
{card.image_lg ? (
<img
src={card.image_lg}
alt={card.title}
className={`h-full w-full object-cover ${card?.maturity?.should_blur ? 'scale-[1.03] blur-xl' : ''}`}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
<i className="fa-solid fa-image text-4xl" />
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 px-5 py-4 text-sm text-slate-300">
<span>{card.publisher}</span>
{card.year ? <span className="text-slate-500">{card.year}</span> : null}
{card.category ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{card.category}</span> : null}
</div>
</div>
))}
</div>
{item.note ? (
<div className="rounded-[26px] border border-sky-300/20 bg-sky-300/10 px-5 py-4 text-sm leading-7 text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">Creator note</div>
<p className="mt-2 whitespace-pre-wrap">{item.note}</p>
</div>
) : null}
</div>
</Modal>
)
}
function EvolutionStoryBlock({ item, onCompare }) {
if (!item) return null
return (
<section className="overflow-hidden rounded-[30px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.025))] p-5 shadow-[0_22px_55px_rgba(2,6,23,0.26)] backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-2xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{item.heading}</div>
<h2 className="mt-2 text-[28px] font-semibold tracking-[-0.04em] text-white">{item.relation_label}</h2>
<p className="mt-2 text-sm leading-7 text-slate-200/90">{item.summary}</p>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{item.years_apart_label ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">
<i className="fa-regular fa-clock" aria-hidden="true" />
{item.years_apart_label}
</span>
) : null}
{item.compare?.available ? (
<button
type="button"
onClick={() => onCompare(item)}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18"
>
<i className="fa-solid fa-up-right-and-down-left-from-center" aria-hidden="true" />
Compare side by side
</button>
) : null}
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<EvolutionArtworkCard card={item.before} />
<EvolutionArtworkCard card={item.after} />
</div>
{item.note ? (
<div className="mt-5 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 text-sm leading-7 text-slate-200/90">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Creator note</div>
<p className="mt-2 whitespace-pre-wrap">{item.note}</p>
</div>
) : null}
</section>
)
}
function EvolutionUpdates({ updates, onCompare }) {
if (!updates?.length) return null
return (
<section className="rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_22px_55px_rgba(0,0,0,0.18)] backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/55">Updated Versions</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">This piece has later evolutions</h2>
</div>
<div className="text-sm text-slate-400">Follow how the creator revisited the idea over time.</div>
</div>
<div className="mt-5 space-y-4">
{updates.map((item) => (
<article key={item.id} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 sm:p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-2xl">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{item.heading}</div>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">{item.after?.title}</h3>
<p className="mt-2 text-sm leading-7 text-slate-300">{item.summary}</p>
</div>
<div className="flex flex-wrap gap-2">
{item.years_apart_label ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">
<i className="fa-regular fa-clock" aria-hidden="true" />
{item.years_apart_label}
</span>
) : null}
{item.compare?.available ? (
<button
type="button"
onClick={() => onCompare(item)}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
Compare
</button>
) : null}
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<EvolutionArtworkCard card={item.before} />
<EvolutionArtworkCard card={item.after} />
</div>
{item.note ? <p className="mt-4 text-sm leading-7 text-slate-300">{item.note}</p> : null}
</article>
))}
</div>
</section>
)
}
export default function ArtworkEvolutionPanel({ evolution }) {
const [compareItem, setCompareItem] = useState(null)
if (!evolution?.primary && !evolution?.updates?.length) {
return null
}
return (
<>
<div className="space-y-5">
{evolution.primary ? <EvolutionStoryBlock item={evolution.primary} onCompare={setCompareItem} /> : null}
{evolution.updates?.length ? <EvolutionUpdates updates={evolution.updates} onCompare={setCompareItem} /> : null}
</div>
<ComparisonModal item={compareItem} open={Boolean(compareItem)} onClose={() => setCompareItem(null)} />
</>
)
}

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react'
function SelectedArtworkCard({ artwork, onClear, disabled = false }) {
if (!artwork) return null
return (
<div className="rounded-[26px] border border-sky-300/20 bg-sky-400/[0.08] p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-4">
{artwork.thumbnail ? (
<img src={artwork.thumbnail} alt={artwork.title} className="h-20 w-20 rounded-[22px] object-cover ring-1 ring-white/10" />
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-[22px] border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-image" />
</div>
)}
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Linked original</div>
<div className="mt-2 truncate text-lg font-semibold text-white">{artwork.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-300">
<span>{artwork.publisher || 'Artist'}</span>
{artwork.year ? <span className="text-slate-500">{artwork.year}</span> : null}
{artwork.content_type ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{artwork.content_type}</span> : null}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{artwork.url ? (
<a
href={artwork.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
Open public
<i className="fa-solid fa-arrow-up-right-from-square text-slate-500" />
</a>
) : null}
<button
type="button"
onClick={onClear}
disabled={disabled}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-link-slash" />
Remove link
</button>
</div>
</div>
</div>
)
}
export default function ArtworkEvolutionSearchPicker({ artworkId, selected, onSelect, disabled = false }) {
const [query, setQuery] = useState('')
const [options, setOptions] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!artworkId) return undefined
const controller = new AbortController()
const handle = window.setTimeout(async () => {
setLoading(true)
try {
const response = await fetch(`/api/studio/artworks/${artworkId}/evolution-options?search=${encodeURIComponent(query)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
})
if (!response.ok) {
setOptions([])
return
}
const data = await response.json()
setOptions(Array.isArray(data.data) ? data.data : [])
} catch (error) {
if (error?.name !== 'AbortError') {
setOptions([])
}
} finally {
setLoading(false)
}
}, query.trim() === '' ? 0 : 220)
return () => {
controller.abort()
window.clearTimeout(handle)
}
}, [artworkId, query])
const visibleOptions = options.filter((option) => Number(option.id) !== Number(selected?.id))
return (
<div className="space-y-4">
<SelectedArtworkCard artwork={selected} onClear={() => onSelect(null)} disabled={disabled} />
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-w-0 flex-1">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Search your manageable artworks</label>
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by title, slug, creator, or group"
disabled={disabled}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{loading ? 'Searching…' : `${visibleOptions.length} result${visibleOptions.length === 1 ? '' : 's'}`}
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-slate-400">
Start with your own published artworks. Group-published pieces appear too when you can publish artworks for that group.
</p>
<div className="mt-4 space-y-3">
{visibleOptions.length ? visibleOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onSelect(option)}
disabled={disabled}
className="flex w-full flex-col gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-4 text-left transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-60 md:flex-row md:items-center md:justify-between"
>
<div className="flex min-w-0 items-center gap-4">
{option.thumbnail ? (
<img src={option.thumbnail} alt={option.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-500">
<i className="fa-solid fa-image" />
</div>
)}
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white">{option.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-400">
<span>{option.publisher || 'Artist'}</span>
{option.year ? <span>{option.year}</span> : null}
{option.category ? <span>{option.category}</span> : null}
</div>
</div>
</div>
<span className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 md:self-center">
<i className="fa-solid fa-link" />
Link older version
</span>
</button>
)) : (
<div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center text-sm text-slate-300">
{loading ? 'Searching artworks…' : 'No manageable published artworks matched this search yet.'}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArtworkCard from './ArtworkCard'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function revokeDismissSignal(entry) {
const item = entry?.item || null
const kind = entry?.kind || null
if (!item || !kind) {
throw new Error('missing_dismiss_entry')
}
const endpoint = kind === 'dislike-tag'
? item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint
: item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint
if (!endpoint) {
throw new Error('missing_revoke_endpoint')
}
const payload = kind === 'dislike-tag'
? {
tag_id: item?.primary_tag?.id ? Number(item.primary_tag.id) : undefined,
tag_slug: item?.primary_tag?.slug || item?.primary_tag?.name || undefined,
artwork_id: Number(item.id),
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
reason: item?.recommendation_reason || null,
interaction_origin: 'artwork-gallery-undo-dislike-tag',
},
}
: {
artwork_id: Number(item.id),
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
reason: item?.recommendation_reason || null,
interaction_origin: 'artwork-gallery-undo-hide',
},
}
const response = await fetch(endpoint, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('revoke_request_failed')
}
return response.json().catch(() => null)
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function getArtworkKey(item, index) {
if (item?.id) return item.id
if (item?.title || item?.name || item?.author) {
return `${item.title || item.name || 'artwork'}-${item.author || item.author_name || item.uname || 'artist'}-${index}`
}
return `artwork-${index}`
}
function DismissNotice({ notice, onUndo, onClose }) {
if (!notice) return null
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
<div className="pointer-events-auto rounded-2xl border border-amber-300/30 bg-slate-950/92 px-4 py-3 text-amber-50 shadow-2xl shadow-black/40 backdrop-blur">
<p className="text-[11px] uppercase tracking-[0.2em] text-amber-100/70">Discovery Feedback</p>
<p className="mt-1 text-sm font-medium">{notice.message}</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={onUndo}
disabled={notice.busy}
className="inline-flex items-center rounded-full border border-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/85 transition hover:border-white/30 hover:text-white"
>
{notice.busy ? 'Undoing...' : 'Undo'}
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/55 transition hover:text-white/85"
>
Dismiss
</button>
</div>
</div>
</div>
)
}
export default function ArtworkGallery({
items,
layout = 'grid',
compact = false,
showStats = true,
showAuthor = true,
className = '',
cardClassName = '',
limit,
containerProps = {},
resolveCardProps,
children,
}) {
if (!Array.isArray(items) || items.length === 0) return null
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
const [dismissedEntries, setDismissedEntries] = useState([])
const [dismissNotice, setDismissNotice] = useState(null)
const visibleArtworkItems = useMemo(
() => visibleItems.filter((item) => !item?.maturity?.should_hide && !dismissedEntries.some((entry) => entry.item?.id === item?.id)),
[dismissedEntries, visibleItems]
)
const baseClassName = layout === 'masonry'
? 'grid gap-6'
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
useEffect(() => {
if (!dismissNotice) {
return undefined
}
const timeoutId = window.setTimeout(() => {
setDismissNotice(null)
}, 3200)
return () => {
window.clearTimeout(timeoutId)
}
}, [dismissNotice])
function handleDismissed(item, kind) {
if (!item?.id) return
setDismissedEntries((current) => {
const next = current.filter((entry) => entry.item?.id !== item.id)
next.push({ item, kind })
return next
})
setDismissNotice({
itemId: item.id,
busy: false,
message: kind === 'dislike-tag'
? `We will show less content like #${item?.primary_tag?.slug || item?.primary_tag?.name || 'this tag'}.`
: 'Artwork hidden from this recommendation view.',
})
}
async function handleUndoDismiss() {
if (!dismissNotice?.itemId) {
setDismissNotice(null)
return
}
const entry = dismissedEntries.find((current) => current.item?.id === dismissNotice.itemId)
if (!entry) {
setDismissNotice(null)
return
}
setDismissNotice((current) => current ? { ...current, busy: true } : current)
try {
await revokeDismissSignal(entry)
} catch {
setDismissNotice({
itemId: entry.item.id,
busy: false,
message: 'Undo failed. The feedback signal is still active.',
})
return
}
setDismissedEntries((current) => current.filter((entry) => entry.item?.id !== dismissNotice.itemId))
setDismissNotice(null)
}
return (
<>
<div className={cx(baseClassName, className)} {...containerProps}>
{visibleArtworkItems.map((item, index) => {
const cardProps = resolveCardProps?.(item, index) || {}
const { className: resolvedClassName = '', ...restCardProps } = cardProps
return (
<ArtworkCard
key={getArtworkKey(item, index)}
artwork={item}
compact={compact}
showStats={showStats}
showAuthor={showAuthor}
className={cx(cardClassName, resolvedClassName)}
onDismissed={handleDismissed}
{...restCardProps}
/>
)
})}
{children}
</div>
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
</>
)
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import ArtworkGallery from './ArtworkGallery'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function ArtworkGalleryGrid({
items,
compact = false,
showStats = true,
showAuthor = true,
limit,
className = '',
cardClassName = '',
}) {
if (!Array.isArray(items) || items.length === 0) return null
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
return (
<ArtworkGallery
items={visibleItems}
layout="grid"
compact={compact}
showStats={showStats}
showAuthor={showAuthor}
className={cx(className)}
cardClassName={cardClassName}
/>
)
}

View File

@@ -0,0 +1,211 @@
import React, { useState, useCallback, useEffect } from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, mediaWidth = null, mediaHeight = null, mediaKey = 'cover', onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
const [isLoaded, setIsLoaded] = useState(false)
const [mainImageMode, setMainImageMode] = useState('primary')
const [previewImageMode, setPreviewImageMode] = useState('primary')
const [showBackdrop, setShowBackdrop] = useState(true)
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
const xlSource = presentXl?.url || artwork?.thumbs?.xl?.url || null
const md = mdSource || FALLBACK_MD
const lg = lgSource || FALLBACK_LG
const xl = xlSource || FALLBACK_XL
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
const blurBackdropSrc = mdSource || lgSource || xlSource || null
const primaryMainSrc = lgSource || xlSource || mdSource || FALLBACK_LG
const primaryPreviewSrc = mdSource || lgSource || xlSource || FALLBACK_MD
const srcSet = [
mdSource ? `${mdSource} 640w` : null,
lgSource ? `${lgSource} 1280w` : null,
xlSource ? `${xlSource} 1920w` : null,
].filter(Boolean).join(', ')
const resolvedMainSrc = mainImageMode === 'fallback'
? FALLBACK_LG
: (mainImageMode === 'hidden' ? null : primaryMainSrc)
const resolvedPreviewSrc = previewImageMode === 'fallback'
? FALLBACK_MD
: (previewImageMode === 'hidden' ? null : primaryPreviewSrc)
const dbWidth = Number(mediaWidth ?? artwork?.width)
const dbHeight = Number(mediaHeight ?? artwork?.height)
const hasDbDims = dbWidth > 0 && dbHeight > 0
// Natural dimensions — seeded from DB if available, otherwise probed from
// the xl thumbnail (largest available, never upscaled past the original).
const [naturalDims, setNaturalDims] = useState(
hasDbDims ? { w: dbWidth, h: dbHeight } : null
)
useEffect(() => {
setIsLoaded(false)
setMainImageMode('primary')
setPreviewImageMode('primary')
setShowBackdrop(true)
if (hasDbDims) {
setNaturalDims({ w: dbWidth, h: dbHeight })
return
}
setNaturalDims(null)
}, [mediaKey, hasDbDims, dbWidth, dbHeight])
// Probe the xl image to discover real dimensions when DB has none
useEffect(() => {
if (naturalDims || !xlSource) return
const img = new Image()
img.onload = () => {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
}
}
img.onerror = null
img.src = xlSource
}, [xlSource, naturalDims])
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
return (
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
{blurBackdropSrc && showBackdrop && (
<>
<img
src={blurBackdropSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
loading="eager"
decoding="async"
onError={(event) => {
event.currentTarget.onerror = null
setShowBackdrop(false)
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
</>
)}
<div className="relative mx-auto flex w-full max-w-[1400px] items-center gap-2 sm:gap-4">
<div className="hidden w-12 shrink-0 justify-center sm:flex">
{hasPrev && (
<button
type="button"
aria-label="Previous artwork"
onClick={() => onPrev?.()}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
</div>
<div className="relative min-w-0 flex-1">
<div
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden transition-[max-width] duration-300 ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
style={{ aspectRatio, maxWidth: naturalDims ? `${naturalDims.w}px` : undefined }}
onClick={onOpenViewer}
role={onOpenViewer ? 'button' : undefined}
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
tabIndex={onOpenViewer ? 0 : undefined}
onKeyDown={onOpenViewer ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenViewer()
}
} : undefined}
>
{resolvedPreviewSrc ? (
<img
src={resolvedPreviewSrc}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
onError={(event) => {
event.currentTarget.onerror = null
if (previewImageMode === 'primary') {
setPreviewImageMode('fallback')
return
}
setPreviewImageMode('hidden')
}}
/>
) : null}
{resolvedMainSrc ? (
<img
src={resolvedMainSrc}
srcSet={mainImageMode === 'primary' && srcSet !== '' ? srcSet : undefined}
sizes={mainImageMode === 'primary' && srcSet !== '' ? '(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw' : undefined}
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.onerror = null
if (mainImageMode === 'primary') {
setMainImageMode('fallback')
setIsLoaded(false)
return
}
setMainImageMode('hidden')
setIsLoaded(true)
}}
/>
) : null}
{onOpenViewer && (
<button
type="button"
aria-label="View fullscreen"
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 shadow-lg ring-1 ring-white/15 backdrop-blur-sm opacity-0 transition-opacity duration-150 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:opacity-100 [div:hover_&]:opacity-100"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
)}
</div>
{hasRealArtworkImage && (
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
)}
</div>
<div className="hidden w-12 shrink-0 justify-center sm:flex">
{hasNext && (
<button
type="button"
aria-label="Next artwork"
onClick={() => onNext?.()}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</figure>
)
}

View File

@@ -0,0 +1,69 @@
import React from 'react'
export default function ArtworkMediaStrip({ items = [], selectedId = null, onSelect }) {
if (!Array.isArray(items) || items.length <= 1) {
return null
}
return (
<div className="mt-4 rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[0_18px_60px_rgba(2,8,23,0.24)] backdrop-blur">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/45">Gallery</p>
<p className="mt-1 text-sm text-white/60">Switch between the default cover and additional archive screenshots.</p>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] text-white/65">
{items.length} views
</span>
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{items.map((item) => {
const active = item.id === selectedId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
aria-pressed={active}
className={[
'group shrink-0 rounded-2xl border p-2 text-left transition-all',
active
? 'border-sky-300/45 bg-sky-400/12 shadow-[0_0_0_1px_rgba(56,189,248,0.18)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div className="h-20 w-28 overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 sm:h-24 sm:w-36">
{item.thumbUrl ? (
<img
src={item.thumbUrl}
alt={item.label}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
/>
) : (
<div className="grid h-full w-full place-items-center text-white/30">
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<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>
</div>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-2 px-1">
<span className="truncate text-xs font-medium text-white/80">{item.label}</span>
<span className={[
'rounded-full px-2 py-0.5 text-[10px]',
active ? 'bg-sky-300/20 text-sky-100' : 'bg-white/10 text-white/45',
].join(' ')}>
{active ? 'Showing' : 'View'}
</span>
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
export default function ArtworkMeta({ artwork }) {
const publisher = artwork?.publisher || null
const credits = artwork?.credits || {}
const primaryAuthor = credits?.primary_author || artwork?.user || null
const contributors = Array.isArray(credits?.contributors) ? credits.contributors : []
return (
<div>
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-300">
{publisher?.type === 'group' ? (
<a href={publisher.profile_url} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Published by</span>
<span className="font-semibold">{publisher.name}</span>
</a>
) : null}
{primaryAuthor ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Primary author</span>
{primaryAuthor.profile_url ? <a href={primaryAuthor.profile_url} className="font-semibold text-white hover:text-sky-200">{primaryAuthor.name || primaryAuthor.username}</a> : <span className="font-semibold text-white">{primaryAuthor.name || primaryAuthor.username}</span>}
</span>
) : null}
{contributors.length > 0 ? (
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contributors</span>
</span>
{contributors.map((item) => {
const label = item.name || item.username
return (
<span key={item.id || label} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
{item.profile_url ? <a href={item.profile_url} className="font-semibold text-white hover:text-sky-200">{label}</a> : <span className="font-semibold text-white">{label}</span>}
{item.credit_role ? <span className="text-slate-400">{item.credit_role}</span> : null}
{item.is_primary ? <span className="rounded-full border border-emerald-300/30 bg-emerald-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Lead support</span> : null}
</span>
)
})}
</div>
) : null}
</div>
<div className="mt-3">
<ArtworkBreadcrumbs artwork={artwork} />
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import ReactionBar from '../comments/ReactionBar'
/**
* Loads and displays reactions for a single artwork.
*
* Props:
* artworkId number
* isLoggedIn boolean
*/
export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
const [totals, setTotals] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!artworkId) return
axios
.get(`/api/artworks/${artworkId}/reactions`)
.then(({ data }) => setTotals(data.totals ?? {}))
.catch(() => setTotals({}))
.finally(() => setLoading(false))
}, [artworkId])
if (loading) return null
if (!totals || Object.values(totals).every((r) => r.count === 0) && !isLoggedIn) {
return null
}
return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-white/30">Reactions</h2>
<ReactionBar
entityType="artwork"
entityId={artworkId}
initialTotals={totals}
isLoggedIn={isLoggedIn}
/>
</section>
)
}

View File

@@ -0,0 +1,445 @@
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
/* ── normalizers ─────────────────────────────────────────────────── */
function normalizeRelated(item) {
if (!item?.url) return null
return {
id: item.id || item.slug || item.url,
title: item.title || 'Untitled',
author: item.author || 'Artist',
authorAvatar: item.author_avatar || null,
url: item.url,
thumb: item.thumb || null,
thumbSrcSet: item.thumb_srcset || null,
maturity: item.maturity || null,
}
}
function normalizeSimilar(item) {
if (!item?.url) return null
return {
id: item.id || item.slug || item.url,
title: item.title || 'Untitled',
author: item.author || 'Artist',
authorAvatar: item.author_avatar || null,
url: item.url,
thumb: item.thumb || null,
thumbSrcSet: item.thumb_srcset || null,
maturity: item.maturity || null,
}
}
function normalizeRankItem(item) {
const url = item?.urls?.direct || item?.urls?.web || item?.url || null
if (!url) return null
return {
id: item.id || item.slug || url,
title: item.title || 'Untitled',
author: item?.author?.name || 'Artist',
authorAvatar: item?.author?.avatar_url || null,
url,
thumb: item.thumbnail_url || item.thumb || null,
thumbSrcSet: null,
maturity: item.maturity || null,
}
}
function dedupeByUrl(items) {
const seen = new Set()
return items.filter((item) => {
if (item?.maturity?.should_hide) return false
if (!item?.url || seen.has(item.url)) return false
seen.add(item.url)
return true
})
}
/* ── Large art card (matches homepage style) ─────────────────── */
function RailCard({ item }) {
const shouldBlur = Boolean(item?.maturity?.should_blur)
const isMature = Boolean(item?.maturity?.is_mature_effective)
return (
<article className="w-[240px] shrink-0 snap-start sm:w-[220px] lg:w-[200px] xl:w-[210px] 2xl:w-[220px]">
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
{/* Gloss sheen */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
srcSet={item.thumbSrcSet || undefined}
sizes="220px"
alt={item.title || 'Artwork'}
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
{isMature ? <div className="absolute left-3 top-3 z-20 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature</div> : null}
{shouldBlur ? <div className="absolute inset-x-3 bottom-3 z-20 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your settings</div> : null}
{/* Bottom info overlay */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.authorAvatar || AVATAR_FALLBACK}
alt={item.author}
className="w-5 h-5 rounded-full object-cover shrink-0 ring-1 ring-white/20"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
/* ── Scroll arrow button ─────────────────────────────────────── */
function ScrollBtn({ direction, onClick, visible }) {
if (!visible) return null
const isLeft = direction === 'left'
return (
<button
onClick={onClick}
aria-label={`Scroll ${direction}`}
className={`absolute top-1/2 z-30 -translate-y-1/2 hidden lg:flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/80 ${isLeft ? 'left-2' : 'right-2'}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{isLeft
? <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />}
</svg>
</button>
)
}
/* ── Rail section (infinite loop + mouse-wheel scroll) ───────── */
function Rail({ title, emoji, items, seeAllHref }) {
const scrollRef = useRef(null)
const isResettingRef = useRef(false)
const scrollEndTimer = useRef(null)
const suppressClickTimerRef = useRef(null)
const touchStartRef = useRef({ x: 0, y: 0 })
const draggedRef = useRef(false)
const suppressClickRef = useRef(false)
const itemCount = items.length
/* Triple items so we can loop seamlessly: [clone|original|clone] */
const loopItems = useMemo(() => {
if (!items.length) return []
return [...items, ...items, ...items]
}, [items])
/* Pixel width of one item-set (measured from the DOM) */
const getSetWidth = useCallback(() => {
const el = scrollRef.current
if (!el || el.children.length < itemCount + 1) return 0
return el.children[itemCount].offsetLeft - el.children[0].offsetLeft
}, [itemCount])
/* Scroll step based on rendered card width + gap for predictable smooth motion */
const getStepWidth = useCallback(() => {
const el = scrollRef.current
if (!el || el.children.length < 2) return el ? el.clientWidth * 0.75 : 0
return el.children[1].offsetLeft - el.children[0].offsetLeft
}, [])
/* Centre on the middle (real) set after mount / data change */
useEffect(() => {
const el = scrollRef.current
if (!el || !itemCount) return
requestAnimationFrame(() => {
const sw = getSetWidth()
if (sw) {
el.style.scrollBehavior = 'auto'
el.scrollLeft = sw
el.style.scrollBehavior = ''
}
})
}, [loopItems, getSetWidth, itemCount])
/* After scroll settles, silently jump back to the middle set if in a clone zone */
const resetIfNeeded = useCallback(() => {
if (isResettingRef.current) return
const el = scrollRef.current
if (!el || !itemCount) return
const setW = getSetWidth()
if (setW === 0) return
if (el.scrollLeft < setW) {
isResettingRef.current = true
el.style.scrollBehavior = 'auto'
el.scrollLeft += setW
el.style.scrollBehavior = ''
requestAnimationFrame(() => { isResettingRef.current = false })
} else if (el.scrollLeft >= setW * 2) {
isResettingRef.current = true
el.style.scrollBehavior = 'auto'
el.scrollLeft -= setW
el.style.scrollBehavior = ''
requestAnimationFrame(() => { isResettingRef.current = false })
}
}, [getSetWidth, itemCount])
/* Keep user in the centre segment before scripted smooth scroll starts */
const normalizeToMiddle = useCallback(() => {
const el = scrollRef.current
if (!el || !itemCount) return
const setW = getSetWidth()
if (setW === 0) return
if (el.scrollLeft < setW || el.scrollLeft >= setW * 2) {
el.style.scrollBehavior = 'auto'
el.scrollLeft = ((el.scrollLeft % setW) + setW) % setW + setW
el.style.scrollBehavior = ''
}
}, [getSetWidth, itemCount])
/* Scroll listener: debounced boundary check + resize re-centre */
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onScroll = () => {
clearTimeout(scrollEndTimer.current)
scrollEndTimer.current = setTimeout(resetIfNeeded, 80)
}
el.addEventListener('scroll', onScroll, { passive: true })
const onResize = () => {
const sw = getSetWidth()
if (sw) {
el.style.scrollBehavior = 'auto'
el.scrollLeft = sw
el.style.scrollBehavior = ''
}
}
window.addEventListener('resize', onResize)
return () => {
el.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
clearTimeout(scrollEndTimer.current)
clearTimeout(suppressClickTimerRef.current)
}
}, [loopItems, resetIfNeeded, getSetWidth])
/* Mouse-wheel → horizontal scroll (re-attach when items arrive) */
useEffect(() => {
const el = scrollRef.current
if (!el || !loopItems.length) return
const onWheel = (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault()
el.scrollLeft += e.deltaY
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [loopItems])
const scroll = useCallback((dir) => {
const el = scrollRef.current
if (!el) return
normalizeToMiddle()
const step = getStepWidth()
const amount = step > 0 ? step * 2 : el.clientWidth * 0.75
el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' })
clearTimeout(scrollEndTimer.current)
scrollEndTimer.current = setTimeout(resetIfNeeded, 260)
}, [getStepWidth, normalizeToMiddle, resetIfNeeded])
/* Prevent accidental link activation after horizontal swipe on touch devices */
const onTouchStart = useCallback((e) => {
if (!e.touches?.length) return
const t = e.touches[0]
touchStartRef.current = { x: t.clientX, y: t.clientY }
draggedRef.current = false
}, [])
const onTouchMove = useCallback((e) => {
if (!e.touches?.length) return
const t = e.touches[0]
const dx = Math.abs(t.clientX - touchStartRef.current.x)
const dy = Math.abs(t.clientY - touchStartRef.current.y)
if (dx > 10 && dx > dy) {
draggedRef.current = true
}
}, [])
const onTouchEnd = useCallback(() => {
if (!draggedRef.current) return
suppressClickRef.current = true
clearTimeout(suppressClickTimerRef.current)
suppressClickTimerRef.current = setTimeout(() => {
suppressClickRef.current = false
}, 260)
}, [])
const onClickCapture = useCallback((e) => {
if (!suppressClickRef.current) return
const link = e.target?.closest?.('a')
if (link) {
e.preventDefault()
e.stopPropagation()
}
}, [])
if (!items.length) return null
return (
<section>
<div className="mb-5 flex items-center justify-between px-4 sm:px-6 lg:px-8">
<h2 className="text-xl font-bold text-white">
{emoji && <span className="mr-1.5">{emoji}</span>}{title}
</h2>
{seeAllHref && (
<a href={seeAllHref} className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
)}
</div>
<div className="relative" data-nav-swipe-ignore="1">
{/* Permanent edge fades for infinite illusion */}
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 w-24 bg-gradient-to-r from-[#0F1724] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 z-20 w-24 bg-gradient-to-l from-[#0F1724] to-transparent" />
<ScrollBtn direction="left" onClick={() => scroll('left')} visible={true} />
<ScrollBtn direction="right" onClick={() => scroll('right')} visible={true} />
<div
ref={scrollRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onClickCapture={onClickCapture}
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 snap-x snap-mandatory scroll-smooth scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{loopItems.map((item, idx) => (
<RailCard key={`${item.id || item.url}-${idx}`} item={item} />
))}
</div>
</div>
</section>
)
}
/* ── Main export ─────────────────────────────────────────────── */
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
const [similarApiItems, setSimilarApiItems] = useState([])
const [similarLoaded, setSimilarLoaded] = useState(false)
const [trendingItems, setTrendingItems] = useState([])
const relatedCards = useMemo(() => {
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
}, [related])
useEffect(() => {
let isCancelled = false
const loadSimilar = async () => {
if (!artwork?.id) {
setSimilarApiItems([])
setSimilarLoaded(true)
return
}
try {
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('similar fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
if (!isCancelled) {
setSimilarApiItems(items)
setSimilarLoaded(true)
}
} catch {
if (!isCancelled) {
setSimilarApiItems([])
setSimilarLoaded(true)
}
}
}
loadSimilar()
return () => {
isCancelled = true
}
}, [artwork?.id])
useEffect(() => {
let isCancelled = false
const loadTrending = async () => {
const categoryId = artwork?.categories?.[0]?.id
if (!categoryId) {
setTrendingItems([])
return
}
try {
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('trending fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
if (!isCancelled) setTrendingItems(items)
} catch {
if (!isCancelled) setTrendingItems([])
}
}
loadTrending()
return () => {
isCancelled = true
}
}, [artwork?.categories])
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
const tagBasedFallback = useMemo(() => {
return relatedCards.filter((item) => String(item.author || '').trim().toLowerCase() !== authorName)
}, [relatedCards, authorName])
const similarItems = useMemo(() => {
if (!similarLoaded) return []
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
return trendingItems.slice(0, 12)
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
if (similarItems.length === 0 && trendingRailItems.length === 0) return null
const categoryName = artwork?.categories?.[0]?.name
const trendingLabel = categoryName
? `Trending in ${categoryName}`
: 'Trending'
const trendingHref = categoryName
? `/discover/trending`
: '/discover/trending'
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
return (
<div className="space-y-14">
<Rail title="Similar Artworks" emoji="✨" items={similarItems} seeAllHref={similarHref} />
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
</div>
)
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
import ArtworkGalleryGrid from './ArtworkGalleryGrid'
export default function ArtworkRelated({ related }) {
if (!Array.isArray(related) || related.length === 0) return null
return (
<section className="mt-12">
<h2 className="text-lg font-semibold text-white">Related Artworks</h2>
<ArtworkGalleryGrid
items={related.slice(0, 8)}
compact
className="mt-5 xl:grid-cols-4"
/>
</section>
)
}

View File

@@ -0,0 +1,78 @@
import React, { lazy, Suspense, useCallback, useState } from 'react'
import useWebShare from '../../hooks/useWebShare'
const ArtworkShareModal = lazy(() => import('./ArtworkShareModal'))
/* ── Share icon (lucide-style) ───────────────────────────────────────────── */
function ShareIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" />
</svg>
)
}
/**
* ArtworkShareButton renders the Share pill and manages modal / native share.
*
* Props:
* artwork artwork object
* shareUrl canonical URL to share
* size 'default' | 'small' (for mobile bar)
*/
export default function ArtworkShareButton({ artwork, shareUrl, size = 'default', isLoggedIn = false }) {
const [modalOpen, setModalOpen] = useState(false)
const openModal = useCallback(
() => setModalOpen(true),
[],
)
const closeModal = useCallback(
() => setModalOpen(false),
[],
)
const { share } = useWebShare({ onFallback: openModal })
const handleClick = () => {
share({
title: artwork?.title || 'Artwork',
text: artwork?.description?.substring(0, 120) || '',
url: shareUrl || artwork?.canonical_url || window.location.href,
})
}
const isSmall = size === 'small'
return (
<>
<button
type="button"
aria-label="Share artwork"
onClick={handleClick}
className={
isSmall
? 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white'
: 'group inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white hover:shadow-lg hover:shadow-white/[0.03]'
}
title="Share"
>
<ShareIcon />
{!isSmall && <span>Share</span>}
</button>
{/* Lazy-loaded modal only rendered when opened */}
{modalOpen && (
<Suspense fallback={null}>
<ArtworkShareModal
open={modalOpen}
onClose={closeModal}
artwork={artwork}
shareUrl={shareUrl}
isLoggedIn={isLoggedIn}
/>
</Suspense>
)}
</>
)
}

View File

@@ -0,0 +1,366 @@
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ShareToast from '../ui/ShareToast'
// Lazy-load the Feed share modal so artwork pages don't bundle the feed layer unless needed
const FeedShareArtworkModal = lazy(() => import('../Feed/ShareArtworkModal'))
/* ── Platform share URLs ─────────────────────────────────────────────────── */
function facebookUrl(url) {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
}
function twitterUrl(url, title) {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
}
function pinterestUrl(url, imageUrl, title) {
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`
}
function emailUrl(url, title) {
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`
}
/* ── Icons ────────────────────────────────────────────────────────────────── */
function CopyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
)
}
function CheckIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5 text-emerald-400">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
</svg>
)
}
function FacebookIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" />
</svg>
)
}
function XTwitterIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" />
</svg>
)
}
function PinterestIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" />
</svg>
)
}
function EmailIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
)
}
function EmbedIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
)
}
function CloseIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)
}
/* ── Helpers ──────────────────────────────────────────────────────────────── */
function openShareWindow(url) {
window.open(url, '_blank', 'noopener,noreferrer,width=600,height=500')
}
function trackShare(artworkId, platform) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
fetch(`/api/artworks/${artworkId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
credentials: 'same-origin',
body: JSON.stringify({ platform }),
}).catch(() => {})
}
/* ── Main component ──────────────────────────────────────────────────────── */
/**
* ArtworkShareModal
*
* Props:
* open boolean, whether modal is visible
* onClose callback to close modal
* artwork artwork object (id, title, description, thumbs, canonical_url, …)
* shareUrl canonical share URL
*/
export default function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
const backdropRef = useRef(null)
const [linkCopied, setLinkCopied] = useState(false)
const [embedCopied, setEmbedCopied] = useState(false)
const [showEmbed, setShowEmbed] = useState(false)
const [toastVisible, setToastVisible] = useState(false)
const [toastMessage, setToastMessage] = useState('')
const [profileShareOpen, setProfileShareOpen] = useState(false)
const url = shareUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const title = artwork?.title || 'Artwork'
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || ''
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl
const embedCode = `<a href="${url}">\n <img src="${thumbMdUrl}" alt="${title.replace(/"/g, '&quot;')}" />\n</a>`
// Lock body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onClose])
// Reset state when re-opening
useEffect(() => {
if (open) {
setLinkCopied(false)
setEmbedCopied(false)
setShowEmbed(false)
}
}, [open])
const showToast = useCallback((msg) => {
setToastMessage(msg)
setToastVisible(true)
}, [])
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setLinkCopied(true)
showToast('Link copied!')
trackShare(artwork?.id, 'copy')
setTimeout(() => setLinkCopied(false), 2500)
} catch { /* noop */ }
}
const handleCopyEmbed = async () => {
try {
await navigator.clipboard.writeText(embedCode)
setEmbedCopied(true)
showToast('Embed code copied!')
trackShare(artwork?.id, 'embed')
setTimeout(() => setEmbedCopied(false), 2500)
} catch { /* noop */ }
}
const handlePlatformShare = (platform, shareLink) => {
openShareWindow(shareLink)
trackShare(artwork?.id, platform)
onClose()
}
if (!open) return null
const SHARE_OPTIONS = [
{
label: linkCopied ? 'Copied!' : 'Copy Link',
icon: linkCopied ? <CheckIcon /> : <CopyIcon />,
onClick: handleCopyLink,
className: linkCopied
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
},
{
label: 'Facebook',
icon: <FacebookIcon />,
onClick: () => handlePlatformShare('facebook', facebookUrl(url)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]',
},
{
label: 'X (Twitter)',
icon: <XTwitterIcon />,
onClick: () => handlePlatformShare('twitter', twitterUrl(url, title)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white',
},
{
label: 'Pinterest',
icon: <PinterestIcon />,
onClick: () => handlePlatformShare('pinterest', pinterestUrl(url, imageUrl, title)),
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]',
},
{
label: 'Email',
icon: <EmailIcon />,
onClick: () => { window.location.href = emailUrl(url, title); trackShare(artwork?.id, 'email') },
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
},
...(isLoggedIn ? [{
label: 'My Profile',
icon: <i className="fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" />,
onClick: () => setProfileShareOpen(true),
className: 'border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20',
}] : []),
]
return createPortal(
<>
{/* Backdrop */}
<div
ref={backdropRef}
onClick={(e) => { if (e.target === backdropRef.current) onClose() }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Share this artwork"
>
{/* Modal container — glassmorphism */}
<div className="w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
<h3 className="text-base font-semibold text-white">Share this artwork</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70"
aria-label="Close share dialog"
>
<CloseIcon />
</button>
</div>
{/* Artwork preview */}
{thumbMdUrl && (
<div className="flex items-center gap-3 border-b border-white/[0.06] px-6 py-3">
<img
src={thumbMdUrl}
alt={title}
className="h-14 w-14 rounded-lg object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{title}</p>
{artwork?.user?.username && (
<p className="truncate text-xs text-white/50">by {artwork.user.username}</p>
)}
</div>
</div>
)}
{/* Share buttons grid */}
<div className="grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5">
{SHARE_OPTIONS.map((opt) => (
<button
key={opt.label}
type="button"
onClick={opt.onClick}
className={[
'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200',
opt.className,
].join(' ')}
>
{opt.icon}
<span className="truncate">{opt.label}</span>
</button>
))}
</div>
{/* Embed section */}
<div className="border-t border-white/[0.06] px-6 py-4">
<button
type="button"
onClick={() => setShowEmbed(!showEmbed)}
className="flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
>
<EmbedIcon />
{showEmbed ? 'Hide Embed Code' : 'Embed Code'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className={`h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? 'rotate-180' : ''}`}
>
<path fillRule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
</button>
{showEmbed && (
<div className="mt-3 space-y-2">
<textarea
readOnly
value={embedCode}
rows={3}
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]"
onClick={(e) => e.target.select()}
/>
<button
type="button"
onClick={handleCopyEmbed}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200',
embedCopied
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
].join(' ')}
>
{embedCopied ? <CheckIcon /> : <CopyIcon />}
{embedCopied ? 'Copied!' : 'Copy Embed'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Toast */}
<ShareToast
message={toastMessage}
visible={toastVisible}
onHide={() => setToastVisible(false)}
/>
{/* Share to Profile (Feed) modal — lazy loaded */}
{profileShareOpen && (
<Suspense fallback={null}>
<FeedShareArtworkModal
isOpen={profileShareOpen}
onClose={() => setProfileShareOpen(false)}
preselectedArtwork={artwork?.id ? {
id: artwork.id,
title: artwork.title,
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
user: artwork.user ?? null,
} : null}
onShared={() => {
setProfileShareOpen(false)
showToast('Shared to your profile!')
}}
/>
</Suspense>
)}
</>,
document.body,
)
}

View File

@@ -0,0 +1,42 @@
import React from 'react'
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${number}`
}
export default function ArtworkStats({ artwork, stats: statsProp }) {
const stats = statsProp || artwork?.stats || {}
const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0
return (
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Statistics</h2>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
<dt className="text-soft">👁 Views</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.views)} views</dd>
</div>
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
<dt className="text-soft"> Downloads</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.downloads)} downloads</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
<dt className="text-soft"> Likes</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.likes)} likes</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
<dt className="text-soft"> Favorites</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.favorites)} favorites</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
</div>
</dl>
</section>
)
}

View File

@@ -0,0 +1,90 @@
import React, { useMemo, useState } from 'react'
export default function ArtworkTags({ artwork }) {
const [expanded, setExpanded] = useState(false)
const tags = useMemo(() => {
const seen = new Set()
const contentTypeSeen = new Set()
const categoryPills = []
// Add content types (e.g. "Wallpapers") first, then categories, then tags
for (const category of artwork?.categories || []) {
const ctSlug = category.content_type_slug
if (ctSlug && !contentTypeSeen.has(ctSlug)) {
contentTypeSeen.add(ctSlug)
const ctName = ctSlug.charAt(0).toUpperCase() + ctSlug.slice(1)
categoryPills.push({
key: `ct-${ctSlug}`,
label: ctName,
href: `/${ctSlug}`,
isCategory: true,
})
}
if (category.parent && !seen.has(category.parent.id)) {
seen.add(category.parent.id)
categoryPills.push({
key: `cat-${category.parent.id}`,
label: category.parent.name,
href: category.parent.url || `/${category.parent.content_type_slug}/${category.parent.slug}`,
isCategory: true,
})
}
if (!seen.has(category.id)) {
seen.add(category.id)
categoryPills.push({
key: `cat-${category.id}`,
label: category.name,
href: category.url || `/${category.content_type_slug}/${category.slug}`,
isCategory: true,
})
}
}
const artworkTags = (artwork?.tags || []).map((tag) => ({
key: `tag-${tag.id || tag.slug}`,
label: tag.name,
href: `/tag/${tag.slug || ''}`,
isCategory: false,
}))
return [...categoryPills, ...artworkTags]
}, [artwork])
if (tags.length === 0) return null
const visible = expanded ? tags : tags.slice(0, 12)
return (
<div>
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-accent/70">Tags &amp; Categories</h3>
<div className="flex flex-wrap gap-2">
{visible.map((tag, idx) => (
<a
key={tag.key}
href={tag.href}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200',
tag.isCategory
? 'border-accent/30 bg-accent/10 text-accent hover:border-accent/50 hover:bg-accent/20'
: 'border-white/[0.08] bg-white/[0.03] text-white/60 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80',
].join(' ')}
>
{tag.label}
</a>
))}
{tags.length > 12 && (
<button
type="button"
className="inline-flex items-center rounded-full border border-dashed border-white/[0.1] px-3 py-1.5 text-xs text-white/40 transition hover:border-white/20 hover:text-white/60"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Show less' : `+${tags.length - 12} more`}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import React, { useMemo, useState } from 'react'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
function formatCount(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${n}`
}
function toCard(item) {
return {
id: item?.id || item?.slug || item?.url,
title: item?.title,
author: item?.author,
url: item?.url,
thumb: item?.thumb,
thumbSrcSet: item?.thumb_srcset,
}
}
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
const publisher = artwork?.publisher || null
const isGroupPublisher = publisher?.type === 'group'
const [following, setFollowing] = useState(Boolean(isGroupPublisher ? artwork?.viewer?.is_following_group : artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(isGroupPublisher ? publisher?.followers_count || 0 : artwork?.user?.followers_count || 0))
const user = artwork?.credits?.primary_author || artwork?.user || {}
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
const authorName = isGroupPublisher ? (publisher?.name || 'Group') : (user.name || user.username || 'Artist')
const profileUrl = isGroupPublisher ? (publisher?.profile_url || '#') : (user.profile_url || (user.username ? `/@${user.username}` : '#'))
const avatar = (isGroupPublisher ? publisher?.avatar_url : user.avatar_url) || presentSq?.url || AVATAR_FALLBACK
const creatorItems = useMemo(() => {
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase()
const notCurrent = item?.url && item.url !== artwork?.canonical_url
return sameAuthor && notCurrent
})
const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : [])
return source.slice(0, 12).map(toCard)
}, [related, authorName, artwork?.canonical_url])
return (
<>
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
{/* Avatar + info — stacked for sidebar */}
<div className="flex flex-col items-center text-center">
<a href={profileUrl} className="group">
<img
src={avatar}
alt={authorName}
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = AVATAR_FALLBACK
}}
/>
</a>
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
{authorName}
</a>
{!isGroupPublisher && user.username && <p className="text-xs text-white/40">@{user.username}</p>}
{isGroupPublisher && artwork?.credits?.primary_author ? <p className="text-xs text-white/40">Primary author: {artwork.credits.primary_author.name || artwork.credits.primary_author.username}</p> : null}
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>
{/* Profile + Follow buttons */}
<div className="mt-4 flex w-full gap-2">
<a
href={profileUrl}
title="View profile"
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Profile
</a>
{!isOwnArtwork && !isGroupPublisher ? (
<FollowButton
username={user.username}
initialFollowing={following}
initialCount={followersCount}
showCount={false}
className="flex-1"
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
setFollowing(nextFollowing)
setFollowersCount(nextFollowersCount)
}}
/>
) : null}
{!isOwnArtwork && isGroupPublisher ? (
<button
type="button"
onClick={async () => {
const method = following ? 'DELETE' : 'POST'
const response = await fetch(following ? artwork.publisher?.unfollow_url || `${publisher.profile_url}/follow` : artwork.publisher?.follow_url || `${publisher.profile_url}/follow`, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
})
const payload = await response.json().catch(() => ({}))
if (response.ok) {
setFollowing(Boolean(payload?.following))
setFollowersCount(Number(payload?.followers_count || 0))
}
}}
className={`flex-1 rounded-xl border px-3 py-2.5 text-sm font-medium transition ${following ? 'border-white/[0.12] bg-white/[0.05] text-white' : 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-300/15'}`}
>
{following ? 'Following' : 'Follow group'}
</button>
) : null}
</div>
</div>
{/* More from creator rail */}
{creatorItems.length > 0 && (
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{creatorItems.slice(0, 3).map((item, idx) => (
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
<div className="aspect-square overflow-hidden bg-deep">
<img
src={item.thumb || AVATAR_FALLBACK}
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
loading="lazy"
decoding="async"
/>
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
</svg>
<span className="text-[10px] font-bold text-white drop-shadow">
{item.likes ? formatCount(item.likes) : ''}
</span>
</div>
</a>
))}
</div>
</div>
)}
</section>
</>
)
}

View File

@@ -0,0 +1,79 @@
import React from 'react'
const providers = [
{
key: 'google',
label: 'Continue with Google',
href: '/auth/google/redirect',
icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
),
className:
'border-white/15 hover:border-white/30 hover:bg-white/5',
},
{
key: 'discord',
label: 'Continue with Discord',
href: '/auth/discord/redirect',
icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5" aria-hidden="true" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.04.033.05a19.89 19.89 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
),
className:
'border-white/15 hover:border-white/30 hover:bg-discord/20',
},
]
/**
* Social login buttons for Google, Apple, and Discord.
*
* @param {{ dividerLabel?: string }} props
*/
export default function SocialLoginButtons({ dividerLabel = 'or continue with email' }) {
return (
<div className="space-y-3">
{providers.map(({ key, label, href, icon, className }) => (
<a
key={key}
href={href}
className={[
'flex items-center justify-center gap-3 w-full rounded-lg border px-4 py-3',
'text-sm font-medium text-white transition-colors duration-150',
className,
].join(' ')}
>
{icon}
<span>{label}</span>
</a>
))}
{dividerLabel && (
<div className="relative flex items-center py-1">
<div className="flex-grow border-t border-white/10" />
<span className="mx-3 text-xs text-white/40 whitespace-nowrap">
{dividerLabel}
</span>
<div className="flex-grow border-t border-white/10" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import React from 'react'
const CONTENT_TYPE_STYLES = {
wallpapers: {
badge: 'from-cyan-400/90 to-sky-500/90',
overlay: 'from-sky-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(34,211,238,0.18)]',
},
skins: {
badge: 'from-orange-400/90 to-amber-500/90',
overlay: 'from-orange-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(251,146,60,0.18)]',
},
photography: {
badge: 'from-emerald-400/90 to-teal-500/90',
overlay: 'from-emerald-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(16,185,129,0.18)]',
},
other: {
badge: 'from-fuchsia-400/90 to-rose-500/90',
overlay: 'from-rose-950/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(244,114,182,0.18)]',
},
default: {
badge: 'from-cyan-400/90 to-orange-400/90',
overlay: 'from-slate-900/10 via-slate-950/12 to-slate-950/92',
glow: 'group-hover:shadow-[0_0_28px_rgba(125,211,252,0.16)]',
},
}
const countFormatter = new Intl.NumberFormat()
function formatArtworkCount(count) {
return `${countFormatter.format(Number(count || 0))} artworks`
}
export default function CategoryCard({ category, index = 0 }) {
const contentTypeSlug = category?.content_type?.slug || 'default'
const contentTypeName = category?.content_type?.name || 'Category'
const styles = CONTENT_TYPE_STYLES[contentTypeSlug] || CONTENT_TYPE_STYLES.default
return (
<a
href={category?.url || '/categories'}
aria-label={`Browse ${category?.name || 'category'} category`}
className={[
'group relative block cursor-pointer rounded-2xl overflow-hidden',
'transition duration-300 ease-out hover:-translate-y-1 hover:scale-[1.01]',
styles.glow,
].join(' ')}
style={{ animationDelay: `${Math.min(index, 8) * 60}ms` }}
>
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80">
<img
src={category?.cover_image}
alt={`Cover artwork for ${category?.name || 'category'}`}
loading="lazy"
className="h-full w-full object-cover transition duration-500 group-hover:scale-110"
/>
<div className={`absolute inset-0 bg-gradient-to-b ${styles.overlay} transition duration-500 group-hover:from-black/20 group-hover:to-black/90`} />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%)] opacity-0 transition duration-500 group-hover:opacity-100" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-3 p-4">
<span className={`inline-flex rounded-full bg-gradient-to-r ${styles.badge} px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-950 shadow-[0_10px_24px_rgba(0,0,0,0.24)]`}>
{contentTypeName}
</span>
<span className="rounded-full border border-white/15 bg-black/25 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/78 backdrop-blur">
{formatArtworkCount(category?.artwork_count)}
</span>
</div>
<div className="absolute inset-x-0 bottom-0 p-4 sm:p-5">
<div className="rounded-[22px] border border-white/10 bg-black/30 p-4 backdrop-blur-md transition duration-300 group-hover:border-white/20 group-hover:bg-black/42">
<div className="mb-3 h-px w-14 bg-gradient-to-r from-white/70 to-transparent transition duration-300 group-hover:w-24" />
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white sm:text-xl">
{category?.name}
</h3>
<p className="mt-2 text-sm leading-6 text-white/65">
Explore {category?.name} across wallpapers, skins, themes, and digital art collections.
</p>
</div>
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,463 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import ReactMarkdown from 'react-markdown'
import EmojiPickerButton from './EmojiPickerButton'
/* ── Toolbar icon components ──────────────────────────────────────────────── */
function BoldIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
</svg>
)
}
function ItalicIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
)
}
function CodeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
)
}
function LinkIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)
}
function ListIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
function QuoteIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
</svg>
)
}
/* ── Toolbar button wrapper ───────────────────────────────────────────────── */
function ToolbarBtn({ title, onClick, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => { e.preventDefault(); onClick() }}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
>
{children}
</button>
)
}
/* ── Main component ───────────────────────────────────────────────────────── */
export default function CommentForm({
artworkId,
onPosted,
isLoggedIn = false,
loginUrl = '/login',
parentId = null,
replyTo = null,
onCancelReply = null,
compact = false,
}) {
const [content, setContent] = useState('')
const [tab, setTab] = useState('write') // 'write' | 'preview'
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState([])
const textareaRef = useRef(null)
const formRef = useRef(null)
// Auto-focus when entering reply mode
useEffect(() => {
if (replyTo && textareaRef.current) {
textareaRef.current.focus()
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [replyTo])
/* ── Helpers to wrap selected text ────────────────────────────────────── */
const wrapSelection = useCallback((before, after) => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const replacement = before + (selected || 'text') + after
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
const cursorPos = selected
? start + replacement.length
: start + before.length
const cursorEnd = selected
? start + replacement.length
: start + before.length + 4
el.selectionStart = cursorPos
el.selectionEnd = cursorEnd
el.focus()
})
}, [content])
const prefixLines = useCallback((prefix) => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const lines = selected ? selected.split('\n') : ['']
const prefixed = lines.map(l => prefix + l).join('\n')
const next = content.slice(0, start) + prefixed + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
el.selectionStart = start
el.selectionEnd = start + prefixed.length
el.focus()
})
}, [content])
const insertLink = useCallback(() => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const isUrl = /^https?:\/\//.test(selected)
const replacement = isUrl
? `[link](${selected})`
: `[${selected || 'link'}](https://)`
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
if (isUrl) {
el.selectionStart = start + 1
el.selectionEnd = start + 5
} else {
const urlStart = start + replacement.length - 1
el.selectionStart = urlStart - 8
el.selectionEnd = urlStart - 1
}
el.focus()
})
}, [content])
// Insert text at cursor (for emoji picker)
const insertAtCursor = useCallback((text) => {
const el = textareaRef.current
if (!el) {
setContent((v) => v + text)
return
}
const start = el.selectionStart ?? content.length
const end = el.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
el.selectionStart = start + text.length
el.selectionEnd = start + text.length
el.focus()
})
}, [content])
const handleEmojiSelect = useCallback((emoji) => {
insertAtCursor(emoji)
}, [insertAtCursor])
/* ── Keyboard shortcuts ───────────────────────────────────────────────── */
const handleKeyDown = useCallback((e) => {
const mod = e.ctrlKey || e.metaKey
if (!mod) return
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault()
wrapSelection('**', '**')
break
case 'i':
e.preventDefault()
wrapSelection('*', '*')
break
case 'k':
e.preventDefault()
insertLink()
break
case 'e':
e.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [wrapSelection, insertLink])
/* ── Submit ───────────────────────────────────────────────────────────── */
const handleSubmit = useCallback(
async (e) => {
e.preventDefault()
if (!isLoggedIn) {
window.location.href = loginUrl
return
}
const trimmed = content.trim()
if (!trimmed) return
setSubmitting(true)
setErrors([])
try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
content: trimmed,
parent_id: parentId || null,
})
setContent('')
setTab('write')
onPosted?.(data.data)
onCancelReply?.()
} catch (err) {
if (err.response?.status === 422) {
const fieldErrors = err.response.data?.errors ?? {}
const allErrors = Object.values(fieldErrors).flat()
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
} else {
setErrors(['Something went wrong. Please try again.'])
}
} finally {
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
)
/* ── Logged-out state ─────────────────────────────────────────────────── */
if (!isLoggedIn) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4 backdrop-blur-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 shrink-0 text-white/25">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-sm text-white/40">
<a href={loginUrl} className="font-medium text-accent transition-colors hover:text-accent/80">
Sign in
</a>{' '}
to join the conversation.
</p>
</div>
)
}
/* ── Editor ───────────────────────────────────────────────────────────── */
return (
<form id={parentId ? `reply-form-${parentId}` : 'comment-form'} ref={formRef} onSubmit={handleSubmit} className="space-y-3">
{/* Reply indicator */}
{replyTo && (
<div className="flex items-center gap-2 rounded-lg bg-accent/[0.06] px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5 text-accent/60 shrink-0">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
<span className="text-xs text-white/50">
Replying to <span className="font-semibold text-white/70">{replyTo}</span>
</span>
<button
type="button"
onClick={onCancelReply}
className="ml-auto text-[11px] font-medium text-white/30 transition-colors hover:text-white/60"
>
Cancel
</button>
</div>
)}
<div className={`rounded-2xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
{/* ── Top bar: tabs + emoji ─────────────────────────────────────── */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
{/* Tabs */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'write'
? 'bg-white/[0.08] text-white'
: 'text-white/40 hover:text-white/60',
].join(' ')}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'preview'
? 'bg-white/[0.08] text-white'
: 'text-white/40 hover:text-white/60',
].join(' ')}
>
Preview
</button>
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'text-[11px] tabular-nums font-medium transition-colors',
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
].join(' ')}
>
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div>
</div>
{/* ── Formatting toolbar (write mode only) ──────────────────────── */}
{tab === 'write' && (
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
<BoldIcon />
</ToolbarBtn>
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
<ItalicIcon />
</ToolbarBtn>
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
<CodeIcon />
</ToolbarBtn>
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
<LinkIcon />
</ToolbarBtn>
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
<ListIcon />
</ToolbarBtn>
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
<QuoteIcon />
</ToolbarBtn>
</div>
)}
{/* ── Write tab ─────────────────────────────────────────────────── */}
{tab === 'write' && (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={replyTo ? `Reply to ${replyTo}` : 'Share your thoughts…'}
rows={compact ? 2 : 4}
maxLength={10000}
disabled={submitting}
aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
/>
)}
{/* ── Preview tab ───────────────────────────────────────────────── */}
{tab === 'preview' && (
<div className="min-h-[7rem] px-4 py-3">
{content.trim() ? (
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-white/25 italic">Nothing to preview</p>
)}
</div>
)}
{/* ── Bottom hint ───────────────────────────────────────────────── */}
{tab === 'write' && (
<div className="px-4 pb-2">
<p className="text-[11px] text-white/15">
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
</p>
</div>
)}
</div>
{/* Errors */}
{errors.length > 0 && (
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
{errors.map((e, i) => (
<li key={i} className="text-xs font-medium text-red-400">
{e}
</li>
))}
</ul>
)}
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !content.trim()}
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Posting
</span>
) : (
'Post comment'
)}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,287 @@
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/default/avatar_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>
)
}

View File

@@ -0,0 +1,115 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import EmojiMartPicker from '../common/EmojiMartPicker'
import loadEmojiMartData from '../common/loadEmojiMartData'
/**
* A button that opens a floating emoji picker.
* When the user selects an emoji, `onEmojiSelect(emojiNative)` is called
* with the native Unicode character.
*
* Props:
* onEmojiSelect (string) → void Called with the emoji character
* disabled boolean Disables the button
* className string Additional classes for the trigger button
*/
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
const [open, setOpen] = useState(false)
const [pickerData, setPickerData] = useState(null)
const wrapRef = useRef(null)
useEffect(() => {
if (!open || pickerData) return
let cancelled = false
loadEmojiMartData().then((data) => {
if (!cancelled) {
setPickerData(data)
}
})
return () => {
cancelled = true
}
}, [open, pickerData])
// Close on outside click
useEffect(() => {
if (!open) return
function handleClick(e) {
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
function handleKey(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open])
const handleSelect = useCallback(
(emoji) => {
onEmojiSelect?.(emoji.native)
setOpen(false)
},
[onEmojiSelect],
)
return (
<div ref={wrapRef} className="relative inline-block">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
aria-label="Open emoji picker"
aria-expanded={open}
className={[
'flex items-center justify-center w-8 h-8 rounded-md',
'text-white/40 hover:text-white/70 hover:bg-white/[0.07]',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-30 disabled:pointer-events-none',
className,
]
.filter(Boolean)
.join(' ')}
>
😊
</button>
{open && (
<div
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
>
{pickerData ? (
<EmojiMartPicker
data={pickerData}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="none"
maxFrequentRows={2}
perLine={8}
/>
) : (
<div className="flex h-24 w-56 items-center justify-center bg-zinc-900 px-4 text-sm text-zinc-300">
Loading emojis...
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
/* ── Reaction definitions ────────────────────────────────────────────────── */
const REACTIONS = [
{ slug: 'thumbs_up', emoji: '👍', label: 'Like' },
{ slug: 'heart', emoji: '❤️', label: 'Love' },
{ slug: 'fire', emoji: '🔥', label: 'Fire' },
{ slug: 'laugh', emoji: '😂', label: 'Haha' },
{ slug: 'clap', emoji: '👏', label: 'Clap' },
{ slug: 'wow', emoji: '😮', label: 'Wow' },
]
/* ── Small heart outline icon for the trigger ─────────────────────────────── */
function HeartOutlineIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
)
}
/**
* Facebook-style reaction bar.
*
* - Compact trigger button (heart icon or the user's reaction)
* - Floating picker that appears on hover/click with scale animation
* - Summary row showing unique reaction emoji + total count
*
* Props:
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null)
const [pickerOpen, setPickerOpen] = useState(false)
const containerRef = useRef(null)
const hoverTimeout = useRef(null)
const endpoint =
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
// Close picker when clicking outside
useEffect(() => {
if (!pickerOpen) return
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setPickerOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [pickerOpen])
const toggle = useCallback(
async (slug) => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (loading) return
setLoading(slug)
setPickerOpen(false)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false, emoji: REACTIONS.find(r => r.slug === slug)?.emoji, label: REACTIONS.find(r => r.slug === slug)?.label }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
})
try {
const { data } = await axios.post(endpoint, { reaction: slug })
setTotals(data.totals)
} catch {
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
})
} finally {
setLoading(null)
}
},
[endpoint, isLoggedIn, loading],
)
// Compute summary data
const entries = Object.entries(totals)
const activeReactions = entries.filter(([, info]) => info.count > 0)
const totalCount = activeReactions.reduce((sum, [, info]) => sum + info.count, 0)
const myReaction = entries.find(([, info]) => info.mine)?.[0] ?? null
const myReactionData = myReaction ? REACTIONS.find(r => r.slug === myReaction) : null
// Hover handlers for desktop — open on hover with a small delay
const onMouseEnter = () => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = setTimeout(() => setPickerOpen(true), 200)
}
const onMouseLeave = () => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
}
const isArtworkVariant = entityType === 'artwork'
const triggerClassName = isArtworkVariant
? [
'inline-flex items-center gap-2.5 rounded-full border px-4 py-2.5 text-sm font-semibold transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'border-accent/35 bg-accent/12 text-accent shadow-[0_12px_30px_rgba(245,158,11,0.14)] hover:bg-accent/18'
: 'border-white/[0.12] bg-white/[0.06] text-white/75 hover:border-accent/30 hover:bg-white/[0.1] hover:text-white',
].join(' ')
: [
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'text-accent'
: 'text-white/40 hover:text-white/70',
].join(' ')
const summaryClassName = isArtworkVariant
? 'inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.05] px-3 py-1.5 transition-colors hover:border-white/[0.16] hover:bg-white/[0.08] group/summary'
: 'inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary'
return (
<div
ref={containerRef}
className={isArtworkVariant ? 'flex flex-wrap items-center gap-3' : 'flex items-center gap-2'}
onMouseLeave={onMouseLeave}
>
{/* ── Trigger button ──────────────────────────────────────────── */}
<div className="relative" onMouseEnter={onMouseEnter}>
<button
type="button"
onClick={() => {
if (isArtworkVariant) {
setPickerOpen((value) => !value)
return
}
if (myReaction) {
toggle(myReaction)
} else {
toggle('thumbs_up')
}
}}
className={triggerClassName}
aria-label={isArtworkVariant
? (myReaction
? `Open reaction picker. Current reaction: ${myReactionData?.label}.`
: 'Open reaction picker for this artwork')
: (myReaction
? `You reacted with ${myReactionData?.label}. Click to remove.`
: 'React to this comment')}
>
{myReaction ? (
<span className={isArtworkVariant ? 'text-xl leading-none' : 'text-base leading-none'}>{myReactionData?.emoji}</span>
) : (
<HeartOutlineIcon className={isArtworkVariant ? 'h-5 w-5' : 'h-4 w-4'} />
)}
<span>{myReaction ? myReactionData?.label : (isArtworkVariant ? 'React to this artwork' : 'React')}</span>
</button>
{/* ── Floating picker ─────────────────────────────────────── */}
{pickerOpen && (
<div
className="absolute bottom-full left-0 mb-2 z-[200] animate-in fade-in slide-in-from-bottom-2 duration-200"
onMouseEnter={() => { clearTimeout(hoverTimeout.current) }}
onMouseLeave={onMouseLeave}
>
<div className="flex items-center gap-0.5 rounded-full bg-nova-800/95 border border-white/[0.1] px-2 py-1.5 shadow-xl shadow-black/40 backdrop-blur-xl">
{REACTIONS.map((r, i) => {
const isActive = totals[r.slug]?.mine
return (
<button
key={r.slug}
type="button"
onClick={() => toggle(r.slug)}
disabled={loading === r.slug}
aria-label={`${r.label}${isActive ? ' (selected)' : ''}`}
className={[
'group/reaction relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200',
'hover:bg-white/[0.08] hover:scale-125 hover:-translate-y-1',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
'disabled:opacity-50',
isActive ? 'bg-white/[0.1] scale-110' : '',
].join(' ')}
style={{ animationDelay: `${i * 30}ms` }}
title={r.label}
>
<span className="text-xl leading-none transition-transform duration-150 group-hover/reaction:scale-110">
{r.emoji}
</span>
{/* Tooltip */}
<span className="pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 rounded bg-black/80 px-1.5 py-0.5 text-[10px] font-medium text-white/90 opacity-0 transition-opacity group-hover/reaction:opacity-100 whitespace-nowrap">
{r.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
{/* ── Summary: stacked emoji + count ───────────────────────── */}
{totalCount > 0 && (
<button
type="button"
onClick={() => setPickerOpen(v => !v)}
className={summaryClassName}
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
>
{/* Stacked emoji circles (Facebook-style, max 3) */}
<span className="inline-flex items-center -space-x-1">
{activeReactions.slice(0, 3).map(([slug, info], i) => (
<span
key={slug}
className="relative flex items-center justify-center w-5 h-5 rounded-full bg-nova-700 border border-nova-800 text-xs leading-none"
style={{ zIndex: 3 - i }}
title={info.label}
>
{info.emoji}
</span>
))}
</span>
<span className="text-xs font-medium tabular-nums text-white/50 group-hover/summary:text-white/70 transition-colors">
{totalCount}
</span>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useRef } from 'react'
let emojiMartRegistrationPromise = null
function ensureEmojiMartRegistered() {
if (!emojiMartRegistrationPromise) {
emojiMartRegistrationPromise = import('emoji-mart')
}
return emojiMartRegistrationPromise
}
function applyPickerProps(element, props) {
if (!element) {
return
}
element.data = props.data
element.onEmojiSelect = props.onEmojiSelect
element.theme = props.theme
element.previewPosition = props.previewPosition
element.skinTonePosition = props.skinTonePosition
element.maxFrequentRows = props.maxFrequentRows
element.perLine = props.perLine
element.navPosition = props.navPosition
element.set = props.set
element.locale = props.locale
element.autoFocus = props.autoFocus
element.searchPosition = props.searchPosition
element.dynamicWidth = props.dynamicWidth
element.noCountryFlags = props.noCountryFlags
}
export default function EmojiMartPicker({
data,
onEmojiSelect,
theme = 'auto',
previewPosition = 'bottom',
skinTonePosition = 'preview',
maxFrequentRows = 4,
perLine = 9,
navPosition = 'top',
set = 'native',
locale = 'en',
autoFocus = false,
searchPosition,
dynamicWidth,
noCountryFlags,
className = '',
}) {
const hostRef = useRef(null)
const pickerRef = useRef(null)
useEffect(() => {
let cancelled = false
ensureEmojiMartRegistered().then(() => {
if (cancelled || !hostRef.current) {
return
}
if (!pickerRef.current) {
pickerRef.current = document.createElement('em-emoji-picker')
hostRef.current.replaceChildren(pickerRef.current)
}
applyPickerProps(pickerRef.current, {
data,
onEmojiSelect,
theme,
previewPosition,
skinTonePosition,
maxFrequentRows,
perLine,
navPosition,
set,
locale,
autoFocus,
searchPosition,
dynamicWidth,
noCountryFlags,
})
})
return () => {
cancelled = true
}
}, [
data,
onEmojiSelect,
theme,
previewPosition,
skinTonePosition,
maxFrequentRows,
perLine,
navPosition,
set,
locale,
autoFocus,
searchPosition,
dynamicWidth,
noCountryFlags,
])
useEffect(() => {
return () => {
if (hostRef.current) {
hostRef.current.replaceChildren()
}
pickerRef.current = null
}
}, [])
return <div ref={hostRef} className={className} />
}

View File

@@ -0,0 +1,9 @@
let emojiMartDataPromise = null
export default function loadEmojiMartData() {
if (!emojiMartDataPromise) {
emojiMartDataPromise = import('@emoji-mart/data').then((module) => module.default)
}
return emojiMartDataPromise
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
export default function ActivityArtworkPreview({ artwork, story }) {
if (artwork?.url && artwork?.thumb) {
return (
<a
href={artwork.url}
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
>
<div className="aspect-[6/5] overflow-hidden bg-black/20">
<img
src={artwork.thumb}
alt={artwork.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
/>
</div>
<div className="border-t border-white/[0.06] px-3 py-2">
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
</div>
</a>
)
}
if (!story?.url || !story?.cover_url) return null
return (
<a
href={story.url}
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
>
<div className="aspect-[6/5] overflow-hidden bg-black/20">
<img
src={story.cover_url}
alt={story.title || 'Story'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
/>
</div>
<div className="border-t border-white/[0.06] px-3 py-2">
<p className="truncate text-[11px] font-medium text-white/65">{story.title || 'Story'}</p>
</div>
</a>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
const BADGE_TONES = {
rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200',
amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200',
sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200',
}
export default function ActivityAvatar({ user }) {
if (!user) return null
const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky
return (
<div className="flex items-start gap-3">
<a href={user.profile_url || '#'} className="shrink-0">
<img
src={user.avatar_url || FALLBACK_AVATAR}
alt={user.name || user.username || 'User'}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = FALLBACK_AVATAR
}}
/>
</a>
<div className="min-w-0">
<a href={user.profile_url || '#'} className="truncate text-sm font-semibold text-white hover:text-sky-200 transition-colors">
{user.name || user.username || 'User'}
</a>
{user.username && <p className="truncate text-xs text-white/35">@{user.username}</p>}
{user.badge && (
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${badgeClassName}`}>
{user.badge.label}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import React from 'react'
import ActivityAvatar from './ActivityAvatar'
import ActivityArtworkPreview from './ActivityArtworkPreview'
import ActivityReactions from './ActivityReactions'
function ActivityHeadline({ activity }) {
const artworkLink = activity?.artwork?.url
const artworkTitle = activity?.artwork?.title || 'an artwork'
const storyLink = activity?.story?.url
const storyTitle = activity?.story?.title || 'a story'
const mentionedUser = activity?.mentioned_user
const reaction = activity?.reaction
const commentAuthor = activity?.comment?.author
const targetUser = activity?.target_user
switch (activity?.type) {
case 'upload':
if (storyLink) {
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">published </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
}
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">published </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'favorite':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">favorited </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'follow':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">followed </span>
{targetUser?.profile_url ? <a href={targetUser.profile_url} className="text-sky-300 hover:text-sky-200">@{targetUser.username || targetUser.name}</a> : <span className="text-white">another creator</span>}
</p>
)
case 'award':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">awarded </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'story_like':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">liked </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
case 'story_comment':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">commented on </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
case 'comment':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">commented on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'reply':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">replied on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'reaction':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} </span>
<span>to </span>
{commentAuthor?.profile_url ? <a href={commentAuthor.profile_url} className="text-sky-300 hover:text-sky-200">{commentAuthor.name || commentAuthor.username || 'a creator'}</a> : <span className="text-white">a creator</span>}
<span> on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'mention':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">mentioned </span>
{mentionedUser?.profile_url ? <a href={mentionedUser.profile_url} className="text-sky-300 hover:text-sky-200">@{mentionedUser.username || mentionedUser.name}</a> : <span className="text-white">someone</span>}
<span> on </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
default:
return <p className="text-sm leading-6 text-white/70">Shared new activity.</p>
}
}
export default function ActivityCard({ activity, isLoggedIn = false }) {
return (
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="sm:w-[220px] sm:shrink-0">
<ActivityAvatar user={activity.user} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<ActivityHeadline activity={activity} />
<span className="text-[11px] uppercase tracking-[0.18em] text-white/25">{activity.time_ago || ''}</span>
</div>
{activity.comment?.body ? (
<div className="mt-3 rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-3">
<p className="whitespace-pre-line break-words text-sm leading-6 text-white/80">{activity.comment.body}</p>
</div>
) : null}
{activity.type === 'mention' && activity.mentioned_user ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">
<i className="fa-solid fa-at" />
Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name}
</div>
) : null}
<ActivityReactions activity={activity} isLoggedIn={isLoggedIn} />
</div>
<div className="sm:ml-auto">
<ActivityArtworkPreview artwork={activity.artwork} story={activity.story} />
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import ActivityCard from './ActivityCard'
function ActivitySkeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex items-start gap-3 sm:w-[220px]">
<div className="h-11 w-11 rounded-2xl bg-white/[0.08]" />
<div className="flex-1 space-y-2">
<div className="h-3 w-24 rounded bg-white/[0.08]" />
<div className="h-2.5 w-16 rounded bg-white/[0.06]" />
</div>
</div>
<div className="flex-1 space-y-3">
<div className="h-3 w-4/5 rounded bg-white/[0.08]" />
<div className="rounded-2xl border border-white/[0.04] bg-white/[0.02] px-4 py-3">
<div className="h-3 w-full rounded bg-white/[0.06]" />
<div className="mt-2 h-3 w-3/4 rounded bg-white/[0.05]" />
</div>
<div className="h-8 w-48 rounded-full bg-white/[0.05]" />
</div>
<div className="h-[132px] w-full rounded-2xl bg-white/[0.05] sm:w-[120px]" />
</div>
</div>
))}
</div>
)
}
function EmptyState({ isFiltered }) {
return (
<div className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-6 py-16 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.06] bg-white/[0.03] text-white/35">
<i className="fa-solid fa-wave-square text-xl" />
</div>
<h3 className="text-lg font-semibold text-white/80">No activity yet</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-white/45">
{isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'}
</p>
</div>
)
}
export default function ActivityFeed({
activities = [],
isLoggedIn = false,
loading = false,
loadingMore = false,
error = null,
sentinelRef,
}) {
if (loading && activities.length === 0) {
return <ActivitySkeleton />
}
if (!loading && activities.length === 0) {
return <EmptyState isFiltered={Boolean(error) === false} />
}
return (
<div className="space-y-4">
{error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{error}
</div>
) : null}
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} isLoggedIn={isLoggedIn} />
))}
{loadingMore ? <ActivitySkeleton /> : null}
<div ref={sentinelRef} className="h-6" aria-hidden="true" />
</div>
)
}

View File

@@ -0,0 +1,56 @@
import React from 'react'
import ReactionBar from '../comments/ReactionBar'
export default function ActivityReactions({ activity, isLoggedIn = false }) {
const commentId = activity?.comment?.id || null
const commentUrl = activity?.comment?.url || null
const artworkUrl = activity?.artwork?.url || null
const storyUrl = activity?.story?.url || null
if (!commentId && !artworkUrl && !storyUrl) {
return null
}
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
{commentId ? (
<ReactionBar
entityType="comment"
entityId={commentId}
initialTotals={activity?.comment?.reactions || {}}
isLoggedIn={isLoggedIn}
/>
) : null}
{commentUrl ? (
<a
href={commentUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
>
<i className="fa-regular fa-comment-dots" />
Reply
</a>
) : null}
{artworkUrl ? (
<a
href={artworkUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
>
<i className="fa-regular fa-image" />
View artwork
</a>
) : null}
{storyUrl ? (
<a
href={storyUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
>
<i className="fa-regular fa-newspaper" />
Read story
</a>
) : null}
</div>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
const TONES = {
tip: {
shell: 'border-sky-300/25 bg-sky-400/10 text-sky-50',
icon: 'fa-solid fa-lightbulb text-sky-200',
label: 'Tip',
},
note: {
shell: 'border-white/15 bg-white/[0.05] text-white',
icon: 'fa-solid fa-circle-info text-slate-200',
label: 'Note',
},
warning: {
shell: 'border-amber-300/25 bg-amber-400/10 text-amber-50',
icon: 'fa-solid fa-triangle-exclamation text-amber-200',
label: 'Warning',
},
practice: {
shell: 'border-emerald-300/25 bg-emerald-400/10 text-emerald-50',
icon: 'fa-solid fa-badge-check text-emerald-200',
label: 'Best Practice',
},
}
export default function DocsCallout({ tone = 'note', title, children }) {
const styles = TONES[tone] || TONES.note
return (
<aside className={`rounded-[24px] border px-4 py-4 md:px-5 ${styles.shell}`}>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/20">
<i className={styles.icon} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{styles.label}</p>
{title ? <h3 className="mt-1 text-base font-semibold">{title}</h3> : null}
<div className="mt-2 text-sm leading-6 opacity-90">{children}</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
export default function DocsComparisonTable({ columns, rows, caption }) {
return (
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
<div className="overflow-x-auto">
<table className="min-w-full border-collapse text-left">
{caption ? <caption className="sr-only">{caption}</caption> : null}
<thead>
<tr className="border-b border-white/10 bg-white/[0.04]">
{columns.map((column) => (
<th key={column.key} scope="col" className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 first:min-w-[180px]">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-white/5 last:border-b-0">
{columns.map((column, index) => (
<td key={`${row.id}-${column.key}`} className={`px-4 py-4 align-top text-sm leading-6 ${index === 0 ? 'font-semibold text-white' : 'text-slate-300'}`}>
{row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useId, useState } from 'react'
export default function DocsFaqAccordion({ items, initialOpenIndex = 0, renderAnswer }) {
const [openIndex, setOpenIndex] = useState(items.length > 0 ? initialOpenIndex : -1)
const baseId = useId()
useEffect(() => {
setOpenIndex(items.length > 0 ? Math.min(initialOpenIndex, items.length - 1) : -1)
}, [items.length, initialOpenIndex])
return (
<div className="space-y-3">
{items.map((item, index) => {
const buttonId = `${baseId}-button-${index}`
const panelId = `${baseId}-panel-${index}`
const isOpen = openIndex === index
const answerContent = renderAnswer ? renderAnswer(item) : item.answer
return (
<div key={item.question} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
<h3>
<button
id={buttonId}
type="button"
className="flex w-full items-center justify-between gap-4 px-4 py-4 text-left md:px-5"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpenIndex(isOpen ? -1 : index)}
>
<span className="text-base font-semibold text-white">{item.question}</span>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-slate-300">
<i className={`fa-solid ${isOpen ? 'fa-minus' : 'fa-plus'} text-xs`} />
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className={isOpen ? 'block border-t border-white/10 px-4 py-4 md:px-5' : 'hidden'}
>
{typeof answerContent === 'string' ? (
<p className="text-sm leading-7 text-slate-300">{answerContent}</p>
) : (
<div className="space-y-4 text-sm leading-7 text-slate-300">{answerContent}</div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export default function DocsSection({ id, eyebrow, title, summary, children, className = '' }) {
return (
<section id={id} aria-labelledby={`${id}-title`} className={`scroll-mt-24 rounded-[32px] border border-white/10 bg-white/[0.03] p-6 shadow-[0_22px_70px_rgba(2,6,23,0.22)] md:p-7 ${className}`.trim()}>
<div className="max-w-3xl">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p> : null}
<h2 id={`${id}-title`} className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white md:text-[2rem]">{title}</h2>
{summary ? <p className="mt-4 text-sm leading-7 text-slate-300 md:text-[15px]">{summary}</p> : null}
</div>
<div className="mt-6">{children}</div>
</section>
)
}

View File

@@ -0,0 +1,50 @@
import React from 'react'
function jumpToSection(targetId) {
if (!targetId || typeof window === 'undefined') return
const element = document.getElementById(targetId)
if (!element) return
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${targetId}`)
}
export default function DocsSidebarNav({ sections, ariaLabel = 'Sections on this page', selectLabel = 'Jump to section', navTitle = 'On this page' }) {
return (
<>
<div className="lg:hidden">
<label htmlFor="groups-help-nav" className="sr-only">{selectLabel}</label>
<select
id="groups-help-nav"
className="w-full rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white outline-none"
defaultValue=""
onChange={(event) => {
jumpToSection(event.target.value)
event.target.value = ''
}}
>
<option value="">Jump to a section</option>
{sections.map((section) => (
<option key={section.id} value={section.id}>{section.label}</option>
))}
</select>
</div>
<nav aria-label={ariaLabel} className="hidden lg:block lg:sticky lg:top-24">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.22)]">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">{navTitle}</p>
<ul className="mt-4 space-y-1.5">
{sections.map((section) => (
<li key={section.id}>
<a href={`#${section.id}`} className="block rounded-2xl px-3 py-2 text-sm text-slate-300 transition hover:bg-white/[0.05] hover:text-white">
{section.label}
</a>
</li>
))}
</ul>
</div>
</nav>
</>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function DocsStepList({ items }) {
return (
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.title} className="flex gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-300/10 text-sm font-semibold text-sky-100">
{index + 1}
</div>
<div className="min-w-0">
<h3 className="text-base font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
</li>
))}
</ol>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
export default function FaqSearchInput({ value, onChange, onClear, resultCount }) {
return (
<div className="rounded-[26px] border border-white/10 bg-black/20 p-4 md:p-5">
<label htmlFor="groups-faq-search" className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Search questions
</label>
<div className="mt-3 flex gap-3">
<div className="relative flex-1">
<span className="pointer-events-none absolute inset-y-0 left-4 flex items-center text-slate-500">
<i className="fa-solid fa-magnifying-glass" />
</span>
<input
id="groups-faq-search"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Search roles, invites, contributor credit, review, troubleshooting..."
className="w-full rounded-[20px] border border-white/10 bg-white/[0.04] py-3 pl-11 pr-4 text-sm text-white outline-none placeholder:text-slate-500"
/>
</div>
{value ? (
<button
type="button"
onClick={onClear}
className="rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]"
>
Clear
</button>
) : null}
</div>
<p className="mt-3 text-sm text-slate-400">{resultCount} question{resultCount === 1 ? '' : 's'} visible</p>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
export default function QuickstartChecklist({ title, summary, items }) {
return (
<section className="rounded-[30px] border border-emerald-300/20 bg-emerald-400/10 p-5 shadow-[0_22px_70px_rgba(2,6,23,0.2)] md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100/80">Checklist</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{title}</h3>
{summary ? <p className="mt-3 text-sm leading-7 text-emerald-50/90">{summary}</p> : null}
<ul className="mt-5 grid gap-3 md:grid-cols-2">
{items.map((item) => (
<li key={item} className="flex gap-3 rounded-[22px] border border-white/10 bg-black/20 px-4 py-4 text-sm leading-6 text-white">
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-300/20 bg-emerald-300/10 text-emerald-100">
<i className="fa-solid fa-check text-[10px]" />
</span>
<span>{item}</span>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
const TONES = {
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
white: 'border-white/10 bg-black/20 text-white',
}
export default function QuickstartNextSteps({ items }) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<a
key={item.title}
href={item.href}
className={`rounded-[28px] border p-5 transition hover:-translate-y-0.5 hover:border-white/20 ${TONES[item.tone] || TONES.white}`}
>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{item.eyebrow}</div>
<div className="mt-2 text-lg font-semibold text-white">{item.title}</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.body}</p>
</a>
))}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import React from 'react'
import LevelBadge from '../xp/LevelBadge'
const ROLE_STYLES = {
admin: 'bg-red-500/15 text-red-300',
moderator: 'bg-amber-500/15 text-amber-300',
member: 'bg-sky-500/15 text-sky-300',
}
const ROLE_LABELS = {
admin: 'Admin',
moderator: 'Moderator',
member: 'Member',
}
export default function AuthorBadge({ user, size = 'md' }) {
const name = user?.name ?? 'Anonymous'
const avatar = user?.avatar_url ?? '/default/avatar_default.webp'
const role = (user?.role ?? 'member').toLowerCase()
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
const label = ROLE_LABELS[role] ?? 'Member'
const level = Number(user?.level ?? 0)
const rank = user?.rank ?? null
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
return (
<div className="flex items-center gap-3">
<img
src={avatar}
alt={`${name} avatar`}
loading="lazy"
decoding="async"
className={`${imgSize} rounded-full border border-white/10 object-cover`}
/>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
{rank && level > 0 ? <LevelBadge level={level} rank={rank} compact /> : null}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
export default function Breadcrumbs({ items = [] }) {
return (
<nav className="text-sm text-zinc-400" aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center gap-1.5">
{items.map((item, i) => (
<React.Fragment key={i}>
{i > 0 && (
<li aria-hidden="true" className="text-zinc-600">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="inline-block">
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</li>
)}
<li>
{item.href ? (
<a href={item.href} className="hover:text-zinc-200 transition-colors">
{item.label}
</a>
) : (
<span className="text-zinc-200">{item.label}</span>
)}
</li>
</React.Fragment>
))}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,172 @@
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
}
}

View File

@@ -0,0 +1,134 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import EmojiMartPicker from '../common/EmojiMartPicker'
import loadEmojiMartData from '../common/loadEmojiMartData'
/**
* Emoji picker button for the forum rich-text editor.
* Uses the same emoji-mart picker as profile tweets / comments
* so the UI is consistent across the whole site.
*
* The panel is rendered through a React portal so it escapes any
* overflow-hidden containers (like the editor wrapper).
*/
export default function EmojiPicker({ onSelect, editor }) {
const [open, setOpen] = useState(false)
const [pickerData, setPickerData] = useState(null)
const [panelStyle, setPanelStyle] = useState({})
const panelRef = useRef(null)
const buttonRef = useRef(null)
useEffect(() => {
if (!open || pickerData) return
let cancelled = false
loadEmojiMartData().then((data) => {
if (!cancelled) {
setPickerData(data)
}
})
return () => {
cancelled = true
}
}, [open, pickerData])
// Position the portal panel relative to the trigger button
useEffect(() => {
if (!open || !buttonRef.current) return
const rect = buttonRef.current.getBoundingClientRect()
const panelWidth = 352 // emoji-mart default width
const panelHeight = 435 // approximate picker height
const spaceAbove = rect.top
const openAbove = spaceAbove > panelHeight + 8
setPanelStyle({
position: 'fixed',
zIndex: 9999,
left: Math.max(8, Math.min(rect.right - panelWidth, window.innerWidth - panelWidth - 8)),
...(openAbove
? { bottom: window.innerHeight - rect.top + 6 }
: { top: rect.bottom + 6 }),
})
}, [open])
// Close on outside click
useEffect(() => {
if (!open) return
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target) &&
buttonRef.current && !buttonRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
const handler = (e) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
const handleSelect = useCallback((emoji) => {
const native = emoji.native ?? ''
onSelect?.(native)
if (editor) {
editor.chain().focus().insertContent(native).run()
}
setOpen(false)
}, [onSelect, editor])
const panel = open ? createPortal(
<div
ref={panelRef}
style={panelStyle}
className="rounded-xl shadow-2xl overflow-hidden"
>
{pickerData ? (
<EmojiMartPicker
data={pickerData}
onEmojiSelect={handleSelect}
theme="dark"
previewPosition="none"
skinTonePosition="search"
maxFrequentRows={2}
perLine={9}
/>
) : (
<div className="flex h-24 w-[352px] items-center justify-center bg-zinc-900 px-4 text-sm text-zinc-300">
Loading emojis...
</div>
)}
</div>,
document.body,
) : null
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
title="Insert emoji"
aria-label="Open emoji picker"
aria-expanded={open}
className={[
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
open
? 'bg-sky-600/25 text-sky-300'
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
].join(' ')}
>
<span className="text-[15px]">😊</span>
</button>
{panel}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
/**
* Dropdown list rendered by TipTap's mention suggestion.
* Receives `items` (user objects) and keyboard nav commands via ref.
*/
const MentionList = forwardRef(function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)
// Reset selection when items change
useEffect(() => setSelectedIndex(0), [items])
// Expose keyboard handler to TipTap suggestion plugin
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((i) => (i + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((i) => (i + 1) % items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
},
}))
const selectItem = (index) => {
const item = items[index]
if (item) {
command({ id: item.username, label: item.username })
}
}
if (!items.length) {
return (
<div className="rounded-xl border border-white/[0.08] bg-nova-800 p-3 shadow-xl backdrop-blur">
<p className="text-xs text-zinc-500">No users found</p>
</div>
)
}
return (
<div className="min-w-[200px] overflow-hidden rounded-xl border border-white/[0.08] bg-nova-800 shadow-xl backdrop-blur">
{items.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => selectItem(index)}
className={[
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
index === selectedIndex
? 'bg-sky-600/20 text-white'
: 'text-zinc-300 hover:bg-white/[0.04]',
].join(' ')}
>
<img
src={item.avatar_url}
alt=""
className="h-6 w-6 rounded-full border border-white/10 object-cover"
/>
<span className="truncate font-medium">@{item.username}</span>
{item.name && item.name !== item.username && (
<span className="truncate text-xs text-zinc-500">{item.name}</span>
)}
</button>
))}
</div>
)
})
export default MentionList

View File

@@ -0,0 +1,93 @@
import React from 'react'
/**
* Pagination control that mirrors Laravel's paginator shape.
* Expects: { current_page, last_page, per_page, total, path } or links array.
*/
export default function Pagination({ meta, onPageChange }) {
const current = meta?.current_page ?? 1
const lastPage = meta?.last_page ?? 1
if (lastPage <= 1) return null
const pages = buildPages(current, lastPage)
const go = (page) => {
if (page < 1 || page > lastPage || page === current) return
if (onPageChange) {
onPageChange(page)
} else {
// Fallback: navigate via URL
const url = new URL(window.location.href)
url.searchParams.set('page', page)
window.location.href = url.toString()
}
}
return (
<nav aria-label="Pagination" className="flex items-center justify-center gap-1.5">
{/* Prev */}
<button
onClick={() => go(current - 1)}
disabled={current <= 1}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Previous page"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`dots-${i}`} className="px-1 text-xs text-zinc-600"></span>
) : (
<button
key={p}
onClick={() => go(p)}
aria-current={p === current ? 'page' : undefined}
className={[
'inline-flex h-9 min-w-[2.25rem] items-center justify-center rounded-xl text-sm font-medium transition-colors',
p === current
? 'bg-sky-600/20 text-sky-300 border border-sky-500/30'
: 'border border-white/10 text-zinc-400 hover:border-white/20 hover:text-white',
].join(' ')}
>
{p}
</button>
)
)}
{/* Next */}
<button
onClick={() => go(current + 1)}
disabled={current >= lastPage}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Next page"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</nav>
)
}
/** Build a compact page-number array with ellipsis. */
function buildPages(current, last) {
if (last <= 7) {
return Array.from({ length: last }, (_, i) => i + 1)
}
const pages = new Set([1, 2, current - 1, current, current + 1, last - 1, last])
const sorted = [...pages].filter(p => p >= 1 && p <= last).sort((a, b) => a - b)
const result = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
result.push('...')
}
result.push(sorted[i])
}
return result
}

View File

@@ -0,0 +1,202 @@
import React, { useState } from 'react'
import AuthorBadge from './AuthorBadge'
const REACTIONS = [
{ key: 'like', label: 'Like', emoji: '👍' },
{ key: 'love', label: 'Love', emoji: '❤️' },
{ key: 'fire', label: 'Amazing', emoji: '🔥' },
{ key: 'laugh', label: 'Funny', emoji: '😂' },
{ key: 'disagree', label: 'Disagree', emoji: '👎' },
]
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
const [reactionState, setReactionState] = useState(post?.reactions ?? { summary: {}, active: null })
const [reacting, setReacting] = useState(false)
const author = post?.user
const content = post?.rendered_content ?? post?.content ?? ''
const postedAt = post?.created_at
const editedAt = post?.edited_at
const isEdited = post?.is_edited
const postId = post?.id
const threadSlug = thread?.slug
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)
try {
const res = await fetch(`/forum/post/${postId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrf(),
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ reaction }),
})
if (res.ok) {
const json = await res.json()
setReactionState(json)
}
} catch { /* silent */ }
setReacting(false)
}
return (
<article
id={`post-${postId}`}
className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur transition-all hover:border-white/10"
>
{/* Header */}
<header className="flex flex-col gap-3 border-b border-white/[0.06] px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<AuthorBadge user={author} />
<div className="flex items-center gap-2 text-xs text-zinc-500">
{postedAt && (
<time dateTime={postedAt}>
{formatDate(postedAt)}
</time>
)}
{isOp && (
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-medium text-cyan-300">
OP
</span>
)}
</div>
</header>
{/* Body */}
<div className="px-5 py-5">
<div
className="prose prose-invert max-w-none text-sm leading-relaxed prose-pre:overflow-x-auto prose-a:text-sky-300 prose-a:hover:text-sky-200"
dangerouslySetInnerHTML={{ __html: content }}
/>
{isEdited && editedAt && (
<p className="mt-3 text-xs text-zinc-600">
Edited {formatTimeAgo(editedAt)}
</p>
)}
{/* Attachments */}
{post?.attachments?.length > 0 && (
<div className="mt-5 space-y-3 border-t border-white/[0.06] pt-4">
<h4 className="text-xs font-semibold uppercase tracking-widest text-white/30">Attachments</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{post.attachments.map((att) => (
<AttachmentItem key={att.id} attachment={att} />
))}
</div>
</div>
)}
</div>
{/* Footer */}
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
<div className="flex flex-wrap items-center gap-2">
{REACTIONS.map((reaction) => {
const count = reactionState?.summary?.[reaction.key] ?? 0
const isActive = reactionState?.active === reaction.key
return (
<button
key={reaction.key}
type="button"
disabled={!isAuthenticated || reacting}
onClick={() => handleReaction(reaction.key)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 transition-colors',
isActive
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200'
: 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
title={reaction.label}
>
<span>{reaction.emoji}</span>
<span>{count}</span>
</button>
)
})}
</div>
{/* Edit */}
{(post?.can_edit) && (
<a
href={`/forum/post/${postId}/edit`}
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
>
Edit
</a>
)}
{canModerate && (
<span className="ml-auto text-[11px] text-amber-400/60">Mod</span>
)}
</footer>
</article>
)
}
function AttachmentItem({ attachment }) {
const mime = attachment?.mime_type ?? ''
const isImage = mime.startsWith('image/')
const url = attachment?.url ?? '#'
return (
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-slate-900/60">
{isImage ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={url}
alt="Attachment"
loading="lazy"
decoding="async"
className="h-40 w-full object-cover transition-transform hover:scale-[1.02]"
/>
</a>
) : (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 text-sm text-sky-300 hover:text-sky-200"
>
<svg width="16" height="16" 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>
Download attachment
</a>
)}
</div>
)
}
function getCsrf() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? ''
}
function formatDate(dateStr) {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
function formatTimeAgo(dateStr) {
try {
const now = new Date()
const date = new Date(dateStr)
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 formatDate(dateStr)
} catch {
return ''
}
}

View File

@@ -0,0 +1,104 @@
import React, { useState, useRef, useCallback } from 'react'
import Button from '../ui/Button'
import RichTextEditor from './RichTextEditor'
import TurnstileField from '../security/TurnstileField'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken, captcha = {} }) {
const [content, setContent] = useState(prefill)
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const formRef = useRef(null)
const handleSubmit = useCallback(async (e) => {
e.preventDefault()
if (submitting || content.trim().length < 2) return
setSubmitting(true)
setError(null)
try {
const fingerprint = await buildBotFingerprint()
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Bot-Fingerprint': fingerprint,
'X-Captcha-Token': captchaToken,
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
body: JSON.stringify({
content: content.trim(),
homepage_url: '',
_bot_fingerprint: fingerprint,
[captcha.inputName || 'cf-turnstile-response']: captchaToken,
}),
})
if (res.ok) {
// Reload page to show new reply
window.location.reload()
} else if (res.status === 422) {
const json = await res.json()
setError(json.errors?.content?.[0] ?? json.errors?.bot?.[0] ?? json.message ?? 'Validation error.')
} else {
const json = await res.json().catch(() => ({}))
setError(json?.errors?.bot?.[0] ?? json?.message ?? 'Failed to post reply. Please try again.')
}
} catch {
setError('Network error. Please try again.')
}
setSubmitting(false)
}, [content, topicKey, csrfToken, submitting])
return (
<form
ref={formRef}
onSubmit={handleSubmit}
className="space-y-4 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur"
>
{quotedAuthor && (
<p className="text-xs text-cyan-400/70">
Replying with quote from <strong className="text-cyan-300">{quotedAuthor}</strong>
</p>
)}
{/* Rich text editor */}
<RichTextEditor
content={content}
onChange={setContent}
placeholder="Write your reply…"
error={error}
minHeight={10}
/>
{/* Submit */}
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
<div className="flex items-center justify-end">
<Button
type="submit"
variant="primary"
size="md"
loading={submitting}
disabled={content.trim().length < 2}
>
Post reply
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,316 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import Mention from '@tiptap/extension-mention'
import mentionSuggestion from './mentionSuggestion'
import EmojiPicker from './EmojiPicker'
/* ─── Toolbar button ─── */
function ToolbarBtn({ onClick, active, disabled, title, children }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={[
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
active
? 'bg-sky-600/25 text-sky-300'
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
disabled && 'opacity-30 pointer-events-none',
].filter(Boolean).join(' ')}
>
{children}
</button>
)
}
function Divider() {
return <div className="mx-1 h-5 w-px bg-white/10" />
}
/* ─── Toolbar ─── */
function Toolbar({ editor }) {
if (!editor) return null
const addLink = useCallback(() => {
const prev = editor.getAttributes('link').href
const url = window.prompt('URL', prev ?? 'https://')
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
}, [editor])
const addImage = useCallback(() => {
const url = window.prompt('Image URL', 'https://')
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
}, [editor])
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
{/* Text formatting */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive('underline')}
title="Underline (Ctrl+U)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
title="Strikethrough"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
</ToolbarBtn>
<Divider />
{/* Headings */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<span className="text-xs font-bold">H2</span>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<span className="text-xs font-bold">H3</span>
</ToolbarBtn>
<Divider />
{/* Lists */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet list"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Numbered list"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
</ToolbarBtn>
<Divider />
{/* Block elements */}
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Quote"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="Code block"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Inline code"
>
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
</ToolbarBtn>
<Divider />
{/* Link & Image */}
<ToolbarBtn
onClick={addLink}
active={editor.isActive('link')}
title="Link"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</ToolbarBtn>
<ToolbarBtn onClick={addImage} title="Insert image">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</ToolbarBtn>
<Divider />
{/* Horizontal rule */}
<ToolbarBtn
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
</ToolbarBtn>
{/* Emoji picker */}
<EmojiPicker editor={editor} />
{/* Mention hint */}
<ToolbarBtn
onClick={() => editor.chain().focus().insertContent('@').run()}
title="Mention a user (type @username)"
>
<span className="text-xs font-bold">@</span>
</ToolbarBtn>
{/* Undo / Redo */}
<div className="ml-auto flex items-center gap-0.5">
<ToolbarBtn
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
</ToolbarBtn>
</div>
</div>
)
}
/* ─── Main editor component ─── */
/**
* Rich text editor for forum posts & replies.
*
* @prop {string} content initial HTML content
* @prop {function} onChange called with HTML string on every change
* @prop {string} placeholder placeholder text
* @prop {string} error validation error message
* @prop {number} minHeight min height in rem (default 12)
* @prop {boolean} autofocus focus on mount
*/
export default function RichTextEditor({
content = '',
onChange,
placeholder = 'Write something…',
error,
minHeight = 12,
autofocus = false,
}) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: {
HTMLAttributes: { class: 'forum-code-block' },
},
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-sky-300 underline hover:text-sky-200',
rel: 'noopener noreferrer nofollow',
},
}),
Image.configure({
HTMLAttributes: { class: 'rounded-lg max-w-full' },
}),
Placeholder.configure({ placeholder }),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: mentionSuggestion,
}),
],
content,
autofocus,
editorProps: {
attributes: {
class: [
'prose prose-invert prose-sm max-w-none',
'focus:outline-none',
'px-4 py-3',
'prose-headings:text-white prose-headings:font-bold',
'prose-p:text-zinc-200 prose-p:leading-relaxed',
'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200',
'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400',
'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs',
'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl',
'prose-img:rounded-xl',
'prose-hr:border-white/10',
].join(' '),
style: `min-height: ${minHeight}rem`,
},
},
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML())
},
})
// Sync content from outside (e.g. prefill / quote)
useEffect(() => {
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
editor.commands.setContent(content, false)
}
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex flex-col gap-1.5">
<div
className={[
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
error
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
].join(' ')}
>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
{error && (
<p role="alert" className="text-xs text-red-400">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import React from 'react'
export default function ThreadRow({ thread, isFirst = false }) {
const id = thread?.topic_id ?? thread?.id ?? 0
const title = thread?.topic ?? thread?.title ?? 'Untitled'
const slug = thread?.slug ?? slugify(title)
const excerpt = thread?.discuss ?? ''
const posts = thread?.num_posts ?? 0
const author = thread?.uname ?? 'Unknown'
const lastUpdate = thread?.last_update ?? thread?.post_date
const isPinned = thread?.is_pinned ?? false
const href = `/forum/topic/${slug}`
return (
<a
href={href}
className={[
'group flex items-start gap-4 px-5 py-4 transition-colors hover:bg-white/[0.03]',
!isFirst && 'border-t border-white/[0.06]',
].filter(Boolean).join(' ')}
>
{/* Icon */}
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/10 text-sky-400 group-hover:bg-sky-500/15 transition-colors">
{isPinned ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z" />
</svg>
) : (
<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>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 truncate text-sm font-semibold leading-tight text-white transition-colors group-hover:text-sky-300">
{title}
</h3>
{isPinned && (
<span className="shrink-0 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300">
Pinned
</span>
)}
</div>
{excerpt && (
<p className="mt-0.5 truncate text-xs text-white/40">
{stripHtml(excerpt).slice(0, 180)}
</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-xs text-white/35">
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{author}
</span>
<span className="flex items-center gap-1">
<svg width="12" height="12" 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>
{posts} {posts === 1 ? 'reply' : 'replies'}
</span>
{lastUpdate && (
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{formatDate(lastUpdate)}
</span>
)}
</div>
</div>
{/* Reply count badge */}
<div className="mt-1 shrink-0">
<span className="inline-flex min-w-[2rem] items-center justify-center rounded-full bg-white/[0.06] px-2.5 py-1 text-xs font-medium text-white/60">
{posts}
</span>
</div>
</a>
)
}
function slugify(text) {
return (text ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 80)
}
function stripHtml(html) {
const decodeEntities = (value) => {
let decoded = String(value ?? '')
for (let index = 0; index < 4; index += 1) {
if (!decoded.includes('&')) break
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea')
textarea.innerHTML = decoded
const next = textarea.value
if (next === decoded) break
decoded = next
} else {
break
}
}
return decoded
}
if (typeof document !== 'undefined') {
const div = document.createElement('div')
div.innerHTML = decodeEntities(html)
return div.textContent || div.innerText || ''
}
return decodeEntities(html).replace(/<[^>]*>/g, '')
}
function formatDate(dateStr) {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
} catch {
return '-'
}
}

View File

@@ -0,0 +1,74 @@
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import MentionList from './MentionList'
/**
* TipTap suggestion configuration for @mentions.
* Fetches users from /api/search/users?q=... as the user types.
*/
export default {
items: async ({ query }) => {
if (!query || query.length < 2) return []
try {
const res = await fetch(`/api/search/users?q=${encodeURIComponent(query)}&per_page=6`)
if (!res.ok) return []
const json = await res.json()
return json.data ?? []
} catch {
return []
}
},
render: () => {
let component
let popup
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
theme: 'mention',
arrow: false,
offset: [0, 8],
})
},
onUpdate: (props) => {
component?.updateProps(props)
if (!props.clientRect) return
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
})
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
}

View File

@@ -0,0 +1,159 @@
.nb-react-carousel {
position: relative;
display: flex;
align-items: center;
min-height: 56px;
overflow: hidden;
}
.nb-react-viewport {
flex: 1 1 auto;
overflow: hidden;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.nb-react-strip {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap !important;
white-space: nowrap;
width: max-content;
min-width: max-content;
max-width: none;
padding: 0.6rem 3rem;
will-change: transform;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
cursor: grab;
user-select: none;
touch-action: pan-x;
}
.nb-react-strip.is-dragging {
transition: none;
cursor: grabbing;
}
.nb-react-fade {
position: absolute;
top: 0;
bottom: 0;
width: 80px;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease;
}
.nb-react-fade--left {
left: 0;
background: linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-fade--right {
right: 0;
background: linear-gradient(to left, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.6) 50%, transparent 100%);
}
.nb-react-carousel:not(.at-start) .nb-react-fade--left {
opacity: 1;
}
.nb-react-carousel:not(.at-end) .nb-react-fade--right {
opacity: 1;
}
.nb-react-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
background: rgba(15,23,36,0.9);
border: 1px solid rgba(255,255,255,0.18);
color: rgba(255,255,255,0.85);
cursor: pointer;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 200ms ease, background 150ms ease, transform 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-react-arrow--left {
left: 8px;
}
.nb-react-arrow--right {
right: 8px;
}
.nb-react-arrow:hover {
background: rgba(30,46,68,0.98);
color: #fff;
transform: translateY(-50%) scale(1.1);
}
.nb-react-arrow:active {
transform: translateY(-50%) scale(0.93);
}
.nb-react-carousel:not(.at-start) .nb-react-arrow--left {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-carousel:not(.at-end) .nb-react-arrow--right {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.nb-react-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
line-height: 1;
border-radius: 9999px;
padding: 0.35rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap !important;
text-decoration: none;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08);
color: rgba(200,215,230,0.85);
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease, box-shadow 150ms ease;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.nb-react-pill:hover {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.25);
color: #fff;
transform: translateY(-1px);
}
.nb-react-pill--active {
background: linear-gradient(135deg, #E07A21 0%, #c9650f 100%);
border-color: rgba(224,122,33,0.6);
color: #fff;
box-shadow: 0 2px 12px rgba(224,122,33,0.35), 0 0 0 1px rgba(224,122,33,0.2) inset;
transform: none;
}
.nb-react-pill--active:hover {
background: linear-gradient(135deg, #f08830 0%, #d9720f 100%);
transform: none;
}

View File

@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './CategoryPillCarousel.css';
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
export default function CategoryPillCarousel({
items = [],
ariaLabel = 'Filter by category',
className = '',
}) {
const viewportRef = useRef(null);
const stripRef = useRef(null);
const animationRef = useRef(0);
const suppressClickRef = useRef(false);
const dragStateRef = useRef({
active: false,
started: false,
captured: false,
pointerId: null,
pointerType: 'mouse',
startX: 0,
startY: 0,
startOffset: 0,
startedOnLink: false,
});
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const [maxScroll, setMaxScroll] = useState(0);
const activeIndex = useMemo(() => {
const idx = items.findIndex((item) => !!item.active);
return idx >= 0 ? idx : 0;
}, [items]);
const maxOffset = useCallback(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return 0;
return Math.max(0, strip.scrollWidth - viewport.clientWidth);
}, []);
const recalcBounds = useCallback(() => {
const max = maxOffset();
setMaxScroll(max);
setOffset((prev) => clamp(prev, -max, 0));
}, [maxOffset]);
const moveTo = useCallback((nextOffset) => {
const max = maxOffset();
const clamped = clamp(nextOffset, -max, 0);
setOffset(clamped);
}, [maxOffset]);
const animateTo = useCallback((targetOffset, duration = 380) => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
const max = maxOffset();
const target = clamp(targetOffset, -max, 0);
const start = offset;
const delta = target - start;
if (Math.abs(delta) < 1) {
setOffset(target);
return;
}
const startTime = performance.now();
setDragging(false);
const easeOutCubic = (t) => 1 - ((1 - t) ** 3);
const step = (now) => {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / duration);
const eased = easeOutCubic(progress);
setOffset(start + (delta * eased));
if (progress < 1) {
animationRef.current = requestAnimationFrame(step);
} else {
animationRef.current = 0;
setOffset(target);
}
};
animationRef.current = requestAnimationFrame(step);
}, [maxOffset, offset]);
const moveToPill = useCallback((direction) => {
const strip = stripRef.current;
if (!strip) return;
const pills = Array.from(strip.querySelectorAll('.nb-react-pill'));
if (!pills.length) return;
const viewLeft = -offset;
if (direction > 0) {
const next = pills.find((pill) => pill.offsetLeft > viewLeft + 6);
if (next) animateTo(-next.offsetLeft);
else animateTo(-maxOffset());
return;
}
for (let i = pills.length - 1; i >= 0; i -= 1) {
const left = pills[i].offsetLeft;
if (left < viewLeft - 6) {
animateTo(-left);
return;
}
}
animateTo(0);
}, [animateTo, maxOffset, offset]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const activeEl = strip.querySelector('[data-active-pill="true"]');
if (!activeEl) {
moveTo(0);
return;
}
const centered = -(activeEl.offsetLeft - (viewport.clientWidth / 2) + (activeEl.offsetWidth / 2));
moveTo(centered);
recalcBounds();
}, [activeIndex, items, moveTo, recalcBounds]);
useEffect(() => {
const viewport = viewportRef.current;
const strip = stripRef.current;
if (!viewport || !strip) return;
const measure = () => recalcBounds();
const rafId = requestAnimationFrame(measure);
window.addEventListener('resize', measure, { passive: true });
let ro = null;
if ('ResizeObserver' in window) {
ro = new ResizeObserver(measure);
ro.observe(viewport);
ro.observe(strip);
}
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', measure);
if (ro) ro.disconnect();
};
}, [items, recalcBounds]);
useEffect(() => {
const strip = stripRef.current;
if (!strip) return;
const onPointerDown = (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
dragStateRef.current.active = true;
dragStateRef.current.started = false;
dragStateRef.current.captured = false;
dragStateRef.current.pointerId = event.pointerId;
dragStateRef.current.pointerType = event.pointerType || 'mouse';
dragStateRef.current.startX = event.clientX;
dragStateRef.current.startY = event.clientY;
dragStateRef.current.startOffset = offset;
dragStateRef.current.startedOnLink = !!event.target.closest('.nb-react-pill');
setDragging(false);
};
const onPointerMove = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
const threshold = state.pointerType === 'touch'
? 12
: (state.startedOnLink ? 24 : 12);
if (!state.started) {
if (Math.abs(dx) <= threshold || Math.abs(dx) <= Math.abs(dy)) {
return;
}
state.started = true;
if (!state.captured && strip.setPointerCapture) {
try {
strip.setPointerCapture(event.pointerId);
state.captured = true;
} catch (_) {
state.captured = false;
}
}
setDragging(true);
}
if (state.started) {
event.preventDefault();
}
moveTo(state.startOffset + dx);
};
const onPointerUpOrCancel = (event) => {
const state = dragStateRef.current;
if (!state.active || state.pointerId !== event.pointerId) return;
suppressClickRef.current = state.started;
state.active = false;
state.started = false;
state.startedOnLink = false;
state.pointerId = null;
setDragging(false);
if (state.captured && strip.releasePointerCapture) {
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
state.captured = false;
};
const onClickCapture = (event) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
suppressClickRef.current = false;
};
strip.addEventListener('pointerdown', onPointerDown);
strip.addEventListener('pointermove', onPointerMove);
strip.addEventListener('pointerup', onPointerUpOrCancel);
strip.addEventListener('pointercancel', onPointerUpOrCancel);
strip.addEventListener('click', onClickCapture, true);
return () => {
strip.removeEventListener('pointerdown', onPointerDown);
strip.removeEventListener('pointermove', onPointerMove);
strip.removeEventListener('pointerup', onPointerUpOrCancel);
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
strip.removeEventListener('click', onClickCapture, true);
};
}, [moveTo, offset]);
useEffect(() => () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
}
}, []);
const max = maxScroll;
const atStart = offset >= -2;
const atEnd = offset <= -(max - 2);
return (
<div className={`nb-react-carousel ${atStart ? 'at-start' : ''} ${atEnd ? 'at-end' : ''} ${className}`.trim()}>
<div className="nb-react-fade nb-react-fade--left" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--left"
aria-label="Previous categories"
onClick={() => moveToPill(-1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd"/></svg>
</button>
<div className="nb-react-viewport" ref={viewportRef} role="list" aria-label={ariaLabel}>
<div
ref={stripRef}
className={`nb-react-strip ${dragging ? 'is-dragging' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
>
{items.map((item) => (
<a
key={`${item.href}-${item.label}`}
href={item.href}
className={`nb-react-pill ${item.active ? 'nb-react-pill--active' : ''}`}
aria-current={item.active ? 'page' : 'false'}
data-active-pill={item.active ? 'true' : undefined}
draggable={false}
onDragStart={(event) => event.preventDefault()}
>
{item.label}
</a>
))}
</div>
</div>
<div className="nb-react-fade nb-react-fade--right" aria-hidden="true" />
<button
type="button"
className="nb-react-arrow nb-react-arrow--right"
aria-label="Next categories"
onClick={() => moveToPill(1)}
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,199 @@
/*
* MasonryGallery scoped CSS
*
* Grid column definitions (activated when React adds .is-enhanced to the root).
* Mirrors the blade @push('styles') blocks so the same rules apply whether the
* page is rendered server-side or by the React component.
*/
/* ── Masonry grid ─────────────────────────────────────────────────────────── */
[data-nova-gallery].is-enhanced [data-gallery-grid] {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-auto-rows: 8px;
gap: 1rem;
}
@media (min-width: 768px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Spec §5: 4 columns desktop, scaling up for very wide screens */
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (min-width: 2200px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */
/*
* When ArtworkCard has no width/height data it renders the img as h-auto,
* meaning the container height is 0 until the image loads. Setting a
* default aspect-ratio here reserves approximate space immediately and
* prevents applyMasonry from calculating span=1 then jumping on load.
* Cards with an inline aspect-ratio style (from real dimensions) override this.
*/
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
aspect-ratio: 3 / 2;
width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */
}
/* Override: when an inline aspect-ratio is set by ArtworkCard those values */
/* take precedence naturally (inline style > class). No extra selector needed. */
/* ── Card max-height cap ──────────────────────────────────────────────────── */
/*
* Limits any single card to the height of 2 stacked 16:9 images in its column.
* Formula: 2 × (col_width × 9/16) = col_width × 9/8
*
* 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5
* = (100vw - 176px) / 5
* max-height = (100vw - 176px) / 5 × 9/8
* = (100vw - 176px) × 0.225
*
* 2-col (md): col_width = (100vw - 80px - 1×24px) / 2
* = (100vw - 104px) / 2
* max-height = (100vw - 104px) / 2 × 9/8
* = (100vw - 104px) × 0.5625
*
* 1-col mobile: uncapped portrait images are fine filling the full width.
*/
/* Global selector covers both the React-rendered gallery and the blade fallback */
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
overflow: hidden; /* ensure img is clipped at max-height */
}
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
/* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */
max-height: calc((100vw - 176px) * 9 / 40);
}
/* Wide (2-col spanning) cards get double the column width */
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
max-height: calc((100vw - 176px) * 9 / 20);
}
}
@media (min-width: 768px) and (max-width: 1023px) {
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
/* 2-column layout */
max-height: calc((100vw - 104px) * 9 / 16);
}
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
/* 2-col span fills full width on md breakpoint */
max-height: calc((100vw - 104px) * 9 / 8);
}
}
/* Image is positioned absolutely inside the container so it always fills
the capped box (max-height), cropping top/bottom via object-fit: cover. */
[data-nova-gallery] [data-gallery-grid] .nova-card-media > .nova-card-main-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.nova-skeleton-card {
border-radius: 1rem;
min-height: 180px;
background: linear-gradient(
110deg,
rgba(255, 255, 255, 0.06) 8%,
rgba(255, 255, 255, 0.12) 18%,
rgba(255, 255, 255, 0.06) 33%
);
background-size: 200% 100%;
animation: novaShimmer 1.2s linear infinite;
}
@keyframes novaShimmer {
to { background-position-x: -200%; }
}
/* ── Card enter animation (appended by infinite scroll) ───────────────────── */
.nova-card-enter { opacity: 0; transform: translateY(8px); }
.nova-card-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
/* ── Card hover: bottom glow pulse ───────────────────────────────────────── */
.nova-card > a {
will-change: transform, box-shadow;
}
.nova-card:hover > a {
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08),
0 0 20px rgba(224, 122, 33, 0.07);
}
/* ── Quick action buttons ─────────────────────────────────────────────────── */
/*
* .nb-card-actions absolutely positioned at top-right of .nova-card.
* Fades in + slides down slightly when the card is hovered.
* Requires .nova-card to have position:relative (set inline by ArtworkCard.jsx).
*/
.nb-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 30;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transform: translateY(-4px);
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
}
.nova-card:hover .nb-card-actions,
.nova-card:focus-within .nb-card-actions {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.nb-card-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: rgba(10, 14, 20, 0.75);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
text-decoration: none;
transition: background 150ms ease, transform 150ms ease, color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.nb-card-action-btn:hover {
background: rgba(224, 122, 33, 0.85);
color: #fff;
transform: scale(1.1);
}

View File

@@ -0,0 +1,551 @@
import React, {
useState, useEffect, useRef, useCallback, memo,
} from 'react';
import ArtworkGallery from '../artwork/ArtworkGallery';
import './MasonryGallery.css';
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
async function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return;
try {
await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
} catch {
// Discovery telemetry should never block the gallery UX.
}
}
// ── Masonry helpers ────────────────────────────────────────────────────────
const ROW_SIZE = 8;
const ROW_GAP = 16;
function applyMasonry(grid) {
if (!grid) return;
Array.from(grid.querySelectorAll('.nova-card')).forEach((card) => {
const media = card.querySelector('.nova-card-media') || card;
let height = media.getBoundingClientRect().height || 200;
// Clamp to the computed max-height so the span never over-reserves rows
// when CSS max-height kicks in (e.g. portrait images capped to 2×16:9).
const cssMaxH = parseFloat(getComputedStyle(media).maxHeight);
if (!isNaN(cssMaxH) && cssMaxH > 0 && cssMaxH < height) {
height = cssMaxH;
}
const span = Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_SIZE + ROW_GAP)));
card.style.gridRowEnd = `span ${span}`;
});
}
function waitForImages(el) {
return Promise.all(
Array.from(el.querySelectorAll('img')).map((img) =>
img.decode ? img.decode().catch(() => null) : Promise.resolve(),
),
);
}
// ── Page fetch helpers ─────────────────────────────────────────────────────
/**
* Fetch the next page of data.
*
* The response is either:
* - JSON { artworks: [...], next_cursor: '...' } when X-Requested-With is
* sent and the controller returns JSON (future enhancement)
* - HTML page we parse [data-react-masonry-gallery] from it and read its
* data-artworks / data-next-cursor / data-next-page-url attributes.
*/
async function fetchPageData(url) {
const res = await fetch(url, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ct = res.headers.get('content-type') || '';
// JSON fast-path (if controller ever returns JSON)
if (ct.includes('application/json')) {
const json = await res.json();
// Support multiple API payload shapes across endpoints.
const artworks = Array.isArray(json.artworks)
? json.artworks
: Array.isArray(json.data)
? json.data
: Array.isArray(json.items)
? json.items
: Array.isArray(json.results)
? json.results
: [];
const nextCursor = json.next_cursor
?? json.nextCursor
?? json.meta?.next_cursor
?? null;
const nextPageUrl = json.next_page_url
?? json.nextPageUrl
?? json.meta?.next_page_url
?? null;
const hasMore = typeof json.has_more === 'boolean'
? json.has_more
: typeof json.hasMore === 'boolean'
? json.hasMore
: null;
return {
artworks,
nextCursor,
nextPageUrl,
hasMore,
meta: json.meta ?? null,
};
}
// HTML: parse and extract mount-container data attributes
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const el = doc.querySelector('[data-react-masonry-gallery]');
if (!el) return { artworks: [], nextCursor: null, nextPageUrl: null };
let artworks = [];
try { artworks = JSON.parse(el.dataset.artworks || '[]'); } catch { /* empty */ }
return {
artworks,
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
meta: null,
};
}
// ── Skeleton row ──────────────────────────────────────────────────────────
function SkeletonCard() {
return <div className="nova-skeleton-card" aria-hidden="true" />;
}
// ── Ranking API helpers ───────────────────────────────────────────────────
/**
* Map a single ArtworkListResource item (from /api/rank/*) to the internal
* artwork object shape used by ArtworkCard.
*/
function mapRankApiArtwork(item) {
const w = item.dimensions?.width ?? null;
const h = item.dimensions?.height ?? null;
const thumb = item.thumbnail_url ?? null;
const webUrl = item.urls?.web ?? item.category?.url ?? null;
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null;
return {
id: item.id ?? null,
name: item.title ?? item.name ?? null,
thumb: thumb,
thumb_url: thumb,
uname: item.author?.name ?? '',
username: publisher?.type === 'group' ? '' : (item.author?.username ?? ''),
avatar_url: item.author?.avatar_url ?? null,
profile_url: publisher?.profile_url ?? item.author?.profile_url ?? null,
published_as_type: publisher?.type ?? null,
publisher: publisher,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
url: webUrl,
width: w,
height: h,
};
}
/**
* Fetch ranked artworks from the ranking API.
* Returns { artworks: [...] } in internal shape, or { artworks: [] } on failure.
*/
async function fetchRankApiArtworks(endpoint, rankType) {
try {
const url = new URL(endpoint, window.location.href);
if (rankType) url.searchParams.set('type', rankType);
const res = await fetch(url.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return { artworks: [] };
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return { artworks: items.map(mapRankApiArtwork) };
} catch {
return { artworks: [] };
}
}
const SKELETON_COUNT = 10;
function getMasonryCardProps(art, idx) {
const title = (art.name || art.title || 'Untitled artwork').trim();
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
const categorySlug = (art.category_slug || '').toLowerCase();
const categoryName = (art.category_name || art.category || '').toLowerCase();
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
const wideCategoryNames = ['photography', 'wallpapers'];
const isWideEligible =
aspectRatio !== null &&
aspectRatio > 2.0 &&
(wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName));
return {
articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`,
articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined,
frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 hover:ring-white/15 hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]',
mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900',
mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined,
imageSrcSet: art.thumb_srcset || undefined,
imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw',
imageWidth: hasDimensions ? art.width : undefined,
imageHeight: hasDimensions ? art.height : undefined,
loading: idx < 8 ? 'eager' : 'lazy',
decoding: idx < 8 ? 'sync' : 'async',
fetchPriority: idx === 0 ? 'high' : undefined,
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
metricBadge: art.recommendation_reason
? {
label: art.recommendation_reason,
className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
}
: null,
};
}
// ── Main component ────────────────────────────────────────────────────────
/**
* MasonryGallery
*
* Props (all optional set via data attributes in entry-masonry-gallery.jsx):
* artworks [] Initial artwork objects
* galleryType string Maps to data-gallery-type (e.g. 'trending')
* cursorEndpoint string|null Route for cursor-based feeds (e.g. For You)
* initialNextCursor string|null First cursor token
* initialNextPageUrl string|null First "next page" URL (page-based feeds)
* limit number Items per page (default 40)
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
* gridClassName string|null Optional CSS class override for grid columns/gaps
*/
function MasonryGallery({
artworks: initialArtworks = [],
galleryType = 'discover',
cursorEndpoint = null,
initialNextCursor = null,
initialNextPageUrl = null,
limit = 40,
rankApiEndpoint = null,
rankType = null,
gridClassName = null,
discoveryEndpoint = null,
algoVersion: initialAlgoVersion = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
const [algoVersion, setAlgoVersion] = useState(initialAlgoVersion);
const gridRef = useRef(null);
const triggerRef = useRef(null);
const viewedArtworkIdsRef = useRef(new Set());
const clickedArtworkIdsRef = useRef(new Set());
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
// or empty page result) and a ranking API endpoint is configured, perform a
// client-side fetch from the ranking API to hydrate the grid.
// Satisfies spec: "Fallback: Latest if ranking missing".
useEffect(() => {
if (initialArtworks.length > 0) return; // SSR artworks already present
if (!rankApiEndpoint) return; // no API endpoint configured
let cancelled = false;
setLoading(true);
fetchRankApiArtworks(rankApiEndpoint, rankType).then(({ artworks: ranked }) => {
if (cancelled) return;
if (ranked.length > 0) {
setArtworks(ranked);
setDone(true); // ranking API returns a full list; no further pagination
}
setLoading(false);
});
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Masonry re-layout ──────────────────────────────────────────────────
const relayout = useCallback(() => {
const g = gridRef.current;
if (!g) return;
applyMasonry(g);
waitForImages(g).then(() => applyMasonry(g));
}, []);
// Re-layout whenever artworks list changes.
// Defer by one requestAnimationFrame so the browser has resolved
// aspect-ratio heights before we measure with getBoundingClientRect().
useEffect(() => {
const raf = requestAnimationFrame(() => relayout());
return () => cancelAnimationFrame(raf);
}, [artworks, relayout]);
// Re-layout on container resize (column width changes)
useEffect(() => {
const g = gridRef.current;
if (!g || !('ResizeObserver' in window)) return;
const ro = new ResizeObserver(relayout);
ro.observe(g);
return () => ro.disconnect();
}, [relayout]);
// ── Load more ──────────────────────────────────────────────────────────
const fetchNext = useCallback(async () => {
if (loading || done) return;
// Build the URL to fetch
let fetchUrl = null;
if (cursorEndpoint && nextCursor) {
const u = new URL(cursorEndpoint, window.location.href);
u.searchParams.set('cursor', nextCursor);
u.searchParams.set('limit', String(limit));
fetchUrl = u.toString();
} else if (nextPageUrl) {
fetchUrl = nextPageUrl;
}
if (!fetchUrl) { setDone(true); return; }
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore, meta } =
await fetchPageData(fetchUrl);
if (meta?.algo_version) {
setAlgoVersion(meta.algo_version);
}
if (!newItems.length) {
setDone(true);
} else {
setArtworks((prev) => [...prev, ...newItems]);
if (cursorEndpoint) {
setNextCursor(nc);
if (hasMore === false || !nc) setDone(true);
} else {
setNextPageUrl(np);
if (!np) setDone(true);
}
}
} catch {
setDone(true);
} finally {
setLoading(false);
}
}, [loading, done, cursorEndpoint, nextCursor, nextPageUrl, limit]);
// ── Intersection observer for infinite scroll ──────────────────────────
useEffect(() => {
if (done) return;
const trigger = triggerRef.current;
if (!trigger || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) fetchNext(); },
{ rootMargin: '900px', threshold: 0 },
);
io.observe(trigger);
return () => io.disconnect();
}, [done, fetchNext]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid || !(window.IntersectionObserver)) return undefined;
const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }]));
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.65) {
return;
}
const card = entry.target.closest('[data-art-id]');
const artworkId = card?.getAttribute('data-art-id');
if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) {
return;
}
const candidate = artworkIndex.get(artworkId);
if (!candidate?.art?.id) {
return;
}
viewedArtworkIdsRef.current.add(artworkId);
observer.unobserve(entry.target);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'view',
artwork_id: Number(candidate.art.id),
algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
position: candidate.index + 1,
source: candidate.art.recommendation_source || null,
reason: candidate.art.recommendation_reason || null,
score: candidate.art.recommendation_score ?? null,
},
});
});
},
{ threshold: [0.65] },
);
const cards = grid.querySelectorAll('[data-art-id]');
cards.forEach((card) => observer.observe(card));
return () => observer.disconnect();
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid) return undefined;
const handleClick = (event) => {
const card = event.target.closest('[data-art-id]');
if (!card) return;
const artworkId = card.getAttribute('data-art-id');
if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) {
return;
}
const artwork = artworks.find((item) => String(item.id) === artworkId);
if (!artwork?.id) {
return;
}
clickedArtworkIdsRef.current.add(artworkId);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'click',
artwork_id: Number(artwork.id),
algo_version: artwork.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
source: artwork.recommendation_source || null,
reason: artwork.recommendation_reason || null,
score: artwork.recommendation_score ?? null,
target_url: artwork.url || null,
},
});
};
grid.addEventListener('click', handleClick, true);
return () => grid.removeEventListener('click', handleClick, true);
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
className="pb-10 pt-2 is-enhanced"
data-nova-gallery
data-gallery-type={galleryType}
data-react-masonry-gallery
data-artworks={JSON.stringify(artworks)}
data-next-cursor={nextCursor ?? undefined}
data-next-page-url={nextPageUrl ?? undefined}
>
{artworks.length > 0 ? (
<>
<div
ref={gridRef}
>
<ArtworkGallery
items={artworks}
layout="masonry"
className={gridClass}
containerProps={{ 'data-gallery-grid': true }}
resolveCardProps={getMasonryCardProps}
/>
</div>
{/* Infinite scroll sentinel placed after the grid */}
{!done && (
<div
ref={triggerRef}
className="h-px w-full"
aria-hidden="true"
/>
)}
{/* Loading indicator */}
{loading && (
<div className="flex justify-center items-center gap-2 mt-8 py-4 text-white/30 text-sm">
<svg
className="animate-spin h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8H4z"
/>
</svg>
Loading more
</div>
)}
{done && artworks.length > 0 && (
<p className="text-center text-xs text-white/20 mt-8 py-2">
All caught up
</p>
)}
</>
) : (
/* Empty state gallery-type-specific messaging handled by caller */
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p className="text-white/40 text-sm">No artworks found for this section yet.</p>
</div>
)}
</section>
);
}
export default memo(MasonryGallery);

View File

@@ -0,0 +1,12 @@
import React from 'react'
import { toneClasses } from './groupStyles'
export default function GroupBadgePill({ label, tone = 'slate', className = '' }) {
if (!label) return null
return (
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${toneClasses(tone)} ${className}`.trim()}>
{label}
</span>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function GroupBrowseFilters({ surfaces = [], currentSurface = 'featured' }) {
if (!Array.isArray(surfaces) || surfaces.length === 0) return null
return (
<div className="mt-6 flex flex-wrap gap-2">
{surfaces.map((surface) => (
<a
key={surface.value}
href={`/groups?surface=${encodeURIComponent(surface.value)}`}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${currentSurface === surface.value ? 'border-sky-300/25 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-white hover:border-white/20 hover:bg-white/[0.06]'}`}
>
{surface.label}
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
import { cx, formatCompactNumber } from './groupStyles'
export default function GroupDiscoveryCard({ group, className = '', compact = false }) {
if (!group) return null
const primarySummary = group.headline || group.bio_excerpt || 'Collaborative publishing identity on Skinbase Nova.'
return (
<a
href={group.urls?.public || '/groups'}
className={cx(
'group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
className,
)}
>
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? (
<img src={group.avatar_url} alt="" className="h-full w-full object-cover" loading="lazy" />
) : (
<i className="fa-solid fa-people-group text-slate-300" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold text-white">{group.name}</h3>
{group.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
{group.is_verified ? <GroupBadgePill label="Verified" tone="sky" /> : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{primarySummary}</p>
{group.owner?.username || group.owner?.name ? (
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">
Led by {group.owner?.username || group.owner?.name}
</p>
) : null}
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{(Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, compact ? 2 : 3).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{(Array.isArray(group.badges) ? group.badges : []).slice(0, compact ? 1 : 2).map((badge) => (
<GroupBadgePill key={badge.key} label={badge.label} tone="amber" />
))}
</div>
{group.recruitment_headline && !compact ? (
<div className="mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Open call</div>
<div className="mt-1">{group.recruitment_headline}</div>
</div>
) : null}
{group.featured_release?.title && !compact ? (
<div className="mt-5 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Featured release</div>
<div className="mt-2 text-base font-semibold text-white">{group.featured_release.title}</div>
{group.featured_release.summary ? <div className="mt-1 text-sm text-slate-400">{group.featured_release.summary}</div> : null}
</div>
) : null}
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-center">
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.artworks)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.members)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{formatCompactNumber(group.counts?.followers)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupLeaderboardCard({ item }) {
if (!item?.entity) return null
const entity = item.entity
return (
<article className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-slate-950/70 text-lg font-black text-white">
#{item.rank}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<a href={entity.url || '/groups'} className="block truncate text-lg font-semibold text-white transition hover:text-sky-300">{entity.name}</a>
{entity.headline ? <p className="mt-1 text-sm text-slate-400">{entity.headline}</p> : null}
</div>
<div className="text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-500">Score</div>
<div className="mt-1 text-xl font-black text-white">{Number(item.score || 0).toLocaleString()}</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(Array.isArray(entity.trust_signals) ? entity.trust_signals : []).slice(0, 2).map((signal) => (
<GroupBadgePill key={signal.key} label={signal.label} tone={signal.tone} />
))}
{entity.is_recruiting ? <GroupBadgePill label="Recruiting" tone="emerald" /> : null}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entity.artworks_count || 0).toLocaleString()} artworks</span>
<span>{Number(entity.members_count || 0).toLocaleString()} members</span>
<span>{Number(entity.followers_count || 0).toLocaleString()} followers</span>
</div>
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
export default function GroupProfileSummary({ contributions = [], href = null }) {
if (!Array.isArray(contributions) || contributions.length === 0) return null
const featured = contributions.slice(0, 3)
return (
<section className="mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_55px_rgba(2,6,23,0.28)] backdrop-blur-xl sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Group footprint</div>
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative work across public groups</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">See the groups this creator contributes to through releases, credited artworks, and shared publishing activity.</p>
</div>
{href ? <a href={href} className="text-sm font-semibold text-sky-200 transition hover:text-white">View full contribution history</a> : null}
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-3">
{featured.map((entry) => (
<a key={entry.group?.slug || entry.group?.id} href={entry.group?.profile_url || '/groups'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{entry.group?.avatar_url ? <img src={entry.group.avatar_url} alt={entry.group?.name} className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-white">{entry.group?.name}</div>
{entry.role?.label ? <div className="mt-1 text-sm text-slate-400">{entry.role.label}</div> : null}
{entry.summary ? <div className="mt-2 text-sm text-slate-300">{entry.summary}</div> : null}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{entry.trusted ? <GroupBadgePill label="Trusted contributor" tone="sky" /> : null}
{entry.recent_release_titles?.length ? <GroupBadgePill label="Recent releases" tone="amber" /> : null}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entry.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
</div>
</a>
))}
</div>
</div>
</section>
)
}

Some files were not shown because too many files have changed in this diff Show More