Save workspace changes
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import ArtworkShareButton from './ArtworkShareButton'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
/* ── SVG Icons ─────────────────────────────────────────────────────────────── */
|
||||
function HeartIcon({ filled }) {
|
||||
return filled ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkIcon({ filled }) {
|
||||
return filled ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CloudDownIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadArrowIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ShareIcon removed — now provided by ArtworkShareButton */
|
||||
|
||||
function FlagIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Report Modal ──────────────────────────────────────────────────────────── */
|
||||
const REPORT_REASONS = [
|
||||
'Inappropriate content',
|
||||
'Copyright violation',
|
||||
'Spam or misleading',
|
||||
'Offensive or abusive',
|
||||
]
|
||||
|
||||
function ReportModal({ open, onClose, onSubmit, submitting }) {
|
||||
const [selected, setSelected] = useState('')
|
||||
const [details, setDetails] = useState('')
|
||||
const backdropRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Reset & focus when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected('')
|
||||
setDetails('')
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 80)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const trimmedDetails = details.trim()
|
||||
const canSubmit = selected.length > 0 && trimmedDetails.length >= 10 && !submitting
|
||||
const fullReason = `${selected}: ${trimmedDetails}`
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) onClose() }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/[0.08] bg-nova-900 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<h3 className="text-base font-semibold text-white">Report Artwork</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* Step 1 — pick a reason */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white/60">Reason <span className="text-red-400">*</span></label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REPORT_REASONS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setSelected(r)}
|
||||
className={[
|
||||
'rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all',
|
||||
selected === r
|
||||
? 'border-red-500/50 bg-red-500/15 text-red-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — describe & prove */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white/60">
|
||||
Details & proof <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
placeholder="Please describe the issue and provide any links or evidence that support your report…"
|
||||
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-sm text-white placeholder-white/30 outline-none transition focus:border-white/[0.15] focus:ring-1 focus:ring-white/[0.1]"
|
||||
/>
|
||||
<p className="text-xs text-white/30">
|
||||
{trimmedDetails.length < 10
|
||||
? `At least 10 characters required (${trimmedDetails.length}/10)`
|
||||
: `${details.length}/1000`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.07] hover:text-white/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => onSubmit(fullReason)}
|
||||
className="rounded-full bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-red-600/20 transition hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Submit Report'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [bookmarked, setBookmarked] = useState(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
const [bookmarkCount, setBookmarkCount] = useState(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const isLoggedIn = Boolean(artwork?.viewer?.is_authenticated)
|
||||
useEffect(() => {
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_favorited])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarked(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
}, [artwork?.id, artwork?.viewer?.is_bookmarked])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarkCount(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
|
||||
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
// Track view
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
const key = `sb_viewed_${artwork.id}`
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).then(res => {
|
||||
if (res.ok && typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
|
||||
}).catch(() => {})
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) throw new Error('Request failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (downloading || !artwork?.id) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/download/artwork/${artwork.id}`
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
|
||||
onStatsChange?.({ favorites: nextState ? 1 : -1 })
|
||||
} catch { setFavorited(!nextState) }
|
||||
}
|
||||
|
||||
const onToggleBookmark = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !bookmarked
|
||||
setBookmarked(nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
|
||||
|
||||
try {
|
||||
const payload = await postInteraction(`/api/artworks/${artwork.id}/bookmark`, { state: nextState })
|
||||
setBookmarked(Boolean(payload?.is_bookmarked))
|
||||
setBookmarkCount(Number(payload?.stats?.bookmarks ?? 0))
|
||||
} catch {
|
||||
setBookmarked(!nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
|
||||
}
|
||||
}
|
||||
|
||||
const openReport = () => {
|
||||
if (reported) return
|
||||
setReportOpen(true)
|
||||
}
|
||||
|
||||
const submitReport = async (reason) => {
|
||||
if (reporting) return
|
||||
setReporting(true)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, { reason })
|
||||
setReported(true)
|
||||
setReportOpen(false)
|
||||
} catch { /* noop */ }
|
||||
finally { setReporting(false) }
|
||||
}
|
||||
|
||||
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
||||
const savedCount = formatCount(bookmarkCount)
|
||||
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop centered bar ────────────────────────────────────── */}
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
|
||||
{/* Favourite (heart) stat pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
favorited
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200 shadow-lg shadow-amber-500/10 hover:bg-amber-400/18'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Views stat pill */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
|
||||
<CloudDownIcon />
|
||||
<span className="tabular-nums">{viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Share pill */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="default" isLoggedIn={isLoggedIn} />
|
||||
|
||||
{/* Report pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report artwork"
|
||||
onClick={openReport}
|
||||
disabled={reported}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
reported
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<FlagIcon />
|
||||
{reported ? 'Reported' : 'Report'}
|
||||
</button>
|
||||
|
||||
{/* Download button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download artwork"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-2.5 text-sm font-bold text-deep shadow-lg shadow-accent/25 transition-all duration-200 hover:brightness-110 hover:shadow-xl hover:shadow-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<DownloadArrowIcon />
|
||||
{downloading ? 'Downloading…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile fixed bottom bar ─────────────────────────────────── */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/[0.08] bg-nova-900/95 px-3 py-2.5 backdrop-blur-md lg:hidden">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
favorited
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report"
|
||||
onClick={openReport}
|
||||
disabled={reported}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
reported
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-400/70 cursor-default'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-red-500/40 hover:text-red-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<FlagIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download artwork"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-accent px-5 py-2 text-xs font-bold text-deep transition hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<DownloadArrowIcon />
|
||||
{downloading ? '…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report modal */}
|
||||
<ReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
onSubmit={submitReport}
|
||||
submitting={reporting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false, onStatsChange }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
// Track the view once per browser session (sessionStorage prevents re-firing).
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
const key = `sb_viewed_${artwork.id}`
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).then(res => {
|
||||
// Only mark as seen after a confirmed success — if the POST fails the
|
||||
// next page load will retry rather than silently skipping forever.
|
||||
if (res.ok && typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem(key, '1')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Download through the secure Laravel route so original files are never exposed directly.
|
||||
const handleDownload = async (e) => {
|
||||
e.preventDefault()
|
||||
if (downloading || !artwork?.id) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/download/artwork/${artwork.id}`
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Request failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const onToggleLike = async () => {
|
||||
const nextState = !liked
|
||||
setLiked(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
|
||||
onStatsChange?.({ likes: nextState ? 1 : -1 })
|
||||
} catch {
|
||||
setLiked(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
|
||||
onStatsChange?.({ favorites: nextState ? 1 : -1 })
|
||||
} catch {
|
||||
setFavorited(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: artwork?.title || 'Artwork',
|
||||
url: shareUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const onReport = async () => {
|
||||
if (reporting) return
|
||||
setReporting(true)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, {
|
||||
reason: 'Reported from artwork page',
|
||||
})
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
setReporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Actions</h2>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{/* Download — full-width primary CTA */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep transition hover:brightness-110 disabled:opacity-60 disabled:cursor-wait"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 100 16v-4l-3 3 3 3v-4a8 8 0 01-8-8z" />
|
||||
</svg>
|
||||
Downloading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Secondary actions — icon row */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{/* Like */}
|
||||
<button
|
||||
type="button"
|
||||
title={liked ? 'Unlike' : 'Like'}
|
||||
onClick={onToggleLike}
|
||||
className={`flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition
|
||||
${liked
|
||||
? 'border-rose-500/60 bg-rose-500/10 text-rose-400 hover:bg-rose-500/20'
|
||||
: 'border-nova-600 text-soft hover:bg-nova-800 hover:text-white'}`}
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0" fill={liked ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 016.364 0L12 7.636l1.318-1.318a4.5 4.5 0 116.364 6.364L12 20.364l-7.682-7.682a4.5 4.5 0 010-6.364z" />
|
||||
</svg>
|
||||
<span>{liked ? 'Liked' : 'Like'}</span>
|
||||
</button>
|
||||
|
||||
{/* Favorite */}
|
||||
<button
|
||||
type="button"
|
||||
title={favorited ? 'Remove from favorites' : 'Favorite'}
|
||||
onClick={onToggleFavorite}
|
||||
className={`flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition
|
||||
${favorited
|
||||
? 'border-amber-500/60 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20'
|
||||
: 'border-nova-600 text-soft hover:bg-nova-800 hover:text-white'}`}
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0" fill={favorited ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<span>{favorited ? 'Saved' : 'Save'}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<button
|
||||
type="button"
|
||||
title="Share"
|
||||
onClick={onShare}
|
||||
className="flex flex-col items-center justify-center gap-1 rounded-lg border border-nova-600 px-2 py-3 text-xs font-medium text-soft transition hover:bg-nova-800 hover:text-white"
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
type="button"
|
||||
title="Report"
|
||||
onClick={onReport}
|
||||
disabled={reporting}
|
||||
className="flex flex-col items-center justify-center gap-1 rounded-lg border border-nova-600 px-2 py-3 text-xs font-medium text-soft transition hover:border-red-500/50 hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
||||
</svg>
|
||||
<span>{reporting ? '…' : 'Report'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobilePriority && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 border-t border-nova-700 bg-panel/95 p-3 backdrop-blur lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep transition hover:brightness-110 disabled:opacity-60 disabled:cursor-wait"
|
||||
>
|
||||
{downloading ? 'Downloading…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
const user = artwork?.user || {}
|
||||
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 grid gap-3 ${isOwnArtwork ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
{!isOwnArtwork ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="min-h-11"
|
||||
sizeClassName="px-3 py-2 text-sm"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Modal from '../ui/Modal'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
const MEDALS = [
|
||||
{ key: 'gold', label: 'Gold', emoji: '🥇', weight: 5 },
|
||||
{ key: 'silver', label: 'Silver', emoji: '🥈', weight: 3 },
|
||||
{ key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 },
|
||||
]
|
||||
|
||||
function getMedalMeta(medalKey) {
|
||||
return MEDALS.find((medal) => medal.key === medalKey) ?? null
|
||||
}
|
||||
|
||||
function getMedalWeight(medalKey) {
|
||||
return getMedalMeta(medalKey)?.weight ?? 0
|
||||
}
|
||||
|
||||
function buildConfirmationContent(pendingConfirmation) {
|
||||
if (!pendingConfirmation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextMedal = getMedalMeta(pendingConfirmation.medal)
|
||||
const previousMedal = getMedalMeta(pendingConfirmation.previousMedal)
|
||||
|
||||
if (pendingConfirmation.action === 'remove') {
|
||||
return {
|
||||
title: `Remove ${nextMedal?.label ?? 'medal'} medal?`,
|
||||
summary: `This will remove your ${nextMedal?.label ?? ''} medal from this artwork.`,
|
||||
details: 'Your contribution to the medal score will be removed immediately after confirmation.',
|
||||
confirmLabel: 'Remove medal',
|
||||
confirmVariant: 'danger',
|
||||
modalVariant: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Change medal to ${nextMedal?.label ?? 'selected medal'}?`,
|
||||
summary: `You already awarded ${previousMedal?.label ?? 'a medal'} to this artwork.`,
|
||||
details: `Confirm to switch your medal from ${previousMedal?.label ?? 'the current medal'} to ${nextMedal?.label ?? 'the selected medal'}.`,
|
||||
confirmLabel: `Change to ${nextMedal?.label ?? 'selected medal'}`,
|
||||
confirmVariant: 'accent',
|
||||
modalVariant: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
function describeMedalError(message) {
|
||||
const normalized = String(message || '').trim()
|
||||
const lower = normalized.toLowerCase()
|
||||
|
||||
if (lower.includes('verify your email')) {
|
||||
return {
|
||||
title: 'Email verification required',
|
||||
summary: 'Medals are limited to verified accounts to reduce abuse and low-quality vote spam.',
|
||||
details: 'Open your account email, use the verification link, then reload this page and try again.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('at least') && lower.includes('hours old')) {
|
||||
return {
|
||||
title: 'Account is too new',
|
||||
summary: normalized,
|
||||
details: 'This cooldown is there to stop throwaway accounts from mass-awarding artworks immediately after signup.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('your own artwork')) {
|
||||
return {
|
||||
title: 'Own artwork cannot be medaled',
|
||||
summary: 'Creators cannot add medals to their own work.',
|
||||
details: 'Only other community members can award medals so the score stays community-driven.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('not published yet')) {
|
||||
return {
|
||||
title: 'Artwork is not published yet',
|
||||
summary: 'This artwork has not reached a public, medal-eligible state yet.',
|
||||
details: 'Medals are only available after the artwork is published and visible publicly.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('not eligible for medals')) {
|
||||
return {
|
||||
title: 'Artwork is not eligible for medals',
|
||||
summary: 'This artwork is currently blocked from medal voting.',
|
||||
details: 'That usually means it is private, unapproved, or otherwise not available for public medal activity.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('no longer available')) {
|
||||
return {
|
||||
title: 'Artwork is unavailable',
|
||||
summary: 'This artwork can no longer receive medals.',
|
||||
details: 'The artwork may have been removed or is no longer publicly available.',
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.includes('disabled')) {
|
||||
return {
|
||||
title: 'Medals are temporarily unavailable',
|
||||
summary: normalized,
|
||||
details: 'This is a site-wide setting, not a problem with your account.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Unable to add medal',
|
||||
summary: normalized || 'The medal request could not be completed.',
|
||||
details: 'Check that you are signed in with an eligible account and that the artwork is publicly medal-eligible.',
|
||||
}
|
||||
}
|
||||
|
||||
export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) {
|
||||
const artworkId = artwork?.id
|
||||
const isOwnArtwork = Boolean(artwork?.viewer?.id && artwork?.user?.id && artwork.viewer.id === artwork.user.id)
|
||||
|
||||
const [awards, setAwards] = useState({
|
||||
gold: initialAwards?.gold ?? 0,
|
||||
silver: initialAwards?.silver ?? 0,
|
||||
bronze: initialAwards?.bronze ?? 0,
|
||||
score: initialAwards?.score ?? 0,
|
||||
})
|
||||
const [viewerAward, setViewerAward] = useState(initialAwards?.current_user_medal ?? initialAwards?.viewer_award ?? null)
|
||||
const [loading, setLoading] = useState(null) // which medal is pending
|
||||
const [error, setError] = useState(null)
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState(null)
|
||||
|
||||
const errorDetails = error ? describeMedalError(error) : null
|
||||
const confirmationContent = buildConfirmationContent(pendingConfirmation)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const apiFetch = useCallback(async (method, body = null) => {
|
||||
const res = await fetch(`/api/artworks/${artworkId}/medal`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}, [artworkId, csrfToken])
|
||||
|
||||
const applyServerResponse = useCallback((data) => {
|
||||
const payload = data?.medals || data?.awards || null
|
||||
|
||||
if (payload) {
|
||||
setAwards({
|
||||
gold: payload.gold ?? 0,
|
||||
silver: payload.silver ?? 0,
|
||||
bronze: payload.bronze ?? 0,
|
||||
score: payload.score ?? 0,
|
||||
})
|
||||
}
|
||||
setViewerAward(data?.current_user_medal ?? data?.viewer_award ?? null)
|
||||
}, [])
|
||||
|
||||
const handleMedalAction = useCallback(async ({ action, medal, previousMedal = null }) => {
|
||||
if (!isAuthenticated || isOwnArtwork) return
|
||||
if (loading) return
|
||||
|
||||
setError(null)
|
||||
|
||||
// Optimistic update
|
||||
const prevAwards = { ...awards }
|
||||
const prevViewer = viewerAward
|
||||
|
||||
if (action === 'remove') {
|
||||
// Undo: remove award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: Math.max(0, a[medal] - 1),
|
||||
score: Math.max(0, a.score - getMedalWeight(medal)),
|
||||
}))
|
||||
setViewerAward(null)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('DELETE')
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'change' && previousMedal) {
|
||||
// Change: swap medals
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[previousMedal]: Math.max(0, a[previousMedal] - 1),
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score - getMedalWeight(previousMedal) + getMedalWeight(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('POST', { medal_type: medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// New award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score + getMedalWeight(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('POST', { medal_type: medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}, [isAuthenticated, isOwnArtwork, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
||||
|
||||
const handleMedalClick = useCallback((medal) => {
|
||||
if (!isAuthenticated || isOwnArtwork) return
|
||||
if (loading) return
|
||||
|
||||
setError(null)
|
||||
|
||||
if (viewerAward === medal) {
|
||||
setPendingConfirmation({ action: 'remove', medal, previousMedal: medal })
|
||||
return
|
||||
}
|
||||
|
||||
if (viewerAward) {
|
||||
setPendingConfirmation({ action: 'change', medal, previousMedal: viewerAward })
|
||||
return
|
||||
}
|
||||
|
||||
void handleMedalAction({ action: 'add', medal })
|
||||
}, [isAuthenticated, isOwnArtwork, loading, viewerAward, handleMedalAction])
|
||||
|
||||
const closeConfirmation = useCallback(() => {
|
||||
if (loading) return
|
||||
setPendingConfirmation(null)
|
||||
}, [loading])
|
||||
|
||||
const confirmPendingAction = useCallback(async () => {
|
||||
if (!pendingConfirmation || loading) return
|
||||
|
||||
const action = pendingConfirmation
|
||||
setPendingConfirmation(null)
|
||||
await handleMedalAction(action)
|
||||
}, [pendingConfirmation, loading, handleMedalAction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/30">Medals</h2>
|
||||
|
||||
{errorDetails && (
|
||||
<div className="mt-3 rounded-xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-left">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-red-300/90">{errorDetails.title}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-red-200">{errorDetails.summary}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-red-100/75">{errorDetails.details}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
{MEDALS.map(({ key, label, emoji }) => {
|
||||
const isActive = viewerAward === key
|
||||
const isPending = loading === key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={!isAuthenticated || isOwnArtwork || loading !== null}
|
||||
onClick={() => handleMedalClick(key)}
|
||||
title={!isAuthenticated ? 'Sign in to medal' : isOwnArtwork ? 'You cannot medal your own artwork' : isActive ? `Remove ${label} medal` : viewerAward ? `Change medal to ${label}` : `Give ${label} medal`}
|
||||
className={[
|
||||
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'border-accent/40 bg-accent/10 font-semibold text-accent shadow-lg shadow-accent/10'
|
||||
: 'border-white/[0.08] bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:border-white/[0.12]',
|
||||
(!isAuthenticated || isOwnArtwork || loading !== null) && 'cursor-not-allowed opacity-60',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="text-xl leading-none" aria-hidden="true">
|
||||
{isPending ? '…' : emoji}
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-none">{label}</span>
|
||||
<span className="text-xs text-soft tabular-nums">
|
||||
{awards[key]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{awards.score > 0 && (
|
||||
<p className="mt-3 text-right text-xs text-soft">
|
||||
Score: <span className="font-semibold text-white">{awards.score}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAuthenticated && isOwnArtwork && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
You cannot medal your own artwork.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(confirmationContent)}
|
||||
onClose={closeConfirmation}
|
||||
title={confirmationContent?.title}
|
||||
size="sm"
|
||||
variant={confirmationContent?.modalVariant}
|
||||
footer={confirmationContent ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={closeConfirmation} disabled={loading !== null}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={confirmationContent.confirmVariant} size="sm" onClick={confirmPendingAction} loading={loading !== null}>
|
||||
{confirmationContent.confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
{confirmationContent ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-6 text-slate-200">{confirmationContent.summary}</p>
|
||||
<p className="text-xs leading-5 text-slate-400">{confirmationContent.details}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ArtworkAwards from './ArtworkAwards'
|
||||
|
||||
describe('ArtworkAwards medal confirmations', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('asks for confirmation before removing the active medal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
medals: { gold: 0, silver: 0, bronze: 0, score: 0 },
|
||||
current_user_medal: null,
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ArtworkAwards
|
||||
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
|
||||
isAuthenticated
|
||||
initialAwards={{ gold: 1, silver: 0, bronze: 0, score: 5, current_user_medal: 'gold' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /gold/i }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /remove gold medal\?/i })).not.toBeNull()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove medal/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/artworks/69461/medal',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('asks for confirmation before changing an existing medal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
medals: { gold: 0, silver: 1, bronze: 0, score: 3 },
|
||||
current_user_medal: 'silver',
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ArtworkAwards
|
||||
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
|
||||
isAuthenticated
|
||||
initialAwards={{ gold: 1, silver: 0, bronze: 0, score: 5, current_user_medal: 'gold' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /silver/i }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /change medal to silver\?/i })).not.toBeNull()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /change to silver/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/artworks/69461/medal',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ medal_type: 'silver' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('still awards a new medal immediately when the viewer has not voted yet', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
medals: { gold: 0, silver: 1, bronze: 0, score: 3 },
|
||||
current_user_medal: 'silver',
|
||||
}),
|
||||
})
|
||||
|
||||
render(
|
||||
<ArtworkAwards
|
||||
artwork={{ id: 69461, viewer: { id: 2 }, user: { id: 7 } }}
|
||||
isAuthenticated
|
||||
initialAwards={{ gold: 0, silver: 0, bronze: 0, score: 0, current_user_medal: null }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /silver/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
|
||||
function Separator() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0 text-white/15"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Crumb({ href, children, current = false }) {
|
||||
const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]'
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
className={`${base} text-white/30`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkBreadcrumbs({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
// Use the first category for the content-type + category crumbs
|
||||
const firstCategory = artwork.categories?.[0] ?? null
|
||||
const contentTypeSlug = firstCategory?.content_type_slug || null
|
||||
const contentTypeName = contentTypeSlug
|
||||
? contentTypeSlug.charAt(0).toUpperCase() + contentTypeSlug.slice(1)
|
||||
: null
|
||||
|
||||
const categorySlug = firstCategory?.slug || null
|
||||
const categoryName = firstCategory?.name || null
|
||||
const categoryUrl = contentTypeSlug && categorySlug
|
||||
? `/${contentTypeSlug}/${categorySlug}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mt-1.5 mb-0">
|
||||
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||
{/* Home */}
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href="/">Home</Crumb>
|
||||
</li>
|
||||
|
||||
{/* Content type e.g. Photography */}
|
||||
{contentTypeSlug && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={`/${contentTypeSlug}`}>{contentTypeName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Category e.g. Landscapes */}
|
||||
{categoryUrl && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={categoryUrl}>{categoryName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current artwork title — omitted: shown as h1 above */}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
const numberFormatter = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
const numeric = Number(value ?? 0)
|
||||
if (!Number.isFinite(numeric)) return '0'
|
||||
return numberFormatter.format(numeric)
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
|
||||
|
||||
const diffHours = Math.round(diffSeconds / 3600)
|
||||
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
|
||||
|
||||
const diffDays = Math.round(diffSeconds / 86400)
|
||||
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
|
||||
|
||||
const diffWeeks = Math.round(diffSeconds / 604800)
|
||||
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
|
||||
|
||||
const diffMonths = Math.round(diffSeconds / 2629800)
|
||||
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
|
||||
|
||||
const diffYears = Math.round(diffSeconds / 31557600)
|
||||
return rtf.format(diffYears, 'year')
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function decodeHtml(value) {
|
||||
const text = String(value ?? '')
|
||||
if (!text.includes('&')) return text
|
||||
|
||||
let decoded = text
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
decoded = decoded
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/&(apos|#39);/gi, "'")
|
||||
.replace(/&(acute|#180|#x00B4);/gi, "'")
|
||||
.replace(/&(quot|#34);/gi, '"')
|
||||
.replace(/&(nbsp|#160);/gi, ' ')
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
break
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.innerHTML = decoded
|
||||
const nextValue = textarea.value
|
||||
if (nextValue === decoded) break
|
||||
decoded = nextValue
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
function normalizeContentTypeLabel(value) {
|
||||
const raw = decodeHtml(value).trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const normalized = raw.toLowerCase()
|
||||
const knownLabels = {
|
||||
artworks: 'Artwork',
|
||||
artwork: 'Artwork',
|
||||
wallpapers: 'Wallpaper',
|
||||
wallpaper: 'Wallpaper',
|
||||
skins: 'Skin',
|
||||
skin: 'Skin',
|
||||
photography: 'Photography',
|
||||
photo: 'Photography',
|
||||
photos: 'Photography',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
if (knownLabels[normalized]) {
|
||||
return knownLabels[normalized]
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function swapImageToFallbackOnce(event, fallbackSrc, { clearResponsive = false } = {}) {
|
||||
const image = event.currentTarget
|
||||
|
||||
if (!image || image.dataset.fallbackApplied === '1') {
|
||||
return
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = '1'
|
||||
image.onerror = null
|
||||
|
||||
if (clearResponsive) {
|
||||
image.removeAttribute('srcset')
|
||||
image.removeAttribute('sizes')
|
||||
}
|
||||
|
||||
image.src = fallbackSrc
|
||||
}
|
||||
|
||||
function sendDiscoveryEvent(endpoint, payload) {
|
||||
if (!endpoint) return
|
||||
|
||||
void fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function sendFeedbackSignal(endpoint, payload) {
|
||||
if (!endpoint) {
|
||||
throw new Error('missing_feedback_endpoint')
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('feedback_request_failed')
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
async function requestJson(endpoint, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.message || 'Request failed.')
|
||||
error.payload = payload
|
||||
throw error
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
|
||||
const endpoint = item?.discovery_endpoint
|
||||
const artworkId = Number(item?.id ?? 0)
|
||||
if (!endpoint || artworkId <= 0) return
|
||||
|
||||
sendDiscoveryEvent(endpoint, {
|
||||
event_type: eventType,
|
||||
artwork_id: artworkId,
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
source: item?.recommendation_source || null,
|
||||
reason: item?.recommendation_reason || null,
|
||||
score: item?.recommendation_score ?? null,
|
||||
...extraMeta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function HeartIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c-4.97-3.12-8.25-6.16-8.25-10.03A4.72 4.72 0 0 1 8.5 5.5c1.5 0 2.93.7 3.84 1.92A4.8 4.8 0 0 1 16.18 5.5a4.72 4.72 0 0 1 4.82 4.72c0 3.87-3.28 6.91-8.25 10.03Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3.75v10.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 10.5 3.75 3.75 3.75-3.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18.75h15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 14.75A2.75 2.75 0 1 0 12 9.25a2.75 2.75 0 0 0 0 5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function HideIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TagIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionLink({ href, label, children, onClick }) {
|
||||
return (
|
||||
<a
|
||||
href={href || '#'}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ label, children, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BadgePill({ className = '', iconClass = '', children }) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] backdrop-blur-sm ring-1 shadow-[0_8px_24px_rgba(2,6,23,0.28)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconClass ? <i className={iconClass} aria-hidden="true" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionPickerModal({
|
||||
open,
|
||||
artworkTitle,
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
notice,
|
||||
createUrl,
|
||||
attachingCollectionId,
|
||||
onAttach,
|
||||
onClose,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close add to collection dialog"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
|
||||
<div className="border-b border-white/10 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-6">
|
||||
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
|
||||
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
|
||||
) : collections.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks • {collection.visibility}</div>
|
||||
</div>
|
||||
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAttach(collection)}
|
||||
disabled={collection.already_attached || attachingCollectionId === collection.id}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
|
||||
>
|
||||
<CollectionIcon className="h-3.5 w-3.5" />
|
||||
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
|
||||
</button>
|
||||
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
|
||||
<CollectionIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
|
||||
<a href={createUrl} className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-plus" />
|
||||
Create Collection
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkCard({
|
||||
artwork,
|
||||
variant = 'default',
|
||||
compact = false,
|
||||
showStats = true,
|
||||
showAuthor = true,
|
||||
className = '',
|
||||
articleClassName = '',
|
||||
frameClassName = '',
|
||||
mediaClassName = '',
|
||||
mediaStyle,
|
||||
articleStyle,
|
||||
imageClassName = '',
|
||||
imageSizes,
|
||||
imageSrcSet,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
loading = 'lazy',
|
||||
decoding = 'async',
|
||||
fetchPriority,
|
||||
onLike,
|
||||
onDismissed,
|
||||
showActions = true,
|
||||
metricBadge = null,
|
||||
}) {
|
||||
let inertiaProps = {}
|
||||
|
||||
try {
|
||||
inertiaProps = usePage()?.props || {}
|
||||
} catch {
|
||||
inertiaProps = {}
|
||||
}
|
||||
|
||||
const item = artwork || {}
|
||||
const rawAuthor = item.author || item.creator
|
||||
const publisher = item.publisher && typeof item.publisher === 'object' ? item.publisher : null
|
||||
const isGroupPublisher = (publisher?.type === 'group') || item.published_as_type === 'group'
|
||||
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||
const author = decodeHtml(
|
||||
(isGroupPublisher ? publisher?.name : null)
|
||||
|| (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|
||||
|| item.author_name
|
||||
|| item.uname
|
||||
|| 'Skinbase Artist'
|
||||
)
|
||||
const username = isGroupPublisher ? null : (rawAuthor?.username || item.author_username || item.username || null)
|
||||
const authorLevel = isGroupPublisher ? 0 : Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
|
||||
const authorRank = isGroupPublisher ? '' : (rawAuthor?.rank || item.author_rank || item.creator?.rank || '')
|
||||
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
|
||||
const responsiveImageSrcSet = imageSrcSet || item.thumb_srcset || item.thumbnail_srcset || undefined
|
||||
const responsiveImageSizes = imageSizes || (variant === 'embed'
|
||||
? '80px'
|
||||
: compact
|
||||
? '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 280px'
|
||||
: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1536px) 25vw, 320px')
|
||||
const avatar = (isGroupPublisher ? publisher?.avatar_url : null)
|
||||
|| rawAuthor?.avatar_url
|
||||
|| rawAuthor?.avatar
|
||||
|| item.avatar
|
||||
|| item.author_avatar
|
||||
|| item.avatar_url
|
||||
|| AVATAR_FALLBACK
|
||||
const likes = item.likes ?? item.favourites ?? 0
|
||||
const views = item.views ?? item.views_count ?? item.view_count ?? 0
|
||||
const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0
|
||||
const contentType = normalizeContentTypeLabel(
|
||||
item.content_type
|
||||
|| item.content_type_name
|
||||
|| item.contentType
|
||||
|| item.contentTypeName
|
||||
|| item.content_type_slug
|
||||
|| ''
|
||||
)
|
||||
const category = decodeHtml(item.category || item.category_name || '')
|
||||
const width = Number(item.width ?? 0)
|
||||
const height = Number(item.height ?? 0)
|
||||
const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : ''))
|
||||
const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#')
|
||||
const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href)
|
||||
const cardLabel = `${title} by ${author}`
|
||||
const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]'
|
||||
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
|
||||
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
|
||||
const authorHref = publisher?.profile_url || rawAuthor?.profile_url || item.profile_url || item.author_url || (username ? `/@${username}` : null)
|
||||
const resolvedMetricBadge = metricBadge || item.metric_badge || null
|
||||
const relativePublishedAt = useMemo(
|
||||
() => formatRelativeTime(item.published_at || item.publishedAt || null),
|
||||
[item.published_at, item.publishedAt]
|
||||
)
|
||||
const maturity = item.maturity && typeof item.maturity === 'object' ? item.maturity : {}
|
||||
const shouldBlurMature = Boolean(maturity.should_blur)
|
||||
const isMatureArtwork = Boolean(maturity.is_mature_effective)
|
||||
const initialLiked = Boolean(item.viewer?.is_liked)
|
||||
const [liked, setLiked] = useState(initialLiked)
|
||||
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
|
||||
const [likeBusy, setLikeBusy] = useState(false)
|
||||
const [downloadBusy, setDownloadBusy] = useState(false)
|
||||
const [hideBusy, setHideBusy] = useState(false)
|
||||
const [dislikeBusy, setDislikeBusy] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
|
||||
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
|
||||
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
|
||||
const [collectionOptions, setCollectionOptions] = useState([])
|
||||
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
|
||||
const [collectionPickerError, setCollectionPickerError] = useState('')
|
||||
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
|
||||
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
|
||||
const openTrackedRef = useRef(false)
|
||||
const primaryTag = useMemo(() => {
|
||||
if (item?.primary_tag && typeof item.primary_tag === 'object') {
|
||||
return item.primary_tag
|
||||
}
|
||||
|
||||
if (Array.isArray(item?.tags) && item.tags.length > 0) {
|
||||
return item.tags[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}, [item.primary_tag, item.tags])
|
||||
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
|
||||
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
|
||||
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
|
||||
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
|
||||
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
|
||||
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
|
||||
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
|
||||
const collectionOptionsEndpoint = canAddToCollection
|
||||
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(Boolean(item.viewer?.is_liked))
|
||||
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
|
||||
setDismissed(false)
|
||||
setCollectionPickerOpen(false)
|
||||
setCollectionOptionsLoading(false)
|
||||
setCollectionOptionsLoaded(false)
|
||||
setCollectionOptions([])
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
setAttachingCollectionId(null)
|
||||
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
|
||||
|
||||
const articleData = useMemo(() => ({
|
||||
'data-art-id': item.id ?? undefined,
|
||||
'data-art-url': href !== '#' ? href : undefined,
|
||||
'data-art-title': title,
|
||||
'data-art-img': image,
|
||||
}), [href, image, item.id, title])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (openTrackedRef.current) return
|
||||
openTrackedRef.current = true
|
||||
|
||||
trackRecommendationFeedback(item, 'click', {
|
||||
interaction_origin: 'artwork-card-open',
|
||||
target_url: href,
|
||||
})
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!item.id || likeBusy) {
|
||||
onLike?.(item)
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !liked
|
||||
setLikeBusy(true)
|
||||
setLiked(nextState)
|
||||
setLikeCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/artworks/${item.id}/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('like_request_failed')
|
||||
}
|
||||
|
||||
if (nextState) {
|
||||
trackRecommendationFeedback(item, 'favorite', {
|
||||
interaction_origin: 'artwork-card-like',
|
||||
})
|
||||
}
|
||||
|
||||
onLike?.(item)
|
||||
} catch {
|
||||
setLiked(!nextState)
|
||||
setLikeCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
|
||||
} finally {
|
||||
setLikeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!item.id || downloadBusy) return
|
||||
|
||||
setDownloadBusy(true)
|
||||
try {
|
||||
trackRecommendationFeedback(item, 'download', {
|
||||
interaction_origin: 'artwork-card-download',
|
||||
target_url: downloadHref,
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadHref
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch {
|
||||
window.open(downloadHref, '_blank', 'noopener,noreferrer')
|
||||
} finally {
|
||||
setDownloadBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const dismissArtwork = (kind) => {
|
||||
setDismissed(true)
|
||||
onDismissed?.(item, kind)
|
||||
}
|
||||
|
||||
const handleHideArtwork = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canHideRecommendation || hideBusy) return
|
||||
|
||||
setHideBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(hideArtworkEndpoint, {
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
primary_tag_slug: primaryTag?.slug || null,
|
||||
interaction_origin: 'artwork-card-hide',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('hide-artwork')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setHideBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDislikePrimaryTag = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDislikePrimaryTag || dislikeBusy) return
|
||||
|
||||
setDislikeBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(dislikeTagEndpoint, {
|
||||
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
|
||||
tag_slug: primaryTag?.slug || undefined,
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
artwork_id: Number(item.id),
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-card-dislike-tag',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('dislike-tag')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setDislikeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCollectionPicker = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
|
||||
|
||||
setCollectionPickerOpen(true)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
if (collectionOptionsLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
setCollectionOptionsLoading(true)
|
||||
try {
|
||||
const payload = await requestJson(collectionOptionsEndpoint)
|
||||
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
|
||||
setCollectionOptionsLoaded(true)
|
||||
} catch (error) {
|
||||
setCollectionPickerError(error.message || 'Unable to load collections.')
|
||||
} finally {
|
||||
setCollectionOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttachToCollection = async (collection) => {
|
||||
if (!collection?.attach_url || attachingCollectionId === collection.id) return
|
||||
|
||||
setAttachingCollectionId(collection.id)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(collection.attach_url, {
|
||||
method: 'POST',
|
||||
body: { artwork_ids: [Number(item.id)] },
|
||||
})
|
||||
|
||||
setCollectionOptions((current) => current.map((entry) => (
|
||||
entry.id === collection.id
|
||||
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
|
||||
: entry
|
||||
)))
|
||||
setCollectionPickerNotice(`Added to ${collection.title}.`)
|
||||
} catch (error) {
|
||||
const firstError = error?.payload?.errors
|
||||
? Object.values(error.payload.errors).flat().find(Boolean)
|
||||
: null
|
||||
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
|
||||
} finally {
|
||||
setAttachingCollectionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (maturity.should_hide) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'embed') {
|
||||
return (
|
||||
<article
|
||||
className={cx('group overflow-hidden rounded-xl border border-white/[0.08] bg-black/30 transition-colors hover:border-sky-500/30', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
|
||||
<img
|
||||
src={image}
|
||||
srcSet={responsiveImageSrcSet}
|
||||
sizes={responsiveImageSizes}
|
||||
alt={title}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
className={cx('h-full w-full object-cover transition-transform duration-300 group-hover:scale-105', shouldBlurMature ? 'scale-[1.02] blur-xl' : '')}
|
||||
onError={(event) => {
|
||||
swapImageToFallbackOnce(event, IMAGE_FALLBACK)
|
||||
}}
|
||||
/>
|
||||
{isMatureArtwork ? <div className="absolute inset-x-2 bottom-2 rounded-lg border border-amber-300/20 bg-black/65 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature content</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{title}</p>
|
||||
{showAuthor && (
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||
{authorHref ? (
|
||||
<span>
|
||||
by {author} {username ? <span className="text-slate-500">@{username}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>by {author}</span>
|
||||
)}
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact /> : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
|
||||
{contentType || 'Artwork'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<span className="sr-only">{cardLabel}</span>
|
||||
</a>
|
||||
|
||||
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
|
||||
|
||||
<img
|
||||
src={image}
|
||||
srcSet={responsiveImageSrcSet}
|
||||
sizes={responsiveImageSizes}
|
||||
alt={title}
|
||||
width={imageWidth || undefined}
|
||||
height={imageHeight || undefined}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchPriority={fetchPriority || undefined}
|
||||
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', shouldBlurMature ? 'scale-[1.02] blur-xl' : '', imageClassName)}
|
||||
onError={(event) => {
|
||||
swapImageToFallbackOnce(event, IMAGE_FALLBACK, { clearResponsive: true })
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
|
||||
|
||||
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
|
||||
<div>
|
||||
{resolvedMetricBadge?.label ? (
|
||||
<BadgePill className={resolvedMetricBadge.className || 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30'} iconClass={resolvedMetricBadge.iconClass}>
|
||||
{resolvedMetricBadge.label}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
{isMatureArtwork ? (
|
||||
<BadgePill className="mt-2 bg-amber-500/16 text-amber-100 ring-amber-300/30" iconClass="fa-solid fa-triangle-exclamation text-[10px]">
|
||||
Mature content
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{relativePublishedAt ? (
|
||||
<BadgePill className="bg-black/45 text-white/75 ring-white/12" iconClass="fa-regular fa-clock text-[10px]">
|
||||
{relativePublishedAt}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showActions && (
|
||||
<div className={cx(
|
||||
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
relativePublishedAt ? 'top-12' : 'top-3'
|
||||
)}>
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
|
||||
</ActionButton>
|
||||
|
||||
<ActionLink href={downloadHref} label={downloadBusy ? 'Downloading artwork' : 'Download artwork'} onClick={handleDownload}>
|
||||
<DownloadIcon className={cx('h-4 w-4', downloadBusy ? 'animate-pulse text-emerald-300' : '')} />
|
||||
</ActionLink>
|
||||
|
||||
<ActionLink href={href} label="View artwork">
|
||||
<ViewIcon className="h-4 w-4" />
|
||||
</ActionLink>
|
||||
|
||||
{canAddToCollection ? (
|
||||
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
|
||||
<CollectionIcon className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
|
||||
{canHideRecommendation ? (
|
||||
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
|
||||
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
||||
{shouldBlurMature ? <div className="mb-2 inline-flex rounded-full border border-amber-300/20 bg-black/55 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your content settings</div> : null}
|
||||
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{showAuthor ? (
|
||||
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-white/80">
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`Avatar of ${author}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
onError={(event) => {
|
||||
swapImageToFallbackOnce(event, AVATAR_FALLBACK)
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username ? <span className="text-[11px] text-white/60"> @{username}</span> : null}
|
||||
</span>
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
||||
</span>
|
||||
{showStats && metadataLine && (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-white/70">
|
||||
{metadataLine}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : showStats && metadataLine ? (
|
||||
<div className="mt-1 text-[11px] text-white/70">
|
||||
{metadataLine}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canDislikePrimaryTag ? (
|
||||
<div className="pointer-events-auto mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDislikePrimaryTag}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
|
||||
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<CollectionPickerModal
|
||||
open={collectionPickerOpen}
|
||||
artworkTitle={title}
|
||||
collections={collectionOptions}
|
||||
loading={collectionOptionsLoading}
|
||||
error={collectionPickerError}
|
||||
notice={collectionPickerNotice}
|
||||
createUrl={collectionCreateUrl}
|
||||
attachingCollectionId={attachingCollectionId}
|
||||
onAttach={handleAttachToCollection}
|
||||
onClose={() => setCollectionPickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import CommentForm from '../comments/CommentForm'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 365) return `${days}d ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/* ── Icons ─────────────────────────────────────────────────────────────────── */
|
||||
function ReplyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Avatar ─────────────────────────────────────────────────────────────────── */
|
||||
function Avatar({ user, size = 36 }) {
|
||||
if (user?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.name || user.username || ''}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Reply item (nested under a parent) ────────────────────────────────────────
|
||||
|
||||
function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, depth = 1 }) {
|
||||
const user = reply.user
|
||||
const html = reply.rendered_content ?? null
|
||||
const plain = reply.content ?? reply.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || user?.name || 'Member'
|
||||
const replies = reply.replies || []
|
||||
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
if (reply.reactions || !reply.id) return
|
||||
axios
|
||||
.get(`/api/comments/${reply.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [reply.id, reply.reactions])
|
||||
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
// Reply posts under THIS reply's id as parent
|
||||
onReplyPosted?.(reply.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [reply.id, onReplyPosted])
|
||||
|
||||
// Show first 2 nested replies, expand to show all
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
|
||||
// Shrink avatar at deeper levels
|
||||
const avatarSize = depth >= 3 ? 22 : 28
|
||||
|
||||
return (
|
||||
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
|
||||
<div className="flex gap-2.5">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
|
||||
<Avatar user={user} size={avatarSize} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
|
||||
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||
>
|
||||
{reply.time_ago || timeAgo(reply.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{html ? (
|
||||
<div
|
||||
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
{/* Actions — Reply + React inline */}
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm(v => !v)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={reply.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline reply form */}
|
||||
{showReplyForm && (
|
||||
<div className="mt-2">
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
parentId={reply.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
onPosted={handleReplyPosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nested replies (tree) */}
|
||||
{replies.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
|
||||
{visibleReplies.map((child) => (
|
||||
<ReplyItem
|
||||
key={child.id}
|
||||
reply={child}
|
||||
parentId={reply.id}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single comment (top-level) ────────────────────────────────────────────────
|
||||
|
||||
function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
const user = comment.user
|
||||
const html = comment.rendered_content ?? null
|
||||
const plain = comment.content ?? comment.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || user?.name || 'Member'
|
||||
const replies = comment.replies || []
|
||||
|
||||
const flood = isFlood(plain)
|
||||
const [expanded, setExpanded] = useState(!flood)
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
|
||||
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
if (comment.reactions || !comment.id) return
|
||||
axios
|
||||
.get(`/api/comments/${comment.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [comment.id, comment.reactions])
|
||||
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
onReplyPosted?.(comment.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [comment.id, onReplyPosted])
|
||||
|
||||
// Show first 2 replies by default, expand to show all
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`comment-${comment.id}`}
|
||||
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
|
||||
>
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex gap-3.5">
|
||||
{/* Avatar */}
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||
<Avatar user={user} size={38} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className={!expanded ? 'overflow-hidden relative' : undefined}
|
||||
style={!expanded ? { maxHeight: '5em' } : undefined}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
{flood && !expanded && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flood && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '↑ Collapse' : '↓ Show full comment'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm(v => !v)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Replies thread ───────────────────────────────────────────────── */}
|
||||
{(replies.length > 0 || showReplyForm) && (
|
||||
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
|
||||
{replies.length > 0 && (
|
||||
<>
|
||||
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
|
||||
{visibleReplies.map((reply) => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
parentId={comment.id}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Inline reply form */}
|
||||
{showReplyForm && (
|
||||
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
parentId={comment.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
onPosted={handleReplyPosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
|
||||
style={{ animationDelay: `${i * 120}ms` }}
|
||||
>
|
||||
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
|
||||
<div className="flex-1 space-y-3 pt-1">
|
||||
<div className="flex gap-2.5">
|
||||
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
|
||||
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ArtworkComments({
|
||||
artworkId,
|
||||
comments: initialComments = [],
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
}) {
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [lastPage, setLastPage] = useState(1)
|
||||
const [total, setTotal] = useState(initialComments.length)
|
||||
const initialized = useRef(false)
|
||||
|
||||
const loadComments = useCallback(
|
||||
async (p = 1) => {
|
||||
if (!artworkId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
|
||||
if (p === 1) {
|
||||
setComments(data.data ?? [])
|
||||
} else {
|
||||
setComments((prev) => [...prev, ...(data.data ?? [])])
|
||||
}
|
||||
setPage(data.meta?.current_page ?? p)
|
||||
setLastPage(data.meta?.last_page ?? 1)
|
||||
setTotal(data.meta?.total ?? 0)
|
||||
} catch {
|
||||
// keep existing
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[artworkId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
if (artworkId && initialComments.length === 0) {
|
||||
loadComments(1)
|
||||
} else {
|
||||
setTotal(initialComments.length)
|
||||
}
|
||||
}, [artworkId, initialComments.length, loadComments])
|
||||
|
||||
// New top-level comment posted
|
||||
const handlePosted = useCallback((newComment) => {
|
||||
// Ensure it has a replies array
|
||||
const comment = { ...newComment, replies: newComment.replies || [] }
|
||||
setComments((prev) => [comment, ...prev])
|
||||
setTotal((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
// Reply posted under a parent comment (works at any nesting depth)
|
||||
const handleReplyPosted = useCallback((parentId, newReply) => {
|
||||
// Recursively find the parent node and append the reply
|
||||
const insertReply = (nodes) =>
|
||||
nodes.map((c) => {
|
||||
if (c.id === parentId) {
|
||||
return { ...c, replies: [...(c.replies || []), { ...newReply, replies: [] }] }
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: insertReply(c.replies) }
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
setComments((prev) => insertReply(prev))
|
||||
setTotal((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments" className="space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||
Comments
|
||||
</h2>
|
||||
{total > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment list */}
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
|
||||
<ChatBubbleIcon />
|
||||
<p className="text-sm font-medium text-white/40">No comments yet</p>
|
||||
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-3 sm:space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={handleReplyPosted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{page < lastPage && (
|
||||
<div className="flex justify-center pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
'Load more comments'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Comment form — after all comments */}
|
||||
{artworkId && (
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
onPosted={handlePosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
loginUrl={loginUrl}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const COLLAPSE_AT = 560
|
||||
|
||||
export default function ArtworkDescription({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
const contentHtml = (artwork?.description_html || '').trim()
|
||||
const collapsed = content.length > COLLAPSE_AT && !expanded
|
||||
|
||||
if (content.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={[
|
||||
'max-w-[720px] overflow-hidden transition-[max-height] duration-300',
|
||||
collapsed ? 'max-h-[11.5rem]' : 'max-h-[100rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content.length > COLLAPSE_AT && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-sm font-medium text-accent transition-colors hover:text-accent/80"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
function EvolutionArtworkCard({ card }) {
|
||||
if (!card) return null
|
||||
|
||||
const shouldBlur = Boolean(card?.maturity?.should_blur)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={card.url}
|
||||
className="group block overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.03))] transition hover:border-white/20 hover:bg-white/[0.07]"
|
||||
>
|
||||
<div className="relative aspect-[1.08/1] overflow-hidden bg-slate-950">
|
||||
{card.thumbnail ? (
|
||||
<img
|
||||
src={card.thumbnail}
|
||||
alt={card.title}
|
||||
className={`h-full w-full object-cover transition duration-500 group-hover:scale-[1.03] ${shouldBlur ? 'scale-[1.03] blur-xl' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-sky-300" />
|
||||
{card.role_label}
|
||||
</div>
|
||||
|
||||
{shouldBlur ? (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(15,23,42,0),rgba(15,23,42,0.9))] px-4 py-4 text-sm text-white/85">
|
||||
Mature artwork preview is softened for your current viewer settings.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-4 py-4 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{card.content_type ? <span>{card.content_type}</span> : null}
|
||||
{card.category ? <span className="text-slate-500">{card.category}</span> : null}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{card.title}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-300">
|
||||
<span>{card.publisher}</span>
|
||||
{card.year ? <span className="text-slate-500">{card.year}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonModal({ item, open, onClose }) {
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={item.compare?.title || 'Compare versions'} size="full">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{item.heading}</div>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{item.relation_label}</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</div>
|
||||
{item.years_apart_label ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-slate-200">
|
||||
<i className="fa-regular fa-clock" aria-hidden="true" />
|
||||
{item.years_apart_label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{[item.before, item.after].map((card) => (
|
||||
<div key={`${item.id}-${card.id}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{card.role_label}</div>
|
||||
<div className="mt-1 text-base font-semibold text-white">{card.title}</div>
|
||||
</div>
|
||||
<a href={card.url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||||
Open
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-slate-500" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-slate-950">
|
||||
{card.image_lg ? (
|
||||
<img
|
||||
src={card.image_lg}
|
||||
alt={card.title}
|
||||
className={`h-full w-full object-cover ${card?.maturity?.should_blur ? 'scale-[1.03] blur-xl' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image text-4xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 text-sm text-slate-300">
|
||||
<span>{card.publisher}</span>
|
||||
{card.year ? <span className="text-slate-500">{card.year}</span> : null}
|
||||
{card.category ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{card.category}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{item.note ? (
|
||||
<div className="rounded-[26px] border border-sky-300/20 bg-sky-300/10 px-5 py-4 text-sm leading-7 text-sky-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">Creator note</div>
|
||||
<p className="mt-2 whitespace-pre-wrap">{item.note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function EvolutionStoryBlock({ item, onCompare }) {
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-[30px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.025))] p-5 shadow-[0_22px_55px_rgba(2,6,23,0.26)] backdrop-blur-xl sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{item.heading}</div>
|
||||
<h2 className="mt-2 text-[28px] font-semibold tracking-[-0.04em] text-white">{item.relation_label}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-200/90">{item.summary}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{item.years_apart_label ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
<i className="fa-regular fa-clock" aria-hidden="true" />
|
||||
{item.years_apart_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.compare?.available ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCompare(item)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18"
|
||||
>
|
||||
<i className="fa-solid fa-up-right-and-down-left-from-center" aria-hidden="true" />
|
||||
Compare side by side
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<EvolutionArtworkCard card={item.before} />
|
||||
<EvolutionArtworkCard card={item.after} />
|
||||
</div>
|
||||
|
||||
{item.note ? (
|
||||
<div className="mt-5 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 text-sm leading-7 text-slate-200/90">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Creator note</div>
|
||||
<p className="mt-2 whitespace-pre-wrap">{item.note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EvolutionUpdates({ updates, onCompare }) {
|
||||
if (!updates?.length) return null
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_22px_55px_rgba(0,0,0,0.18)] backdrop-blur-xl sm:p-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/55">Updated Versions</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">This piece has later evolutions</h2>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">Follow how the creator revisited the idea over time.</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{updates.map((item) => (
|
||||
<article key={item.id} className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{item.heading}</div>
|
||||
<h3 className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">{item.after?.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.years_apart_label ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
<i className="fa-regular fa-clock" aria-hidden="true" />
|
||||
{item.years_apart_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.compare?.available ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCompare(item)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<EvolutionArtworkCard card={item.before} />
|
||||
<EvolutionArtworkCard card={item.after} />
|
||||
</div>
|
||||
|
||||
{item.note ? <p className="mt-4 text-sm leading-7 text-slate-300">{item.note}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkEvolutionPanel({ evolution }) {
|
||||
const [compareItem, setCompareItem] = useState(null)
|
||||
|
||||
if (!evolution?.primary && !evolution?.updates?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
{evolution.primary ? <EvolutionStoryBlock item={evolution.primary} onCompare={setCompareItem} /> : null}
|
||||
{evolution.updates?.length ? <EvolutionUpdates updates={evolution.updates} onCompare={setCompareItem} /> : null}
|
||||
</div>
|
||||
|
||||
<ComparisonModal item={compareItem} open={Boolean(compareItem)} onClose={() => setCompareItem(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
function SelectedArtworkCard({ artwork, onClear, disabled = false }) {
|
||||
if (!artwork) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-[26px] border border-sky-300/20 bg-sky-400/[0.08] p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{artwork.thumbnail ? (
|
||||
<img src={artwork.thumbnail} alt={artwork.title} className="h-20 w-20 rounded-[22px] object-cover ring-1 ring-white/10" />
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-[22px] border border-white/10 bg-white/[0.04] text-slate-500">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Linked original</div>
|
||||
<div className="mt-2 truncate text-lg font-semibold text-white">{artwork.title}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-300">
|
||||
<span>{artwork.publisher || 'Artist'}</span>
|
||||
{artwork.year ? <span className="text-slate-500">{artwork.year}</span> : null}
|
||||
{artwork.content_type ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{artwork.content_type}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{artwork.url ? (
|
||||
<a
|
||||
href={artwork.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
Open public
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-slate-500" />
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<i className="fa-solid fa-link-slash" />
|
||||
Remove link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkEvolutionSearchPicker({ artworkId, selected, onSelect, disabled = false }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [options, setOptions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkId) return undefined
|
||||
|
||||
const controller = new AbortController()
|
||||
const handle = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/studio/artworks/${artworkId}/evolution-options?search=${encodeURIComponent(query)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setOptions([])
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setOptions(Array.isArray(data.data) ? data.data : [])
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
setOptions([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, query.trim() === '' ? 0 : 220)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(handle)
|
||||
}
|
||||
}, [artworkId, query])
|
||||
|
||||
const visibleOptions = options.filter((option) => Number(option.id) !== Number(selected?.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SelectedArtworkCard artwork={selected} onClear={() => onSelect(null)} disabled={disabled} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Search your manageable artworks</label>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by title, slug, creator, or group"
|
||||
disabled={disabled}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
{loading ? 'Searching…' : `${visibleOptions.length} result${visibleOptions.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-400">
|
||||
Start with your own published artworks. Group-published pieces appear too when you can publish artworks for that group.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{visibleOptions.length ? visibleOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(option)}
|
||||
disabled={disabled}
|
||||
className="flex w-full flex-col gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-4 text-left transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-60 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{option.thumbnail ? (
|
||||
<img src={option.thumbnail} alt={option.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-500">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-white">{option.title}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-slate-400">
|
||||
<span>{option.publisher || 'Artist'}</span>
|
||||
{option.year ? <span>{option.year}</span> : null}
|
||||
{option.category ? <span>{option.category}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 md:self-center">
|
||||
<i className="fa-solid fa-link" />
|
||||
Link older version
|
||||
</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center text-sm text-slate-300">
|
||||
{loading ? 'Searching artworks…' : 'No manageable published artworks matched this search yet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ArtworkCard from './ArtworkCard'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function revokeDismissSignal(entry) {
|
||||
const item = entry?.item || null
|
||||
const kind = entry?.kind || null
|
||||
|
||||
if (!item || !kind) {
|
||||
throw new Error('missing_dismiss_entry')
|
||||
}
|
||||
|
||||
const endpoint = kind === 'dislike-tag'
|
||||
? item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint
|
||||
: item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('missing_revoke_endpoint')
|
||||
}
|
||||
|
||||
const payload = kind === 'dislike-tag'
|
||||
? {
|
||||
tag_id: item?.primary_tag?.id ? Number(item.primary_tag.id) : undefined,
|
||||
tag_slug: item?.primary_tag?.slug || item?.primary_tag?.name || undefined,
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
reason: item?.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-gallery-undo-dislike-tag',
|
||||
},
|
||||
}
|
||||
: {
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
reason: item?.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-gallery-undo-hide',
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('revoke_request_failed')
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getArtworkKey(item, index) {
|
||||
if (item?.id) return item.id
|
||||
if (item?.title || item?.name || item?.author) {
|
||||
return `${item.title || item.name || 'artwork'}-${item.author || item.author_name || item.uname || 'artist'}-${index}`
|
||||
}
|
||||
|
||||
return `artwork-${index}`
|
||||
}
|
||||
|
||||
function DismissNotice({ notice, onUndo, onClose }) {
|
||||
if (!notice) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
|
||||
<div className="pointer-events-auto rounded-2xl border border-amber-300/30 bg-slate-950/92 px-4 py-3 text-amber-50 shadow-2xl shadow-black/40 backdrop-blur">
|
||||
<p className="text-[11px] uppercase tracking-[0.2em] text-amber-100/70">Discovery Feedback</p>
|
||||
<p className="mt-1 text-sm font-medium">{notice.message}</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={notice.busy}
|
||||
className="inline-flex items-center rounded-full border border-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/85 transition hover:border-white/30 hover:text-white"
|
||||
>
|
||||
{notice.busy ? 'Undoing...' : 'Undo'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/55 transition hover:text-white/85"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkGallery({
|
||||
items,
|
||||
layout = 'grid',
|
||||
compact = false,
|
||||
showStats = true,
|
||||
showAuthor = true,
|
||||
className = '',
|
||||
cardClassName = '',
|
||||
limit,
|
||||
containerProps = {},
|
||||
resolveCardProps,
|
||||
children,
|
||||
}) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
|
||||
const [dismissedEntries, setDismissedEntries] = useState([])
|
||||
const [dismissNotice, setDismissNotice] = useState(null)
|
||||
const visibleArtworkItems = useMemo(
|
||||
() => visibleItems.filter((item) => !item?.maturity?.should_hide && !dismissedEntries.some((entry) => entry.item?.id === item?.id)),
|
||||
[dismissedEntries, visibleItems]
|
||||
)
|
||||
const baseClassName = layout === 'masonry'
|
||||
? 'grid gap-6'
|
||||
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
|
||||
|
||||
useEffect(() => {
|
||||
if (!dismissNotice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDismissNotice(null)
|
||||
}, 3200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [dismissNotice])
|
||||
|
||||
function handleDismissed(item, kind) {
|
||||
if (!item?.id) return
|
||||
|
||||
setDismissedEntries((current) => {
|
||||
const next = current.filter((entry) => entry.item?.id !== item.id)
|
||||
next.push({ item, kind })
|
||||
return next
|
||||
})
|
||||
|
||||
setDismissNotice({
|
||||
itemId: item.id,
|
||||
busy: false,
|
||||
message: kind === 'dislike-tag'
|
||||
? `We will show less content like #${item?.primary_tag?.slug || item?.primary_tag?.name || 'this tag'}.`
|
||||
: 'Artwork hidden from this recommendation view.',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUndoDismiss() {
|
||||
if (!dismissNotice?.itemId) {
|
||||
setDismissNotice(null)
|
||||
return
|
||||
}
|
||||
|
||||
const entry = dismissedEntries.find((current) => current.item?.id === dismissNotice.itemId)
|
||||
if (!entry) {
|
||||
setDismissNotice(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDismissNotice((current) => current ? { ...current, busy: true } : current)
|
||||
|
||||
try {
|
||||
await revokeDismissSignal(entry)
|
||||
} catch {
|
||||
setDismissNotice({
|
||||
itemId: entry.item.id,
|
||||
busy: false,
|
||||
message: 'Undo failed. The feedback signal is still active.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setDismissedEntries((current) => current.filter((entry) => entry.item?.id !== dismissNotice.itemId))
|
||||
setDismissNotice(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(baseClassName, className)} {...containerProps}>
|
||||
{visibleArtworkItems.map((item, index) => {
|
||||
const cardProps = resolveCardProps?.(item, index) || {}
|
||||
const { className: resolvedClassName = '', ...restCardProps } = cardProps
|
||||
|
||||
return (
|
||||
<ArtworkCard
|
||||
key={getArtworkKey(item, index)}
|
||||
artwork={item}
|
||||
compact={compact}
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(cardClassName, resolvedClassName)}
|
||||
onDismissed={handleDismissed}
|
||||
{...restCardProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import ArtworkGallery from './ArtworkGallery'
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function ArtworkGalleryGrid({
|
||||
items,
|
||||
compact = false,
|
||||
showStats = true,
|
||||
showAuthor = true,
|
||||
limit,
|
||||
className = '',
|
||||
cardClassName = '',
|
||||
}) {
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
|
||||
|
||||
return (
|
||||
<ArtworkGallery
|
||||
items={visibleItems}
|
||||
layout="grid"
|
||||
compact={compact}
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(className)}
|
||||
cardClassName={cardClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
|
||||
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, mediaWidth = null, mediaHeight = null, mediaKey = 'cover', onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [mainImageMode, setMainImageMode] = useState('primary')
|
||||
const [previewImageMode, setPreviewImageMode] = useState('primary')
|
||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||
|
||||
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
|
||||
const xlSource = presentXl?.url || artwork?.thumbs?.xl?.url || null
|
||||
|
||||
const md = mdSource || FALLBACK_MD
|
||||
const lg = lgSource || FALLBACK_LG
|
||||
const xl = xlSource || FALLBACK_XL
|
||||
|
||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||
const primaryMainSrc = lgSource || xlSource || mdSource || FALLBACK_LG
|
||||
const primaryPreviewSrc = mdSource || lgSource || xlSource || FALLBACK_MD
|
||||
const srcSet = [
|
||||
mdSource ? `${mdSource} 640w` : null,
|
||||
lgSource ? `${lgSource} 1280w` : null,
|
||||
xlSource ? `${xlSource} 1920w` : null,
|
||||
].filter(Boolean).join(', ')
|
||||
const resolvedMainSrc = mainImageMode === 'fallback'
|
||||
? FALLBACK_LG
|
||||
: (mainImageMode === 'hidden' ? null : primaryMainSrc)
|
||||
const resolvedPreviewSrc = previewImageMode === 'fallback'
|
||||
? FALLBACK_MD
|
||||
: (previewImageMode === 'hidden' ? null : primaryPreviewSrc)
|
||||
|
||||
const dbWidth = Number(mediaWidth ?? artwork?.width)
|
||||
const dbHeight = Number(mediaHeight ?? artwork?.height)
|
||||
const hasDbDims = dbWidth > 0 && dbHeight > 0
|
||||
|
||||
// Natural dimensions — seeded from DB if available, otherwise probed from
|
||||
// the xl thumbnail (largest available, never upscaled past the original).
|
||||
const [naturalDims, setNaturalDims] = useState(
|
||||
hasDbDims ? { w: dbWidth, h: dbHeight } : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false)
|
||||
setMainImageMode('primary')
|
||||
setPreviewImageMode('primary')
|
||||
setShowBackdrop(true)
|
||||
|
||||
if (hasDbDims) {
|
||||
setNaturalDims({ w: dbWidth, h: dbHeight })
|
||||
return
|
||||
}
|
||||
|
||||
setNaturalDims(null)
|
||||
}, [mediaKey, hasDbDims, dbWidth, dbHeight])
|
||||
|
||||
// Probe the xl image to discover real dimensions when DB has none
|
||||
useEffect(() => {
|
||||
if (naturalDims || !xlSource) return
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
}
|
||||
}
|
||||
img.onerror = null
|
||||
img.src = xlSource
|
||||
}, [xlSource, naturalDims])
|
||||
|
||||
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
|
||||
|
||||
return (
|
||||
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
|
||||
{blurBackdropSrc && showBackdrop && (
|
||||
<>
|
||||
<img
|
||||
src={blurBackdropSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
setShowBackdrop(false)
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
|
||||
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-[1400px] items-center gap-2 sm:gap-4">
|
||||
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||
{hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous artwork"
|
||||
onClick={() => onPrev?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden transition-[max-width] duration-300 ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||
style={{ aspectRatio, maxWidth: naturalDims ? `${naturalDims.w}px` : undefined }}
|
||||
onClick={onOpenViewer}
|
||||
role={onOpenViewer ? 'button' : undefined}
|
||||
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
|
||||
tabIndex={onOpenViewer ? 0 : undefined}
|
||||
onKeyDown={onOpenViewer ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenViewer()
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{resolvedPreviewSrc ? (
|
||||
<img
|
||||
src={resolvedPreviewSrc}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="absolute inset-0 h-full w-full object-contain rounded-xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
|
||||
if (previewImageMode === 'primary') {
|
||||
setPreviewImageMode('fallback')
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewImageMode('hidden')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{resolvedMainSrc ? (
|
||||
<img
|
||||
src={resolvedMainSrc}
|
||||
srcSet={mainImageMode === 'primary' && srcSet !== '' ? srcSet : undefined}
|
||||
sizes={mainImageMode === 'primary' && srcSet !== '' ? '(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw' : undefined}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
|
||||
if (mainImageMode === 'primary') {
|
||||
setMainImageMode('fallback')
|
||||
setIsLoaded(false)
|
||||
return
|
||||
}
|
||||
|
||||
setMainImageMode('hidden')
|
||||
setIsLoaded(true)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{onOpenViewer && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View fullscreen"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 shadow-lg ring-1 ring-white/15 backdrop-blur-sm opacity-0 transition-opacity duration-150 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:opacity-100 [div:hover_&]:opacity-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||
{hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next artwork"
|
||||
onClick={() => onNext?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ArtworkMediaStrip({ items = [], selectedId = null, onSelect }) {
|
||||
if (!Array.isArray(items) || items.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[0_18px_60px_rgba(2,8,23,0.24)] backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/45">Gallery</p>
|
||||
<p className="mt-1 text-sm text-white/60">Switch between the default cover and additional archive screenshots.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] text-white/65">
|
||||
{items.length} views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{items.map((item) => {
|
||||
const active = item.id === selectedId
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'group shrink-0 rounded-2xl border p-2 text-left transition-all',
|
||||
active
|
||||
? 'border-sky-300/45 bg-sky-400/12 shadow-[0_0_0_1px_rgba(56,189,248,0.18)]'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-20 w-28 overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 sm:h-24 sm:w-36">
|
||||
{item.thumbUrl ? (
|
||||
<img
|
||||
src={item.thumbUrl}
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-white/30">
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="truncate text-xs font-medium text-white/80">{item.label}</span>
|
||||
<span className={[
|
||||
'rounded-full px-2 py-0.5 text-[10px]',
|
||||
active ? 'bg-sky-300/20 text-sky-100' : 'bg-white/10 text-white/45',
|
||||
].join(' ')}>
|
||||
{active ? 'Showing' : 'View'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
|
||||
export default function ArtworkMeta({ artwork }) {
|
||||
const publisher = artwork?.publisher || null
|
||||
const credits = artwork?.credits || {}
|
||||
const primaryAuthor = credits?.primary_author || artwork?.user || null
|
||||
const contributors = Array.isArray(credits?.contributors) ? credits.contributors : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-300">
|
||||
{publisher?.type === 'group' ? (
|
||||
<a href={publisher.profile_url} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Published by</span>
|
||||
<span className="font-semibold">{publisher.name}</span>
|
||||
</a>
|
||||
) : null}
|
||||
{primaryAuthor ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Primary author</span>
|
||||
{primaryAuthor.profile_url ? <a href={primaryAuthor.profile_url} className="font-semibold text-white hover:text-sky-200">{primaryAuthor.name || primaryAuthor.username}</a> : <span className="font-semibold text-white">{primaryAuthor.name || primaryAuthor.username}</span>}
|
||||
</span>
|
||||
) : null}
|
||||
{contributors.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contributors</span>
|
||||
</span>
|
||||
{contributors.map((item) => {
|
||||
const label = item.name || item.username
|
||||
|
||||
return (
|
||||
<span key={item.id || label} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white">
|
||||
{item.profile_url ? <a href={item.profile_url} className="font-semibold text-white hover:text-sky-200">{label}</a> : <span className="font-semibold text-white">{label}</span>}
|
||||
{item.credit_role ? <span className="text-slate-400">{item.credit_role}</span> : null}
|
||||
{item.is_primary ? <span className="rounded-full border border-emerald-300/30 bg-emerald-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Lead support</span> : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
|
||||
/**
|
||||
* Loads and displays reactions for a single artwork.
|
||||
*
|
||||
* Props:
|
||||
* artworkId number
|
||||
* isLoggedIn boolean
|
||||
*/
|
||||
export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
|
||||
const [totals, setTotals] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkId) return
|
||||
axios
|
||||
.get(`/api/artworks/${artworkId}/reactions`)
|
||||
.then(({ data }) => setTotals(data.totals ?? {}))
|
||||
.catch(() => setTotals({}))
|
||||
.finally(() => setLoading(false))
|
||||
}, [artworkId])
|
||||
|
||||
if (loading) return null
|
||||
|
||||
if (!totals || Object.values(totals).every((r) => r.count === 0) && !isLoggedIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-white/30">Reactions</h2>
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artworkId}
|
||||
initialTotals={totals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
/* ── normalizers ─────────────────────────────────────────────────── */
|
||||
|
||||
function normalizeRelated(item) {
|
||||
if (!item?.url) return null
|
||||
return {
|
||||
id: item.id || item.slug || item.url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item.author || 'Artist',
|
||||
authorAvatar: item.author_avatar || null,
|
||||
url: item.url,
|
||||
thumb: item.thumb || null,
|
||||
thumbSrcSet: item.thumb_srcset || null,
|
||||
maturity: item.maturity || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSimilar(item) {
|
||||
if (!item?.url) return null
|
||||
return {
|
||||
id: item.id || item.slug || item.url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item.author || 'Artist',
|
||||
authorAvatar: item.author_avatar || null,
|
||||
url: item.url,
|
||||
thumb: item.thumb || null,
|
||||
thumbSrcSet: item.thumb_srcset || null,
|
||||
maturity: item.maturity || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRankItem(item) {
|
||||
const url = item?.urls?.direct || item?.urls?.web || item?.url || null
|
||||
if (!url) return null
|
||||
return {
|
||||
id: item.id || item.slug || url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item?.author?.name || 'Artist',
|
||||
authorAvatar: item?.author?.avatar_url || null,
|
||||
url,
|
||||
thumb: item.thumbnail_url || item.thumb || null,
|
||||
thumbSrcSet: null,
|
||||
maturity: item.maturity || null,
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeByUrl(items) {
|
||||
const seen = new Set()
|
||||
return items.filter((item) => {
|
||||
if (item?.maturity?.should_hide) return false
|
||||
if (!item?.url || seen.has(item.url)) return false
|
||||
seen.add(item.url)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Large art card (matches homepage style) ─────────────────── */
|
||||
|
||||
function RailCard({ item }) {
|
||||
const shouldBlur = Boolean(item?.maturity?.should_blur)
|
||||
const isMature = Boolean(item?.maturity?.is_mature_effective)
|
||||
|
||||
return (
|
||||
<article className="w-[240px] shrink-0 snap-start sm:w-[220px] lg:w-[200px] xl:w-[210px] 2xl:w-[220px]">
|
||||
<a
|
||||
href={item.url}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
|
||||
{/* Gloss sheen */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="220px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
{isMature ? <div className="absolute left-3 top-3 z-20 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Mature</div> : null}
|
||||
{shouldBlur ? <div className="absolute inset-x-3 bottom-3 z-20 rounded-full border border-amber-300/20 bg-black/60 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your settings</div> : null}
|
||||
|
||||
{/* Bottom info overlay */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img
|
||||
src={item.authorAvatar || AVATAR_FALLBACK}
|
||||
alt={item.author}
|
||||
className="w-5 h-5 rounded-full object-cover shrink-0 ring-1 ring-white/20"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<span className="truncate">{item.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only">{item.title} by {item.author}</span>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Scroll arrow button ─────────────────────────────────────── */
|
||||
|
||||
function ScrollBtn({ direction, onClick, visible }) {
|
||||
if (!visible) return null
|
||||
const isLeft = direction === 'left'
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
className={`absolute top-1/2 z-30 -translate-y-1/2 hidden lg:flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/80 ${isLeft ? 'left-2' : 'right-2'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{isLeft
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Rail section (infinite loop + mouse-wheel scroll) ───────── */
|
||||
|
||||
function Rail({ title, emoji, items, seeAllHref }) {
|
||||
const scrollRef = useRef(null)
|
||||
const isResettingRef = useRef(false)
|
||||
const scrollEndTimer = useRef(null)
|
||||
const suppressClickTimerRef = useRef(null)
|
||||
const touchStartRef = useRef({ x: 0, y: 0 })
|
||||
const draggedRef = useRef(false)
|
||||
const suppressClickRef = useRef(false)
|
||||
const itemCount = items.length
|
||||
|
||||
/* Triple items so we can loop seamlessly: [clone|original|clone] */
|
||||
const loopItems = useMemo(() => {
|
||||
if (!items.length) return []
|
||||
return [...items, ...items, ...items]
|
||||
}, [items])
|
||||
|
||||
/* Pixel width of one item-set (measured from the DOM) */
|
||||
const getSetWidth = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || el.children.length < itemCount + 1) return 0
|
||||
return el.children[itemCount].offsetLeft - el.children[0].offsetLeft
|
||||
}, [itemCount])
|
||||
|
||||
/* Scroll step based on rendered card width + gap for predictable smooth motion */
|
||||
const getStepWidth = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || el.children.length < 2) return el ? el.clientWidth * 0.75 : 0
|
||||
return el.children[1].offsetLeft - el.children[0].offsetLeft
|
||||
}, [])
|
||||
|
||||
/* Centre on the middle (real) set after mount / data change */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !itemCount) return
|
||||
requestAnimationFrame(() => {
|
||||
const sw = getSetWidth()
|
||||
if (sw) {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = sw
|
||||
el.style.scrollBehavior = ''
|
||||
}
|
||||
})
|
||||
}, [loopItems, getSetWidth, itemCount])
|
||||
|
||||
/* After scroll settles, silently jump back to the middle set if in a clone zone */
|
||||
const resetIfNeeded = useCallback(() => {
|
||||
if (isResettingRef.current) return
|
||||
const el = scrollRef.current
|
||||
if (!el || !itemCount) return
|
||||
const setW = getSetWidth()
|
||||
if (setW === 0) return
|
||||
|
||||
if (el.scrollLeft < setW) {
|
||||
isResettingRef.current = true
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft += setW
|
||||
el.style.scrollBehavior = ''
|
||||
requestAnimationFrame(() => { isResettingRef.current = false })
|
||||
} else if (el.scrollLeft >= setW * 2) {
|
||||
isResettingRef.current = true
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft -= setW
|
||||
el.style.scrollBehavior = ''
|
||||
requestAnimationFrame(() => { isResettingRef.current = false })
|
||||
}
|
||||
}, [getSetWidth, itemCount])
|
||||
|
||||
/* Keep user in the centre segment before scripted smooth scroll starts */
|
||||
const normalizeToMiddle = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !itemCount) return
|
||||
const setW = getSetWidth()
|
||||
if (setW === 0) return
|
||||
if (el.scrollLeft < setW || el.scrollLeft >= setW * 2) {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = ((el.scrollLeft % setW) + setW) % setW + setW
|
||||
el.style.scrollBehavior = ''
|
||||
}
|
||||
}, [getSetWidth, itemCount])
|
||||
|
||||
/* Scroll listener: debounced boundary check + resize re-centre */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const onScroll = () => {
|
||||
clearTimeout(scrollEndTimer.current)
|
||||
scrollEndTimer.current = setTimeout(resetIfNeeded, 80)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
const onResize = () => {
|
||||
const sw = getSetWidth()
|
||||
if (sw) {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = sw
|
||||
el.style.scrollBehavior = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
clearTimeout(scrollEndTimer.current)
|
||||
clearTimeout(suppressClickTimerRef.current)
|
||||
}
|
||||
}, [loopItems, resetIfNeeded, getSetWidth])
|
||||
|
||||
/* Mouse-wheel → horizontal scroll (re-attach when items arrive) */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !loopItems.length) return
|
||||
const onWheel = (e) => {
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
e.preventDefault()
|
||||
el.scrollLeft += e.deltaY
|
||||
}
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [loopItems])
|
||||
|
||||
const scroll = useCallback((dir) => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
normalizeToMiddle()
|
||||
const step = getStepWidth()
|
||||
const amount = step > 0 ? step * 2 : el.clientWidth * 0.75
|
||||
el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' })
|
||||
clearTimeout(scrollEndTimer.current)
|
||||
scrollEndTimer.current = setTimeout(resetIfNeeded, 260)
|
||||
}, [getStepWidth, normalizeToMiddle, resetIfNeeded])
|
||||
|
||||
/* Prevent accidental link activation after horizontal swipe on touch devices */
|
||||
const onTouchStart = useCallback((e) => {
|
||||
if (!e.touches?.length) return
|
||||
const t = e.touches[0]
|
||||
touchStartRef.current = { x: t.clientX, y: t.clientY }
|
||||
draggedRef.current = false
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e) => {
|
||||
if (!e.touches?.length) return
|
||||
const t = e.touches[0]
|
||||
const dx = Math.abs(t.clientX - touchStartRef.current.x)
|
||||
const dy = Math.abs(t.clientY - touchStartRef.current.y)
|
||||
if (dx > 10 && dx > dy) {
|
||||
draggedRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (!draggedRef.current) return
|
||||
suppressClickRef.current = true
|
||||
clearTimeout(suppressClickTimerRef.current)
|
||||
suppressClickTimerRef.current = setTimeout(() => {
|
||||
suppressClickRef.current = false
|
||||
}, 260)
|
||||
}, [])
|
||||
|
||||
const onClickCapture = useCallback((e) => {
|
||||
if (!suppressClickRef.current) return
|
||||
const link = e.target?.closest?.('a')
|
||||
if (link) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-5 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{emoji && <span className="mr-1.5">{emoji}</span>}{title}
|
||||
</h2>
|
||||
{seeAllHref && (
|
||||
<a href={seeAllHref} className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative" data-nav-swipe-ignore="1">
|
||||
{/* Permanent edge fades for infinite illusion */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 w-24 bg-gradient-to-r from-[#0F1724] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-20 w-24 bg-gradient-to-l from-[#0F1724] to-transparent" />
|
||||
|
||||
<ScrollBtn direction="left" onClick={() => scroll('left')} visible={true} />
|
||||
<ScrollBtn direction="right" onClick={() => scroll('right')} visible={true} />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onClickCapture={onClickCapture}
|
||||
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 snap-x snap-mandatory scroll-smooth scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{loopItems.map((item, idx) => (
|
||||
<RailCard key={`${item.id || item.url}-${idx}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main export ─────────────────────────────────────────────── */
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const [similarApiItems, setSimilarApiItems] = useState([])
|
||||
const [similarLoaded, setSimilarLoaded] = useState(false)
|
||||
const [trendingItems, setTrendingItems] = useState([])
|
||||
|
||||
const relatedCards = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
|
||||
}, [related])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadSimilar = async () => {
|
||||
if (!artwork?.id) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems(items)
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSimilar()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadTrending = async () => {
|
||||
const categoryId = artwork?.categories?.[0]?.id
|
||||
if (!categoryId) {
|
||||
setTrendingItems([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('trending fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
|
||||
if (!isCancelled) setTrendingItems(items)
|
||||
} catch {
|
||||
if (!isCancelled) setTrendingItems([])
|
||||
}
|
||||
}
|
||||
|
||||
loadTrending()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.categories])
|
||||
|
||||
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
|
||||
|
||||
const tagBasedFallback = useMemo(() => {
|
||||
return relatedCards.filter((item) => String(item.author || '').trim().toLowerCase() !== authorName)
|
||||
}, [relatedCards, authorName])
|
||||
|
||||
const similarItems = useMemo(() => {
|
||||
if (!similarLoaded) return []
|
||||
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
|
||||
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
|
||||
return trendingItems.slice(0, 12)
|
||||
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
|
||||
|
||||
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
|
||||
|
||||
if (similarItems.length === 0 && trendingRailItems.length === 0) return null
|
||||
|
||||
const categoryName = artwork?.categories?.[0]?.name
|
||||
const trendingLabel = categoryName
|
||||
? `Trending in ${categoryName}`
|
||||
: 'Trending'
|
||||
|
||||
const trendingHref = categoryName
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
|
||||
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<Rail title="Similar Artworks" emoji="✨" items={similarItems} seeAllHref={similarHref} />
|
||||
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ArtworkGalleryGrid from './ArtworkGalleryGrid'
|
||||
|
||||
export default function ArtworkRelated({ related }) {
|
||||
if (!Array.isArray(related) || related.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg font-semibold text-white">Related Artworks</h2>
|
||||
|
||||
<ArtworkGalleryGrid
|
||||
items={related.slice(0, 8)}
|
||||
compact
|
||||
className="mt-5 xl:grid-cols-4"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { lazy, Suspense, useCallback, useState } from 'react'
|
||||
import useWebShare from '../../hooks/useWebShare'
|
||||
|
||||
const ArtworkShareModal = lazy(() => import('./ArtworkShareModal'))
|
||||
|
||||
/* ── Share icon (lucide-style) ───────────────────────────────────────────── */
|
||||
function ShareIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ArtworkShareButton – renders the Share pill and manages modal / native share.
|
||||
*
|
||||
* Props:
|
||||
* artwork – artwork object
|
||||
* shareUrl – canonical URL to share
|
||||
* size – 'default' | 'small' (for mobile bar)
|
||||
*/
|
||||
export default function ArtworkShareButton({ artwork, shareUrl, size = 'default', isLoggedIn = false }) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
() => setModalOpen(true),
|
||||
[],
|
||||
)
|
||||
const closeModal = useCallback(
|
||||
() => setModalOpen(false),
|
||||
[],
|
||||
)
|
||||
|
||||
const { share } = useWebShare({ onFallback: openModal })
|
||||
|
||||
const handleClick = () => {
|
||||
share({
|
||||
title: artwork?.title || 'Artwork',
|
||||
text: artwork?.description?.substring(0, 120) || '',
|
||||
url: shareUrl || artwork?.canonical_url || window.location.href,
|
||||
})
|
||||
}
|
||||
|
||||
const isSmall = size === 'small'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share artwork"
|
||||
onClick={handleClick}
|
||||
className={
|
||||
isSmall
|
||||
? 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white'
|
||||
: 'group inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white hover:shadow-lg hover:shadow-white/[0.03]'
|
||||
}
|
||||
title="Share"
|
||||
>
|
||||
<ShareIcon />
|
||||
{!isSmall && <span>Share</span>}
|
||||
</button>
|
||||
|
||||
{/* Lazy-loaded modal – only rendered when opened */}
|
||||
{modalOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ArtworkShareModal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
artwork={artwork}
|
||||
shareUrl={shareUrl}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import ShareToast from '../ui/ShareToast'
|
||||
|
||||
// Lazy-load the Feed share modal so artwork pages don't bundle the feed layer unless needed
|
||||
const FeedShareArtworkModal = lazy(() => import('../Feed/ShareArtworkModal'))
|
||||
|
||||
/* ── Platform share URLs ─────────────────────────────────────────────────── */
|
||||
function facebookUrl(url) {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
|
||||
}
|
||||
function twitterUrl(url, title) {
|
||||
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
|
||||
}
|
||||
function pinterestUrl(url, imageUrl, title) {
|
||||
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`
|
||||
}
|
||||
function emailUrl(url, title) {
|
||||
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
/* ── Icons ────────────────────────────────────────────────────────────────── */
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5 text-emerald-400">
|
||||
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FacebookIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XTwitterIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PinterestIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EmbedIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────────────── */
|
||||
function openShareWindow(url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer,width=600,height=500')
|
||||
}
|
||||
|
||||
function trackShare(artworkId, platform) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
fetch(`/api/artworks/${artworkId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ platform }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/* ── Main component ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* ArtworkShareModal
|
||||
*
|
||||
* Props:
|
||||
* open – boolean, whether modal is visible
|
||||
* onClose – callback to close modal
|
||||
* artwork – artwork object (id, title, description, thumbs, canonical_url, …)
|
||||
* shareUrl – canonical share URL
|
||||
*/
|
||||
export default function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
|
||||
const backdropRef = useRef(null)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const [embedCopied, setEmbedCopied] = useState(false)
|
||||
const [showEmbed, setShowEmbed] = useState(false)
|
||||
const [toastVisible, setToastVisible] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState('')
|
||||
const [profileShareOpen, setProfileShareOpen] = useState(false)
|
||||
|
||||
const url = shareUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const title = artwork?.title || 'Artwork'
|
||||
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || ''
|
||||
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl
|
||||
|
||||
const embedCode = `<a href="${url}">\n <img src="${thumbMdUrl}" alt="${title.replace(/"/g, '"')}" />\n</a>`
|
||||
|
||||
// Lock body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
// Reset state when re-opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLinkCopied(false)
|
||||
setEmbedCopied(false)
|
||||
setShowEmbed(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const showToast = useCallback((msg) => {
|
||||
setToastMessage(msg)
|
||||
setToastVisible(true)
|
||||
}, [])
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setLinkCopied(true)
|
||||
showToast('Link copied!')
|
||||
trackShare(artwork?.id, 'copy')
|
||||
setTimeout(() => setLinkCopied(false), 2500)
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
const handleCopyEmbed = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(embedCode)
|
||||
setEmbedCopied(true)
|
||||
showToast('Embed code copied!')
|
||||
trackShare(artwork?.id, 'embed')
|
||||
setTimeout(() => setEmbedCopied(false), 2500)
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
const handlePlatformShare = (platform, shareLink) => {
|
||||
openShareWindow(shareLink)
|
||||
trackShare(artwork?.id, platform)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const SHARE_OPTIONS = [
|
||||
{
|
||||
label: linkCopied ? 'Copied!' : 'Copy Link',
|
||||
icon: linkCopied ? <CheckIcon /> : <CopyIcon />,
|
||||
onClick: handleCopyLink,
|
||||
className: linkCopied
|
||||
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
},
|
||||
{
|
||||
label: 'Facebook',
|
||||
icon: <FacebookIcon />,
|
||||
onClick: () => handlePlatformShare('facebook', facebookUrl(url)),
|
||||
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]',
|
||||
},
|
||||
{
|
||||
label: 'X (Twitter)',
|
||||
icon: <XTwitterIcon />,
|
||||
onClick: () => handlePlatformShare('twitter', twitterUrl(url, title)),
|
||||
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white',
|
||||
},
|
||||
{
|
||||
label: 'Pinterest',
|
||||
icon: <PinterestIcon />,
|
||||
onClick: () => handlePlatformShare('pinterest', pinterestUrl(url, imageUrl, title)),
|
||||
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]',
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
icon: <EmailIcon />,
|
||||
onClick: () => { window.location.href = emailUrl(url, title); trackShare(artwork?.id, 'email') },
|
||||
className: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
},
|
||||
...(isLoggedIn ? [{
|
||||
label: 'My Profile',
|
||||
icon: <i className="fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" />,
|
||||
onClick: () => setProfileShareOpen(true),
|
||||
className: 'border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20',
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) onClose() }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Share this artwork"
|
||||
>
|
||||
{/* Modal container — glassmorphism */}
|
||||
<div className="w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<h3 className="text-base font-semibold text-white">Share this artwork</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70"
|
||||
aria-label="Close share dialog"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Artwork preview */}
|
||||
{thumbMdUrl && (
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.06] px-6 py-3">
|
||||
<img
|
||||
src={thumbMdUrl}
|
||||
alt={title}
|
||||
className="h-14 w-14 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">{title}</p>
|
||||
{artwork?.user?.username && (
|
||||
<p className="truncate text-xs text-white/50">by {artwork.user.username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share buttons grid */}
|
||||
<div className="grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5">
|
||||
{SHARE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={opt.onClick}
|
||||
className={[
|
||||
'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200',
|
||||
opt.className,
|
||||
].join(' ')}
|
||||
>
|
||||
{opt.icon}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Embed section */}
|
||||
<div className="border-t border-white/[0.06] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmbed(!showEmbed)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
|
||||
>
|
||||
<EmbedIcon />
|
||||
{showEmbed ? 'Hide Embed Code' : 'Embed Code'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className={`h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path fillRule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showEmbed && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
readOnly
|
||||
value={embedCode}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]"
|
||||
onClick={(e) => e.target.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyEmbed}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200',
|
||||
embedCopied
|
||||
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{embedCopied ? <CheckIcon /> : <CopyIcon />}
|
||||
{embedCopied ? 'Copied!' : 'Copy Embed'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
<ShareToast
|
||||
message={toastMessage}
|
||||
visible={toastVisible}
|
||||
onHide={() => setToastVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Share to Profile (Feed) modal — lazy loaded */}
|
||||
{profileShareOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<FeedShareArtworkModal
|
||||
isOpen={profileShareOpen}
|
||||
onClose={() => setProfileShareOpen(false)}
|
||||
preselectedArtwork={artwork?.id ? {
|
||||
id: artwork.id,
|
||||
title: artwork.title,
|
||||
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
|
||||
user: artwork.user ?? null,
|
||||
} : null}
|
||||
onShared={() => {
|
||||
setProfileShareOpen(false)
|
||||
showToast('Shared to your profile!')
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatCount(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${number}`
|
||||
}
|
||||
|
||||
export default function ArtworkStats({ artwork, stats: statsProp }) {
|
||||
const stats = statsProp || artwork?.stats || {}
|
||||
const width = artwork?.dimensions?.width || 0
|
||||
const height = artwork?.dimensions?.height || 0
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Statistics</h2>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt className="text-soft">👁 Views</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.views)} views</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt className="text-soft">⬇️ Downloads</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.downloads)} downloads</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
|
||||
<dt className="text-soft">❤️ Likes</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.likes)} likes</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
|
||||
<dt className="text-soft">⭐ Favorites</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.favorites)} favorites</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
|
||||
<dt className="text-soft">Resolution</dt>
|
||||
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
export default function ArtworkTags({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const contentTypeSeen = new Set()
|
||||
const categoryPills = []
|
||||
|
||||
// Add content types (e.g. "Wallpapers") first, then categories, then tags
|
||||
for (const category of artwork?.categories || []) {
|
||||
const ctSlug = category.content_type_slug
|
||||
if (ctSlug && !contentTypeSeen.has(ctSlug)) {
|
||||
contentTypeSeen.add(ctSlug)
|
||||
const ctName = ctSlug.charAt(0).toUpperCase() + ctSlug.slice(1)
|
||||
categoryPills.push({
|
||||
key: `ct-${ctSlug}`,
|
||||
label: ctName,
|
||||
href: `/${ctSlug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (category.parent && !seen.has(category.parent.id)) {
|
||||
seen.add(category.parent.id)
|
||||
categoryPills.push({
|
||||
key: `cat-${category.parent.id}`,
|
||||
label: category.parent.name,
|
||||
href: category.parent.url || `/${category.parent.content_type_slug}/${category.parent.slug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
if (!seen.has(category.id)) {
|
||||
seen.add(category.id)
|
||||
categoryPills.push({
|
||||
key: `cat-${category.id}`,
|
||||
label: category.name,
|
||||
href: category.url || `/${category.content_type_slug}/${category.slug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||
key: `tag-${tag.id || tag.slug}`,
|
||||
label: tag.name,
|
||||
href: `/tag/${tag.slug || ''}`,
|
||||
isCategory: false,
|
||||
}))
|
||||
|
||||
return [...categoryPills, ...artworkTags]
|
||||
}, [artwork])
|
||||
|
||||
if (tags.length === 0) return null
|
||||
|
||||
const visible = expanded ? tags : tags.slice(0, 12)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-accent/70">Tags & Categories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((tag, idx) => (
|
||||
<a
|
||||
key={tag.key}
|
||||
href={tag.href}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200',
|
||||
tag.isCategory
|
||||
? 'border-accent/30 bg-accent/10 text-accent hover:border-accent/50 hover:bg-accent/20'
|
||||
: 'border-white/[0.08] bg-white/[0.03] text-white/60 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{tags.length > 12 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-dashed border-white/[0.1] px-3 py-1.5 text-xs text-white/40 transition hover:border-white/20 hover:text-white/60"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : `+${tags.length - 12} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
function toCard(item) {
|
||||
return {
|
||||
id: item?.id || item?.slug || item?.url,
|
||||
title: item?.title,
|
||||
author: item?.author,
|
||||
url: item?.url,
|
||||
thumb: item?.thumb,
|
||||
thumbSrcSet: item?.thumb_srcset,
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
const publisher = artwork?.publisher || null
|
||||
const isGroupPublisher = publisher?.type === 'group'
|
||||
const [following, setFollowing] = useState(Boolean(isGroupPublisher ? artwork?.viewer?.is_following_group : artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(isGroupPublisher ? publisher?.followers_count || 0 : artwork?.user?.followers_count || 0))
|
||||
|
||||
const user = artwork?.credits?.primary_author || artwork?.user || {}
|
||||
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = isGroupPublisher ? (publisher?.name || 'Group') : (user.name || user.username || 'Artist')
|
||||
const profileUrl = isGroupPublisher ? (publisher?.profile_url || '#') : (user.profile_url || (user.username ? `/@${user.username}` : '#'))
|
||||
const avatar = (isGroupPublisher ? publisher?.avatar_url : user.avatar_url) || presentSq?.url || AVATAR_FALLBACK
|
||||
|
||||
const creatorItems = useMemo(() => {
|
||||
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
|
||||
const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase()
|
||||
const notCurrent = item?.url && item.url !== artwork?.canonical_url
|
||||
return sameAuthor && notCurrent
|
||||
})
|
||||
|
||||
const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : [])
|
||||
return source.slice(0, 12).map(toCard)
|
||||
}, [related, authorName, artwork?.canonical_url])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{!isGroupPublisher && user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
{isGroupPublisher && artwork?.credits?.primary_author ? <p className="text-xs text-white/40">Primary author: {artwork.credits.primary_author.name || artwork.credits.primary_author.username}</p> : null}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
title="View profile"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
{!isOwnArtwork && !isGroupPublisher ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="flex-1"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!isOwnArtwork && isGroupPublisher ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const method = following ? 'DELETE' : 'POST'
|
||||
const response = await fetch(following ? artwork.publisher?.unfollow_url || `${publisher.profile_url}/follow` : artwork.publisher?.follow_url || `${publisher.profile_url}/follow`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (response.ok) {
|
||||
setFollowing(Boolean(payload?.following))
|
||||
setFollowersCount(Number(payload?.followers_count || 0))
|
||||
}
|
||||
}}
|
||||
className={`flex-1 rounded-xl border px-3 py-2.5 text-sm font-medium transition ${following ? 'border-white/[0.12] bg-white/[0.05] text-white' : 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-300/15'}`}
|
||||
>
|
||||
{following ? 'Following' : 'Follow group'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user