feat: artwork page carousels, recommendations, avatars & fixes

- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
2026-02-28 14:05:39 +01:00
parent 80100c7651
commit eee7df1f8c
46 changed files with 2536 additions and 498 deletions

View File

@@ -1,18 +1,19 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import axios from 'axios'
import ArtworkHero from '../components/artwork/ArtworkHero'
import ArtworkMeta from '../components/artwork/ArtworkMeta'
import ArtworkActions from '../components/artwork/ArtworkActions'
import ArtworkAwards from '../components/artwork/ArtworkAwards'
import ArtworkStats from '../components/artwork/ArtworkStats'
import ArtworkTags from '../components/artwork/ArtworkTags'
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
import ArtworkRelated from '../components/artwork/ArtworkRelated'
import ArtworkDescription from '../components/artwork/ArtworkDescription'
import ArtworkComments from '../components/artwork/ArtworkComments'
import ArtworkReactions from '../components/artwork/ArtworkReactions'
import ArtworkActionBar from '../components/artwork/ArtworkActionBar'
import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel'
import CreatorSpotlight from '../components/artwork/CreatorSpotlight'
import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommendationsRails'
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
import ArtworkViewer from '../components/viewer/ArtworkViewer'
import ReactionBar from '../components/comments/ReactionBar'
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
const [viewerOpen, setViewerOpen] = useState(false)
@@ -43,6 +44,16 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
// Artwork-level reactions
const [reactionTotals, setReactionTotals] = useState(null)
useEffect(() => {
if (!artwork?.id) return
axios
.get(`/api/artworks/${artwork.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => setReactionTotals({}))
}, [artwork?.id])
/**
* Called by ArtworkNavigator after a successful no-reload navigation.
* data = ArtworkResource JSON from /api/artworks/{id}/page
@@ -66,50 +77,83 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
return (
<>
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
<ArtworkHero
artwork={artwork}
presentMd={presentMd}
presentLg={presentLg}
presentXl={presentXl}
onOpenViewer={openViewer}
hasPrev={navState.hasPrev}
hasNext={navState.hasNext}
onPrev={navState.navigatePrev}
onNext={navState.navigateNext}
/>
<div className="mt-6 space-y-4 lg:hidden">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
<main className="pb-24 pt-6 lg:pb-12 lg:pt-8">
{/* ── Hero ────────────────────────────────────────────────────── */}
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
<ArtworkHero
artwork={artwork}
presentMd={presentMd}
presentLg={presentLg}
presentXl={presentXl}
onOpenViewer={openViewer}
hasPrev={navState.hasPrev}
hasNext={navState.hasNext}
onPrev={navState.navigatePrev}
onNext={navState.navigateNext}
/>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<ArtworkMeta artwork={artwork} />
<ArtworkStats artwork={artwork} stats={liveStats} />
<ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} />
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
<ArtworkComments
artworkId={artwork.id}
comments={comments}
isLoggedIn={isAuthenticated}
loginUrl="/login"
/>
</div>
{/* ── Centered action bar with stat counts ────────────────────── */}
<div className="mx-auto mt-5 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
<ArtworkActionBar
artwork={artwork}
stats={liveStats}
canonicalUrl={canonicalUrl}
onStatsChange={handleStatsChange}
/>
</div>
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24 space-y-4">
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} onStatsChange={handleStatsChange} />
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
{/* ── Two-column content ──────────────────────────────────────── */}
<div className="mx-auto mt-8 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
{/* LEFT COLUMN — main content */}
<div className="relative z-10 min-w-0 space-y-5">
{/* Title + author + breadcrumbs */}
<ArtworkMeta artwork={artwork} />
{/* Description */}
<ArtworkDescription artwork={artwork} />
{/* Artwork reactions */}
{reactionTotals !== null && (
<ReactionBar
entityType="artwork"
entityId={artwork.id}
initialTotals={reactionTotals}
isLoggedIn={isAuthenticated}
/>
)}
{/* Tags & categories */}
<ArtworkTags artwork={artwork} />
{/* Comments */}
<ArtworkComments
artworkId={artwork.id}
comments={comments}
isLoggedIn={isAuthenticated}
loginUrl="/login"
/>
</div>
</aside>
{/* RIGHT COLUMN — sidebar */}
<aside className="space-y-5 lg:sticky lg:top-6 lg:self-start">
{/* Creator card */}
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
{/* Details (collapsible) */}
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
{/* Awards */}
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</aside>
</div>
</div>
<ArtworkRelated related={related} />
{/* ── Full-width recommendation rails ─────────────────────────── */}
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
<ArtworkRecommendationsRails artwork={artwork} related={related} />
</div>
</main>
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}

View File

@@ -1,7 +1,7 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null

View File

@@ -1,6 +1,6 @@
import React from 'react'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function CreatorCard({ creator }) {
return (

View File

@@ -1,7 +1,7 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function FreshCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null

View File

@@ -1,7 +1,7 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null

View File

@@ -1,6 +1,6 @@
import React from 'react'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function CreatorCard({ creator }) {
return (

View File

@@ -1,7 +1,7 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null

View File

@@ -1,7 +1,7 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null

View File

@@ -1,6 +1,6 @@
import React from 'react'
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
export default function HomeWelcomeRow({ user_data }) {
if (!user_data) return null

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import SearchBar from '../Search/SearchBar'
const DEFAULT_AVATAR = 'https://files.skinbase.org/avatars/default.webp'
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
export default function Topbar({ user = null }) {
const [menuOpen, setMenuOpen] = useState(false)

View File

@@ -0,0 +1,324 @@
import React, { useEffect, useState } 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}`
}
/* ── 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>
)
}
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>
)
}
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>
)
}
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [downloading, setDownloading] = useState(false)
const [reporting, setReporting] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
setLiked(Boolean(artwork?.viewer?.is_liked))
setFavorited(Boolean(artwork?.viewer?.is_favorited))
}, [artwork?.id, artwork?.viewer?.is_liked, artwork?.viewer?.is_favorited])
const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
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 res = await fetch(`/api/art/${artwork.id}/download`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
const data = res.ok ? await res.json() : null
const url = data?.url || fallbackUrl
const a = document.createElement('a')
a.href = url
a.download = data?.filename || ''
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}
}
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)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} 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) }
}
const likeCount = formatCount(stats?.likes ?? artwork?.stats?.likes ?? 0)
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
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">
{/* Like stat pill */}
<button
type="button"
aria-label={liked ? 'Unlike artwork' : 'Like artwork'}
onClick={onToggleLike}
className={[
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
liked
? '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={liked} />
<span className="tabular-nums">{likeCount}</span>
</button>
{/* Favorite/bookmark stat pill */}
<button
type="button"
aria-label={favorited ? 'Unsave artwork' : 'Save artwork'}
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-amber-500/40 bg-amber-500/15 text-amber-400 shadow-lg shadow-amber-500/10 hover:bg-amber-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(' ')}
>
<BookmarkIcon filled={favorited} />
<span className="tabular-nums">{favCount}</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 */}
<button
type="button"
aria-label="Share artwork"
onClick={onShare}
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 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
>
<ShareIcon />
{copied ? 'Copied!' : 'Share'}
</button>
{/* Report pill */}
<button
type="button"
aria-label="Report artwork"
onClick={onReport}
disabled={reporting}
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 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400 disabled:cursor-wait disabled:opacity-50"
>
<FlagIcon />
{reporting ? '…' : '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={liked ? 'Unlike' : 'Like'}
onClick={onToggleLike}
className={[
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
liked
? '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={liked} />
<span className="tabular-nums">{likeCount}</span>
</button>
<button
type="button"
aria-label={favorited ? 'Unsave' : 'Save'}
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-amber-500/40 bg-amber-500/15 text-amber-400'
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
].join(' ')}
>
<BookmarkIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
</button>
{/* Share */}
<button
type="button"
aria-label="Share"
onClick={onShare}
className="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"
>
<ShareIcon />
</button>
{/* Report */}
<button
type="button"
aria-label="Report"
onClick={onReport}
disabled={reporting}
className="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-red-500/40 hover:text-red-400 disabled:opacity-50"
>
<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>
</>
)
}

View File

@@ -136,8 +136,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
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">Awards</h2>
<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">Awards</h2>
{error && (
<p className="mt-2 text-xs text-red-400">{error}</p>
@@ -158,8 +158,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
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 bg-accent/10 font-semibold text-accent'
: 'border-nova-600 text-white hover:bg-nova-800',
? '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 || loading !== null) && 'cursor-not-allowed opacity-60',
].filter(Boolean).join(' ')}
>

View File

@@ -0,0 +1,33 @@
import React from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
export default function ArtworkCardMini({ item }) {
if (!item?.url) return null
return (
<article className="group min-w-[14rem] shrink-0 snap-start overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 hover:-translate-y-0.5 hover:border-white/[0.1] hover:shadow-xl hover:shadow-black/30">
<a href={item.url} className="block">
<div className="relative aspect-[4/3] overflow-hidden bg-deep">
<img
src={item.thumb || FALLBACK_MD}
srcSet={item.thumbSrcSet || undefined}
sizes="256px"
alt={item.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.src = FALLBACK_MD
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-60" />
</div>
<div className="px-3.5 py-3">
<h3 className="truncate text-sm font-semibold text-white/90">{item.title || 'Untitled'}</h3>
<p className="mt-0.5 truncate text-xs text-white/40">by {item.author || 'Artist'}</p>
</div>
</a>
</article>
)
}

View File

@@ -20,6 +20,32 @@ function timeAgo(dateStr) {
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 (
@@ -28,12 +54,12 @@ function Avatar({ user, size = 36 }) {
alt={user.name || user.username || ''}
width={size}
height={size}
className="rounded-full object-cover shrink-0"
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/avatars/default.webp'
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
@@ -41,7 +67,7 @@ function Avatar({ user, size = 36 }) {
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<span
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
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}
@@ -49,21 +75,172 @@ function Avatar({ user, size = 36 }) {
)
}
// ── Single comment ────────────────────────────────────────────────────────────
// ── Reply item (nested under a parent) ────────────────────────────────────────
function CommentItem({ comment, isLoggedIn }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.content ?? ''
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 || []
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
const flood = isFlood(plain)
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>
)}
<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)
// Build initial reaction totals (empty if not provided by server)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
// Load reactions lazily if not provided
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
@@ -72,92 +249,159 @@ function CommentItem({ comment, isLoggedIn }) {
.catch(() => {})
}, [comment.id, comment.reactions])
return (
<li className="flex gap-3" id={`comment-${comment.id}`}>
{/* Avatar */}
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={36} />
</a>
) : (
<span className="shrink-0 mt-0.5">
<Avatar user={user} size={36} />
</span>
)}
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(comment.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [comment.id, onReplyPosted])
{/* Content */}
<div className="min-w-0 flex-1 space-y-1.5">
{/* Header */}
<div className="flex items-baseline gap-2 flex-wrap">
// 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="text-sm font-medium text-white hover:underline">
{user.display || user.username || user.name || 'Member'}
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={38} />
</a>
) : (
<span className="text-sm font-medium text-white">
{user?.display || user?.username || user?.name || 'Member'}
</span>
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
)}
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-xs text-neutral-500"
>
{comment.time_ago || timeAgo(comment.created_at)}
</time>
</div>
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
<div
className={!expanded ? 'overflow-hidden relative' : undefined}
style={!expanded ? { maxHeight: '5em' } : undefined}
>
{html ? (
{/* 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>
)}
<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="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
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:rounded prose-code:text-xs"
// rendered_content is server-sanitized HTML safe to inject
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
{plain}
</p>
)}
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>
)}
{/* Gradient fade at the bottom while collapsed */}
{flood && !expanded && (
<div
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
aria-hidden="true"
/>
)}
{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>
{/* Flood expand / collapse toggle */}
{flood && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-xs text-sky-400 hover:text-sky-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-500 rounded"
aria-expanded={expanded}
>
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
</button>
)}
{/* Reactions */}
{Object.keys(reactionTotals).length > 0 && (
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
)}
</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>
)
}
@@ -166,14 +410,24 @@ function CommentItem({ comment, isLoggedIn }) {
function Skeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-3 bg-white/[0.07] rounded w-28" />
<div className="h-3 bg-white/[0.05] rounded w-full" />
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
<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>
))}
@@ -183,19 +437,6 @@ function Skeleton() {
// ── Main export ───────────────────────────────────────────────────────────────
/**
* ArtworkComments
*
* Can operate in two modes:
* 1. Static: pass `comments` array from Inertia page props (legacy / SSR)
* 2. Dynamic: pass `artworkId` to load + post comments via the API
*
* Props:
* artworkId number Used for API calls
* comments array SSR initial comments (optional)
* isLoggedIn boolean
* loginUrl string
*/
export default function ArtworkComments({
artworkId,
comments: initialComments = [],
@@ -209,7 +450,6 @@ export default function ArtworkComments({
const [total, setTotal] = useState(initialComments.length)
const initialized = useRef(false)
// Load comments from API
const loadComments = useCallback(
async (p = 1) => {
if (!artworkId) return
@@ -225,7 +465,7 @@ export default function ArtworkComments({
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// keep existing data on error
// keep existing
} finally {
setLoading(false)
}
@@ -233,7 +473,6 @@ export default function ArtworkComments({
[artworkId],
)
// On mount, load if artworkId provided and no SSR comments given
useEffect(() => {
if (initialized.current) return
initialized.current = true
@@ -245,21 +484,95 @@ export default function ArtworkComments({
}
}, [artworkId, initialComments.length, loadComments])
// New top-level comment posted
const handlePosted = useCallback((newComment) => {
setComments((prev) => [newComment, ...prev])
// 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">
<h2 className="text-base font-semibold text-white">
Comments{' '}
{/* 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="text-neutral-500 font-normal">({total})</span>
<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>
)}
</h2>
</div>
{/* Comment form */}
{/* 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}
@@ -268,39 +581,6 @@ export default function ArtworkComments({
loginUrl={loginUrl}
/>
)}
{/* Comment list */}
{loading && comments.length === 0 ? (
<Skeleton />
) : comments.length === 0 ? (
<p className="text-sm text-neutral-500">No comments yet. Be the first!</p>
) : (
<>
<ul className="space-y-5">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
isLoggedIn={isLoggedIn}
/>
))}
</ul>
{/* Load more */}
{page < lastPage && (
<div className="flex justify-center pt-2">
<button
type="button"
disabled={loading}
onClick={() => loadComments(page + 1)}
className="px-5 py-2 rounded-lg text-sm text-white/60 border border-white/[0.08] hover:text-white hover:border-white/20 transition-colors disabled:opacity-40"
>
{loading ? 'Loading…' : 'Load more comments'}
</button>
</div>
)}
</>
)}
</section>
)
}

View File

@@ -39,7 +39,7 @@ function renderMarkdownSafe(text) {
}
return (
<p key={`p-${lineIndex}`} className="text-base leading-7 text-soft">
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
{parts}
</p>
)
@@ -60,19 +60,18 @@ export default function ArtworkDescription({ artwork }) {
if (content.length === 0) return null
return (
<section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Description</h2>
<div className="mt-4 max-w-[720px] space-y-4">{rendered}</div>
<div>
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
{content.length > COLLAPSE_AT && (
<button
type="button"
className="mt-4 text-sm font-medium text-accent hover:underline"
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>
)}
</section>
</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

@@ -18,102 +18,117 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
const blurBackdropSrc = mdSource || lgSource || xlSource || null
const width = Number(artwork?.width)
const height = Number(artwork?.height)
const hasKnownAspect = width > 0 && height > 0
const aspectRatio = hasKnownAspect ? `${width} / ${height}` : '16 / 9'
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
return (
<figure className="w-full">
<div className="relative mx-auto w-full max-w-[1280px]">
<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 && (
<>
<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"
/>
<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" />
</>
)}
{/* Outer flex row: left arrow | image | right arrow */}
<div className="flex items-center gap-2">
{/* Prev arrow — outside the picture */}
<div className="flex w-12 shrink-0 justify-center">
{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 hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
>
<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>
{/* Image area */}
<div className="relative min-w-0 flex-1">
{hasRealArtworkImage && (
<div className="absolute inset-0 -z-10" />
)}
<div
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
onClick={onOpenViewer}
role={onOpenViewer ? 'button' : undefined}
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
tabIndex={onOpenViewer ? 0 : undefined}
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
<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"
>
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain"
loading="eager"
decoding="async"
/>
<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>
<img
src={lg}
srcSet={srcSet}
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
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"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.src = FALLBACK_LG
}}
/>
<div className="relative min-w-0 flex-1">
<div
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden ] ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
style={{ aspectRatio }}
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}
>
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain rounded-xl"
loading="eager"
decoding="async"
fetchPriority="high"
/>
{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 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
>
<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>
<img
src={lg}
srcSet={srcSet}
sizes="(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw"
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.src = FALLBACK_LG
}}
/>
{hasRealArtworkImage && (
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
)}
</div>
{/* Next arrow — outside the picture */}
<div className="flex w-12 shrink-0 justify-center">
{hasNext && (
{onOpenViewer && (
<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 hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
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 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 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

@@ -2,31 +2,12 @@ import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
export default function ArtworkMeta({ artwork }) {
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
const publishedAt = artwork?.published_at
? new Date(artwork.published_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
: '—'
const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0
return (
<div className="rounded-xl border border-nova-700 bg-panel p-5">
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
<ArtworkBreadcrumbs artwork={artwork} />
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
<dt>Author</dt>
<dd className="text-white">{author}</dd>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
<dt>Upload date</dt>
<dd className="text-white">{publishedAt}</dd>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2">
<dt>Resolution</dt>
<dd className="text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
</div>
</dl>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
<div className="mt-3">
<ArtworkBreadcrumbs artwork={artwork} />
</div>
</div>
)
}

View File

@@ -29,13 +29,14 @@ export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
}
return (
<div className="mt-3">
<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}
/>
</div>
</section>
)
}

View File

@@ -0,0 +1,365 @@
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,
}
}
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,
}
}
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,
}
}
function dedupeByUrl(items) {
const seen = new Set()
return items.filter((item) => {
if (!item?.url || seen.has(item.url)) return false
seen.add(item.url)
return true
})
}
/* ── Large art card (matches homepage style) ─────────────────── */
function RailCard({ item }) {
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]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
{/* 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 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])
/* 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])
/* 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)
}
}, [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
const amount = el.clientWidth * 0.75
el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' })
}, [])
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">
{/* 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}
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 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`, { 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'
return (
<div className="space-y-14">
<Rail title="Similar Artworks" emoji="✨" items={similarItems} />
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
</div>
)
}

View File

@@ -4,19 +4,52 @@ export default function ArtworkTags({ artwork }) {
const [expanded, setExpanded] = useState(false)
const tags = useMemo(() => {
const categories = (artwork?.categories || []).map((category) => ({
key: `cat-${category.id || category.slug}`,
label: category.name,
href: category.url || `/${category.content_type_slug}/${category.slug}`,
}))
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 [...categories, ...artworkTags]
return [...categoryPills, ...artworkTags]
}, [artwork])
if (tags.length === 0) return null
@@ -24,31 +57,34 @@ export default function ArtworkTags({ artwork }) {
const visible = expanded ? tags : tags.slice(0, 12)
return (
<section className="rounded-xl border border-nova-700 bg-panel p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Tags & Categories</h2>
{tags.length > 12 && (
<button
type="button"
className="text-xs text-accent hover:underline"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Show less' : `Show all (${tags.length})`}
</button>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{visible.map((tag) => (
<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 rounded-full border border-nova-600 bg-nova-900/30 px-3 py-1 text-xs text-white hover:border-accent hover:text-accent"
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>
</section>
</div>
)
}

View File

@@ -0,0 +1,165 @@
import React, { useMemo, useState } from 'react'
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 [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
const user = artwork?.user || {}
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
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
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])
const onToggleFollow = async () => {
const nextState = !following
setFollowing(nextState)
try {
const response = await fetch(`/api/users/${user.id}/follow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify({ state: nextState }),
})
if (!response.ok) throw new Error('Follow failed')
const payload = await response.json()
if (typeof payload?.followers_count === 'number') {
setFollowersCount(payload.followers_count)
}
setFollowing(Boolean(payload?.is_following))
} catch {
setFollowing(!nextState)
}
}
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>
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
</p>
{/* Follow + Profile buttons */}
<div className="mt-4 flex w-full gap-2">
<a
href={profileUrl}
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>
Follow
</a>
<button
type="button"
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
onClick={onToggleFollow}
className={[
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
following
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
].join(' ')}
>
<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>
{following ? 'Following' : 'Follow'}
</button>
</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">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

@@ -1,28 +1,181 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import ReactMarkdown from 'react-markdown'
import EmojiPickerButton from './EmojiPickerButton'
/**
* Comment form with emoji picker and Markdown-lite support.
*
* Props:
* artworkId number Target artwork
* onPosted (comment) => void Called when comment is successfully posted
* isLoggedIn boolean
* loginUrl string Where to redirect non-authenticated users
*/
/* ── 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)
// Insert text at current cursor position
// 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) {
@@ -32,11 +185,9 @@ export default function CommentForm({
const start = el.selectionStart ?? content.length
const end = el.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
const next = content.slice(0, start) + text + content.slice(end)
setContent(next)
// Restore cursor after the inserted text
requestAnimationFrame(() => {
el.selectionStart = start + text.length
el.selectionEnd = start + text.length
@@ -48,6 +199,34 @@ export default function CommentForm({
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()
@@ -66,14 +245,18 @@ export default function CommentForm({
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 apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
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.'])
}
@@ -81,62 +264,174 @@ export default function CommentForm({
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted],
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
)
/* ── Logged-out state ─────────────────────────────────────────────────── */
if (!isLoggedIn) {
return (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
Sign in
</a>{' '}
to leave a comment.
<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 onSubmit={handleSubmit} className="space-y-2">
{/* Textarea */}
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
rows={3}
maxLength={10000}
disabled={submitting}
aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
/>
{/* Toolbar at bottom-right of textarea */}
<div className="absolute bottom-2 right-3 flex items-center gap-2">
<span
className={[
'text-xs tabular-nums transition-colors',
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
].join(' ')}
aria-live="polite"
>
{content.length}/10 000
<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>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
<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>
)}
{/* Markdown hint */}
<p className="text-xs text-white/25 px-1">
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
</p>
<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" role="alert">
<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 text-red-400 px-1">
<li key={i} className="text-xs font-medium text-red-400">
{e}
</li>
))}
@@ -148,9 +443,19 @@ export default function CommentForm({
<button
type="submit"
disabled={submitting || !content.trim()}
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
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 ? 'Posting…' : 'Post comment'}
{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

@@ -131,7 +131,7 @@ function CommentItem({ comment }) {
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
</a>

View File

@@ -1,42 +1,80 @@
import React, { useCallback, useOptimistic, useState } from 'react'
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>
)
}
/**
* Reaction bar for an artwork or comment.
* 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 — if false, clicking shows a prompt
* 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) // slug being toggled
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 // prevent double-click
if (loading) return
setLoading(slug)
setPickerOpen(false)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
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 ? entry.count - 1 : entry.count + 1,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
@@ -46,14 +84,13 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
const { data } = await axios.post(endpoint, { reaction: slug })
setTotals(data.totals)
} catch {
// Rollback
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
@@ -65,46 +102,127 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
[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
if (entries.length === 0) return 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)
}
return (
<div
role="group"
aria-label="Reactions"
className="flex flex-wrap items-center gap-1.5"
ref={containerRef}
className="flex items-center gap-2"
onMouseLeave={onMouseLeave}
>
{entries.map(([slug, info]) => {
const { emoji, label, count, mine } = info
const isProcessing = loading === slug
{/* ── Trigger button ──────────────────────────────────────────── */}
<div className="relative" onMouseEnter={onMouseEnter}>
<button
type="button"
onClick={() => {
if (myReaction) {
// Quick-toggle: remove own reaction
toggle(myReaction)
} else {
// Quick-like with thumbs_up
toggle('thumbs_up')
}
}}
className={[
'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(' ')}
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
>
{myReaction ? (
<span className="text-base leading-none">{myReactionData?.emoji}</span>
) : (
<HeartOutlineIcon className="h-4 w-4" />
)}
<span>{myReaction ? myReactionData?.label : 'React'}</span>
</button>
return (
<button
key={slug}
type="button"
disabled={isProcessing}
onClick={() => toggle(slug)}
aria-label={`${label}${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
aria-pressed={mine}
className={[
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
'border transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-50 disabled:pointer-events-none',
mine
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
]
.filter(Boolean)
.join(' ')}
{/* ── 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}
>
<span aria-hidden="true">{emoji}</span>
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
<span className="sr-only">{label}</span>
</button>
)
})}
<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="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
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

@@ -27,7 +27,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
const cdnBase = 'https://files.skinbase.org';
const avatarSrc = art.avatar_url || `${cdnBase}/avatars/default.webp`;
const avatarSrc = art.avatar_url || `${cdnBase}/default/avatar_default.webp`;
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;

View File

@@ -36,8 +36,8 @@
$imageObject = [
'@context' => 'https://schema.org',
'@type' => 'ImageObject',
'name' => (string) $artwork->title,
'description' => (string) ($artwork->description ?? ''),
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => $meta['canonical'],
'contentUrl' => $meta['og_image'] ?? null,
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
@@ -53,8 +53,8 @@
$creativeWork = [
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => (string) $artwork->title,
'description' => (string) ($artwork->description ?? ''),
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => $meta['canonical'],
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
'datePublished' => optional($artwork->published_at)->toAtomString(),

View File

@@ -58,7 +58,7 @@
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
class="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
onerror="this.src='https://files.skinbase.org/avatars/default.webp'" />
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
@if($creator->username ?? null)