feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import axios from 'axios'
|
||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
||||
import ArtworkActions from '../components/artwork/ArtworkActions'
|
||||
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
||||
import ArtworkStats from '../components/artwork/ArtworkStats'
|
||||
import ArtworkTags from '../components/artwork/ArtworkTags'
|
||||
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
|
||||
import ArtworkRelated from '../components/artwork/ArtworkRelated'
|
||||
import ArtworkDescription from '../components/artwork/ArtworkDescription'
|
||||
import ArtworkComments from '../components/artwork/ArtworkComments'
|
||||
import ArtworkReactions from '../components/artwork/ArtworkReactions'
|
||||
import ArtworkActionBar from '../components/artwork/ArtworkActionBar'
|
||||
import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel'
|
||||
import CreatorSpotlight from '../components/artwork/CreatorSpotlight'
|
||||
import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommendationsRails'
|
||||
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
import ReactionBar from '../components/comments/ReactionBar'
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
@@ -43,6 +44,16 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
||||
|
||||
// Artwork-level reactions
|
||||
const [reactionTotals, setReactionTotals] = useState(null)
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
axios
|
||||
.get(`/api/artworks/${artwork.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => setReactionTotals({}))
|
||||
}, [artwork?.id])
|
||||
|
||||
/**
|
||||
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||
@@ -66,50 +77,83 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
||||
<ArtworkHero
|
||||
artwork={artwork}
|
||||
presentMd={presentMd}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
|
||||
<div className="mt-6 space-y-4 lg:hidden">
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority onStatsChange={handleStatsChange} />
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
<main className="pb-24 pt-6 lg:pb-12 lg:pt-8">
|
||||
{/* ── Hero ────────────────────────────────────────────────────── */}
|
||||
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
|
||||
<ArtworkHero
|
||||
artwork={artwork}
|
||||
presentMd={presentMd}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
<ArtworkStats artwork={artwork} stats={liveStats} />
|
||||
<ArtworkTags artwork={artwork} />
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
|
||||
<ArtworkComments
|
||||
artworkId={artwork.id}
|
||||
comments={comments}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loginUrl="/login"
|
||||
/>
|
||||
</div>
|
||||
{/* ── Centered action bar with stat counts ────────────────────── */}
|
||||
<div className="mx-auto mt-5 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||
<ArtworkActionBar
|
||||
artwork={artwork}
|
||||
stats={liveStats}
|
||||
canonicalUrl={canonicalUrl}
|
||||
onStatsChange={handleStatsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="hidden space-y-6 lg:block">
|
||||
<div className="sticky top-24 space-y-4">
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} onStatsChange={handleStatsChange} />
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
{/* ── Two-column content ──────────────────────────────────────── */}
|
||||
<div className="mx-auto mt-8 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
{/* LEFT COLUMN — main content */}
|
||||
<div className="relative z-10 min-w-0 space-y-5">
|
||||
{/* Title + author + breadcrumbs */}
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
|
||||
{/* Description */}
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
|
||||
{/* Artwork reactions */}
|
||||
{reactionTotals !== null && (
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artwork.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isAuthenticated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags & categories */}
|
||||
<ArtworkTags artwork={artwork} />
|
||||
|
||||
{/* Comments */}
|
||||
<ArtworkComments
|
||||
artworkId={artwork.id}
|
||||
comments={comments}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loginUrl="/login"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* RIGHT COLUMN — sidebar */}
|
||||
<aside className="space-y-5 lg:sticky lg:top-6 lg:self-start">
|
||||
{/* Creator card */}
|
||||
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
|
||||
|
||||
{/* Details (collapsible) */}
|
||||
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
|
||||
|
||||
{/* Awards */}
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtworkRelated related={related} />
|
||||
{/* ── Full-width recommendation rails ─────────────────────────── */}
|
||||
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
|
||||
<ArtworkRecommendationsRails artwork={artwork} related={related} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CreatorCard({ creator }) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function FreshCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CreatorCard({ creator }) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function ArtCard({ item }) {
|
||||
const username = item.author_username ? `@${item.author_username}` : null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
export default function HomeWelcomeRow({ user_data }) {
|
||||
if (!user_data) return null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import SearchBar from '../Search/SearchBar'
|
||||
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/avatars/default.webp'
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
export default function Topbar({ user = null }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
324
resources/js/components/artwork/ArtworkActionBar.jsx
Normal file
324
resources/js/components/artwork/ArtworkActionBar.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
/* ── SVG Icons ─────────────────────────────────────────────────────────────── */
|
||||
function HeartIcon({ filled }) {
|
||||
return filled ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkIcon({ filled }) {
|
||||
return filled ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CloudDownIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadArrowIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FlagIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(Boolean(artwork?.viewer?.is_liked))
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_liked, artwork?.viewer?.is_favorited])
|
||||
|
||||
const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
// Track view
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
const key = `sb_viewed_${artwork.id}`
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).then(res => {
|
||||
if (res.ok && typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1')
|
||||
}).catch(() => {})
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) throw new Error('Request failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (downloading || !artwork?.id) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/art/${artwork.id}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = res.ok ? await res.json() : null
|
||||
const url = data?.url || fallbackUrl
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = data?.filename || ''
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch {
|
||||
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleLike = async () => {
|
||||
const nextState = !liked
|
||||
setLiked(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
|
||||
onStatsChange?.({ likes: nextState ? 1 : -1 })
|
||||
} catch { setLiked(!nextState) }
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
|
||||
onStatsChange?.({ favorites: nextState ? 1 : -1 })
|
||||
} catch { setFavorited(!nextState) }
|
||||
}
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: artwork?.title || 'Artwork', url: shareUrl })
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
const onReport = async () => {
|
||||
if (reporting) return
|
||||
setReporting(true)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, { reason: 'Reported from artwork page' })
|
||||
} catch { /* noop */ }
|
||||
finally { setReporting(false) }
|
||||
}
|
||||
|
||||
const likeCount = formatCount(stats?.likes ?? artwork?.stats?.likes ?? 0)
|
||||
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
||||
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop centered bar ────────────────────────────────────── */}
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
|
||||
{/* Like stat pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? 'Unlike artwork' : 'Like artwork'}
|
||||
onClick={onToggleLike}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
liked
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
<span className="tabular-nums">{likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Favorite/bookmark stat pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Unsave artwork' : 'Save artwork'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
favorited
|
||||
? 'border-amber-500/40 bg-amber-500/15 text-amber-400 shadow-lg shadow-amber-500/10 hover:bg-amber-500/20'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Views stat pill */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
|
||||
<CloudDownIcon />
|
||||
<span className="tabular-nums">{viewCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Share pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share artwork"
|
||||
onClick={onShare}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<ShareIcon />
|
||||
{copied ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
|
||||
{/* Report pill */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report artwork"
|
||||
onClick={onReport}
|
||||
disabled={reporting}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
<FlagIcon />
|
||||
{reporting ? '…' : 'Report'}
|
||||
</button>
|
||||
|
||||
{/* Download button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download artwork"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-2.5 text-sm font-bold text-deep shadow-lg shadow-accent/25 transition-all duration-200 hover:brightness-110 hover:shadow-xl hover:shadow-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<DownloadArrowIcon />
|
||||
{downloading ? 'Downloading…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile fixed bottom bar ─────────────────────────────────── */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/[0.08] bg-nova-900/95 px-3 py-2.5 backdrop-blur-md lg:hidden">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={liked ? 'Unlike' : 'Like'}
|
||||
onClick={onToggleLike}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
liked
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={liked} />
|
||||
<span className="tabular-nums">{likeCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={favorited ? 'Unsave' : 'Save'}
|
||||
onClick={onToggleFavorite}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
favorited
|
||||
? 'border-amber-500/40 bg-amber-500/15 text-amber-400'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share"
|
||||
onClick={onShare}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Report"
|
||||
onClick={onReport}
|
||||
disabled={reporting}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all hover:border-red-500/40 hover:text-red-400 disabled:opacity-50"
|
||||
>
|
||||
<FlagIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download artwork"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-accent px-5 py-2 text-xs font-bold text-deep transition hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<DownloadArrowIcon />
|
||||
{downloading ? '…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -136,8 +136,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
||||
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Awards</h2>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/30">Awards</h2>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-400">{error}</p>
|
||||
@@ -158,8 +158,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
||||
className={[
|
||||
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-nova-600 text-white hover:bg-nova-800',
|
||||
? 'border-accent/40 bg-accent/10 font-semibold text-accent shadow-lg shadow-accent/10'
|
||||
: 'border-white/[0.08] bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:border-white/[0.12]',
|
||||
(!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
|
||||
33
resources/js/components/artwork/ArtworkCardMini.jsx
Normal file
33
resources/js/components/artwork/ArtworkCardMini.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
|
||||
export default function ArtworkCardMini({ item }) {
|
||||
if (!item?.url) return null
|
||||
|
||||
return (
|
||||
<article className="group min-w-[14rem] shrink-0 snap-start overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 hover:-translate-y-0.5 hover:border-white/[0.1] hover:shadow-xl hover:shadow-black/30">
|
||||
<a href={item.url} className="block">
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || FALLBACK_MD}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="256px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_MD
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-60" />
|
||||
</div>
|
||||
<div className="px-3.5 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-white/90">{item.title || 'Untitled'}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">by {item.author || 'Artist'}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,32 @@ function timeAgo(dateStr) {
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/* ── Icons ─────────────────────────────────────────────────────────────────── */
|
||||
function ReplyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Avatar ─────────────────────────────────────────────────────────────────── */
|
||||
function Avatar({ user, size = 36 }) {
|
||||
if (user?.avatar_url) {
|
||||
return (
|
||||
@@ -28,12 +54,12 @@ function Avatar({ user, size = 36 }) {
|
||||
alt={user.name || user.username || ''}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0"
|
||||
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp'
|
||||
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -41,7 +67,7 @@ function Avatar({ user, size = 36 }) {
|
||||
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
|
||||
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initials}
|
||||
@@ -49,21 +75,172 @@ function Avatar({ user, size = 36 }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single comment ────────────────────────────────────────────────────────────
|
||||
// ── Reply item (nested under a parent) ────────────────────────────────────────
|
||||
|
||||
function CommentItem({ comment, isLoggedIn }) {
|
||||
const user = comment.user
|
||||
const html = comment.rendered_content ?? null
|
||||
const plain = comment.content ?? ''
|
||||
function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, depth = 1 }) {
|
||||
const user = reply.user
|
||||
const html = reply.rendered_content ?? null
|
||||
const plain = reply.content ?? reply.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || user?.name || 'Member'
|
||||
const replies = reply.replies || []
|
||||
|
||||
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
|
||||
const flood = isFlood(plain)
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
if (reply.reactions || !reply.id) return
|
||||
axios
|
||||
.get(`/api/comments/${reply.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [reply.id, reply.reactions])
|
||||
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
// Reply posts under THIS reply's id as parent
|
||||
onReplyPosted?.(reply.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [reply.id, onReplyPosted])
|
||||
|
||||
// Show first 2 nested replies, expand to show all
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
|
||||
// Shrink avatar at deeper levels
|
||||
const avatarSize = depth >= 3 ? 22 : 28
|
||||
|
||||
return (
|
||||
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
|
||||
<div className="flex gap-2.5">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
|
||||
<Avatar user={user} size={avatarSize} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||
)}
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
|
||||
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||
>
|
||||
{reply.time_ago || timeAgo(reply.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{html ? (
|
||||
<div
|
||||
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
{/* Actions — Reply + React inline */}
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm(v => !v)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={reply.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline reply form */}
|
||||
{showReplyForm && (
|
||||
<div className="mt-2">
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
parentId={reply.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
onPosted={handleReplyPosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nested replies (tree) */}
|
||||
{replies.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
|
||||
{visibleReplies.map((child) => (
|
||||
<ReplyItem
|
||||
key={child.id}
|
||||
reply={child}
|
||||
parentId={reply.id}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single comment (top-level) ────────────────────────────────────────────────
|
||||
|
||||
function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
const user = comment.user
|
||||
const html = comment.rendered_content ?? null
|
||||
const plain = comment.content ?? comment.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || user?.name || 'Member'
|
||||
const replies = comment.replies || []
|
||||
|
||||
const flood = isFlood(plain)
|
||||
const [expanded, setExpanded] = useState(!flood)
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
|
||||
// Build initial reaction totals (empty if not provided by server)
|
||||
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
|
||||
|
||||
// Load reactions lazily if not provided
|
||||
useEffect(() => {
|
||||
if (comment.reactions || !comment.id) return
|
||||
axios
|
||||
@@ -72,92 +249,159 @@ function CommentItem({ comment, isLoggedIn }) {
|
||||
.catch(() => {})
|
||||
}, [comment.id, comment.reactions])
|
||||
|
||||
return (
|
||||
<li className="flex gap-3" id={`comment-${comment.id}`}>
|
||||
{/* Avatar */}
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||
<Avatar user={user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
onReplyPosted?.(comment.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [comment.id, onReplyPosted])
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{/* Header */}
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
// Show first 2 replies by default, expand to show all
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`comment-${comment.id}`}
|
||||
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
|
||||
>
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex gap-3.5">
|
||||
{/* Avatar */}
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-sm font-medium text-white hover:underline">
|
||||
{user.display || user.username || user.name || 'Member'}
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||
<Avatar user={user} size={38} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.display || user?.username || user?.name || 'Member'}
|
||||
</span>
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
|
||||
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
|
||||
<div
|
||||
className={!expanded ? 'overflow-hidden relative' : undefined}
|
||||
style={!expanded ? { maxHeight: '5em' } : undefined}
|
||||
>
|
||||
{html ? (
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||
)}
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
|
||||
prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline
|
||||
prose-code:bg-white/[0.07] prose-code:px-1 prose-code:rounded prose-code:text-xs"
|
||||
// rendered_content is server-sanitized HTML — safe to inject
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{plain}
|
||||
</p>
|
||||
)}
|
||||
className={!expanded ? 'overflow-hidden relative' : undefined}
|
||||
style={!expanded ? { maxHeight: '5em' } : undefined}
|
||||
>
|
||||
{html ? (
|
||||
<div
|
||||
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
{/* Gradient fade at the bottom while collapsed */}
|
||||
{flood && !expanded && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{flood && !expanded && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flood && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '↑ Collapse' : '↓ Show full comment'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm(v => !v)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flood expand / collapse toggle */}
|
||||
{flood && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-500 rounded"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{Object.keys(reactionTotals).length > 0 && (
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Replies thread ───────────────────────────────────────────────── */}
|
||||
{(replies.length > 0 || showReplyForm) && (
|
||||
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
|
||||
{replies.length > 0 && (
|
||||
<>
|
||||
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
|
||||
{visibleReplies.map((reply) => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
parentId={comment.id}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Inline reply form */}
|
||||
{showReplyForm && (
|
||||
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
parentId={comment.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
onPosted={handleReplyPosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -166,14 +410,24 @@ function CommentItem({ comment, isLoggedIn }) {
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-3 bg-white/[0.07] rounded w-28" />
|
||||
<div className="h-3 bg-white/[0.05] rounded w-full" />
|
||||
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
|
||||
style={{ animationDelay: `${i * 120}ms` }}
|
||||
>
|
||||
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
|
||||
<div className="flex-1 space-y-3 pt-1">
|
||||
<div className="flex gap-2.5">
|
||||
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
|
||||
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -183,19 +437,6 @@ function Skeleton() {
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ArtworkComments
|
||||
*
|
||||
* Can operate in two modes:
|
||||
* 1. Static: pass `comments` array from Inertia page props (legacy / SSR)
|
||||
* 2. Dynamic: pass `artworkId` to load + post comments via the API
|
||||
*
|
||||
* Props:
|
||||
* artworkId number Used for API calls
|
||||
* comments array SSR initial comments (optional)
|
||||
* isLoggedIn boolean
|
||||
* loginUrl string
|
||||
*/
|
||||
export default function ArtworkComments({
|
||||
artworkId,
|
||||
comments: initialComments = [],
|
||||
@@ -209,7 +450,6 @@ export default function ArtworkComments({
|
||||
const [total, setTotal] = useState(initialComments.length)
|
||||
const initialized = useRef(false)
|
||||
|
||||
// Load comments from API
|
||||
const loadComments = useCallback(
|
||||
async (p = 1) => {
|
||||
if (!artworkId) return
|
||||
@@ -225,7 +465,7 @@ export default function ArtworkComments({
|
||||
setLastPage(data.meta?.last_page ?? 1)
|
||||
setTotal(data.meta?.total ?? 0)
|
||||
} catch {
|
||||
// keep existing data on error
|
||||
// keep existing
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -233,7 +473,6 @@ export default function ArtworkComments({
|
||||
[artworkId],
|
||||
)
|
||||
|
||||
// On mount, load if artworkId provided and no SSR comments given
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
@@ -245,21 +484,95 @@ export default function ArtworkComments({
|
||||
}
|
||||
}, [artworkId, initialComments.length, loadComments])
|
||||
|
||||
// New top-level comment posted
|
||||
const handlePosted = useCallback((newComment) => {
|
||||
setComments((prev) => [newComment, ...prev])
|
||||
// Ensure it has a replies array
|
||||
const comment = { ...newComment, replies: newComment.replies || [] }
|
||||
setComments((prev) => [comment, ...prev])
|
||||
setTotal((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
// Reply posted under a parent comment (works at any nesting depth)
|
||||
const handleReplyPosted = useCallback((parentId, newReply) => {
|
||||
// Recursively find the parent node and append the reply
|
||||
const insertReply = (nodes) =>
|
||||
nodes.map((c) => {
|
||||
if (c.id === parentId) {
|
||||
return { ...c, replies: [...(c.replies || []), { ...newReply, replies: [] }] }
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: insertReply(c.replies) }
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
setComments((prev) => insertReply(prev))
|
||||
setTotal((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments" className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Comments{' '}
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||
Comments
|
||||
</h2>
|
||||
{total > 0 && (
|
||||
<span className="text-neutral-500 font-normal">({total})</span>
|
||||
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Comment form */}
|
||||
{/* Comment list */}
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
|
||||
<ChatBubbleIcon />
|
||||
<p className="text-sm font-medium text-white/40">No comments yet</p>
|
||||
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-3 sm:space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
artworkId={artworkId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={handleReplyPosted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{page < lastPage && (
|
||||
<div className="flex justify-center pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
'Load more comments'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Comment form — after all comments */}
|
||||
{artworkId && (
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
@@ -268,39 +581,6 @@ export default function ArtworkComments({
|
||||
loginUrl={loginUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">No comments yet. Be the first!</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Load more */}
|
||||
{page < lastPage && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="px-5 py-2 rounded-lg text-sm text-white/60 border border-white/[0.08] hover:text-white hover:border-white/20 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more comments'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function renderMarkdownSafe(text) {
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={`p-${lineIndex}`} className="text-base leading-7 text-soft">
|
||||
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
|
||||
{parts}
|
||||
</p>
|
||||
)
|
||||
@@ -60,19 +60,18 @@ export default function ArtworkDescription({ artwork }) {
|
||||
if (content.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Description</h2>
|
||||
<div className="mt-4 max-w-[720px] space-y-4">{rendered}</div>
|
||||
<div>
|
||||
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
|
||||
|
||||
{content.length > COLLAPSE_AT && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 text-sm font-medium text-accent hover:underline"
|
||||
className="mt-3 text-sm font-medium text-accent transition-colors hover:text-accent/80"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
91
resources/js/components/artwork/ArtworkDetailsDrawer.jsx
Normal file
91
resources/js/components/artwork/ArtworkDetailsDrawer.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
|
||||
function formatCount(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${number}`
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
export default function ArtworkDetailsDrawer({ isOpen, onClose, artwork, stats }) {
|
||||
const width = artwork?.dimensions?.width || artwork?.width || 0
|
||||
const height = artwork?.dimensions?.height || artwork?.height || 0
|
||||
|
||||
const fileType = useMemo(() => {
|
||||
const mime = artwork?.file?.mime_type || artwork?.mime_type || ''
|
||||
if (mime) return mime
|
||||
const url = artwork?.file?.url || artwork?.thumbs?.xl?.url || ''
|
||||
const ext = url.split('.').pop()
|
||||
return ext ? ext.toUpperCase() : '—'
|
||||
}, [artwork])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close details"
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 max-h-[90vh] overflow-y-auto rounded-t-3xl border border-white/10 bg-nova-900/85 p-5 backdrop-blur xl:inset-auto xl:right-6 xl:top-24 xl:w-[34rem] xl:rounded-3xl xl:border-white/15 xl:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-white">Details</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close details drawer"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/15 p-4">
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">Resolution</dt>
|
||||
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">Upload date</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatDate(artwork?.published_at)}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">File type</dt>
|
||||
<dd className="mt-1 font-medium text-white">{fileType}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">Views</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats?.views)}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">Downloads</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats?.downloads)}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<dt className="text-soft">Favorites</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats?.favorites)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
resources/js/components/artwork/ArtworkDetailsPanel.jsx
Normal file
84
resources/js/components/artwork/ArtworkDetailsPanel.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
const d = new Date(value)
|
||||
const now = Date.now()
|
||||
const diff = now - d.getTime()
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 30) return `${days} days ago`
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Stat tile shown in the 2-col grid ─────────────────────────────────── */
|
||||
function StatTile({ icon, label, value }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] px-3 py-3.5">
|
||||
<span className="text-white/30">{icon}</span>
|
||||
<span className="text-base font-semibold tabular-nums text-white/90">{value}</span>
|
||||
<span className="text-[11px] uppercase tracking-wider text-white/35">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Key-value row ─────────────────────────────────────────────────────── */
|
||||
function InfoRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs uppercase tracking-wider text-white/35">{label}</span>
|
||||
<span className="text-sm font-medium text-white/80">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkDetailsPanel({ artwork, stats }) {
|
||||
const width = artwork?.dimensions?.width || artwork?.width || 0
|
||||
const height = artwork?.dimensions?.height || artwork?.height || 0
|
||||
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<StatTile
|
||||
icon={
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
}
|
||||
label="Views"
|
||||
value={formatCount(stats?.views)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
}
|
||||
label="Downloads"
|
||||
value={formatCount(stats?.downloads)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info rows */}
|
||||
<div className="mt-4 divide-y divide-white/[0.05]">
|
||||
{resolution && <InfoRow label="Resolution" value={resolution} />}
|
||||
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -18,102 +18,117 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||
|
||||
const width = Number(artwork?.width)
|
||||
const height = Number(artwork?.height)
|
||||
const hasKnownAspect = width > 0 && height > 0
|
||||
const aspectRatio = hasKnownAspect ? `${width} / ${height}` : '16 / 9'
|
||||
|
||||
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
||||
|
||||
return (
|
||||
<figure className="w-full">
|
||||
<div className="relative mx-auto w-full max-w-[1280px]">
|
||||
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
|
||||
{blurBackdropSrc && (
|
||||
<>
|
||||
<img
|
||||
src={blurBackdropSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
|
||||
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Outer flex row: left arrow | image | right arrow */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Prev arrow — outside the picture */}
|
||||
<div className="flex w-12 shrink-0 justify-center">
|
||||
{hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous artwork"
|
||||
onClick={() => onPrev?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{hasRealArtworkImage && (
|
||||
<div className="absolute inset-0 -z-10" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||
onClick={onOpenViewer}
|
||||
role={onOpenViewer ? 'button' : undefined}
|
||||
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
||||
tabIndex={onOpenViewer ? 0 : undefined}
|
||||
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
||||
<div className="relative mx-auto flex w-full max-w-[1400px] items-center gap-2 sm:gap-4">
|
||||
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||
{hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous artwork"
|
||||
onClick={() => onPrev?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<img
|
||||
src={md}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={lg}
|
||||
srcSet={srcSet}
|
||||
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_LG
|
||||
}}
|
||||
/>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden ] ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||
style={{ aspectRatio }}
|
||||
onClick={onOpenViewer}
|
||||
role={onOpenViewer ? 'button' : undefined}
|
||||
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
|
||||
tabIndex={onOpenViewer ? 0 : undefined}
|
||||
onKeyDown={onOpenViewer ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenViewer()
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<img
|
||||
src={md}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="absolute inset-0 h-full w-full object-contain rounded-xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
|
||||
{onOpenViewer && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View fullscreen"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<img
|
||||
src={lg}
|
||||
srcSet={srcSet}
|
||||
sizes="(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw"
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_LG
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next arrow — outside the picture */}
|
||||
<div className="flex w-12 shrink-0 justify-center">
|
||||
{hasNext && (
|
||||
{onOpenViewer && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next artwork"
|
||||
onClick={() => onNext?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
|
||||
aria-label="View fullscreen"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 shadow-lg ring-1 ring-white/15 backdrop-blur-sm opacity-0 transition-opacity duration-150 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:opacity-100 [div:hover_&]:opacity-100"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||
{hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next artwork"
|
||||
onClick={() => onNext?.()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
@@ -2,31 +2,12 @@ import React from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
|
||||
export default function ArtworkMeta({ artwork }) {
|
||||
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
|
||||
const publishedAt = artwork?.published_at
|
||||
? new Date(artwork.published_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: '—'
|
||||
const width = artwork?.dimensions?.width || 0
|
||||
const height = artwork?.dimensions?.height || 0
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5">
|
||||
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt>Author</dt>
|
||||
<dd className="text-white">{author}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt>Upload date</dt>
|
||||
<dd className="text-white">{publishedAt}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2">
|
||||
<dt>Resolution</dt>
|
||||
<dd className="text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
|
||||
<div className="mt-3">
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,13 +29,14 @@ export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-white/30">Reactions</h2>
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artworkId}
|
||||
initialTotals={totals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
/* ── normalizers ─────────────────────────────────────────────────── */
|
||||
|
||||
function normalizeRelated(item) {
|
||||
if (!item?.url) return null
|
||||
return {
|
||||
id: item.id || item.slug || item.url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item.author || 'Artist',
|
||||
authorAvatar: item.author_avatar || null,
|
||||
url: item.url,
|
||||
thumb: item.thumb || null,
|
||||
thumbSrcSet: item.thumb_srcset || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSimilar(item) {
|
||||
if (!item?.url) return null
|
||||
return {
|
||||
id: item.id || item.slug || item.url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item.author || 'Artist',
|
||||
authorAvatar: item.author_avatar || null,
|
||||
url: item.url,
|
||||
thumb: item.thumb || null,
|
||||
thumbSrcSet: item.thumb_srcset || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRankItem(item) {
|
||||
const url = item?.urls?.direct || item?.urls?.web || item?.url || null
|
||||
if (!url) return null
|
||||
return {
|
||||
id: item.id || item.slug || url,
|
||||
title: item.title || 'Untitled',
|
||||
author: item?.author?.name || 'Artist',
|
||||
authorAvatar: item?.author?.avatar_url || null,
|
||||
url,
|
||||
thumb: item.thumbnail_url || item.thumb || null,
|
||||
thumbSrcSet: null,
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeByUrl(items) {
|
||||
const seen = new Set()
|
||||
return items.filter((item) => {
|
||||
if (!item?.url || seen.has(item.url)) return false
|
||||
seen.add(item.url)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Large art card (matches homepage style) ─────────────────── */
|
||||
|
||||
function RailCard({ item }) {
|
||||
return (
|
||||
<article className="w-[240px] shrink-0 snap-start sm:w-[220px] lg:w-[200px] xl:w-[210px] 2xl:w-[220px]">
|
||||
<a
|
||||
href={item.url}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
|
||||
{/* Gloss sheen */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="220px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
{/* Bottom info overlay */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img
|
||||
src={item.authorAvatar || AVATAR_FALLBACK}
|
||||
alt={item.author}
|
||||
className="w-5 h-5 rounded-full object-cover shrink-0 ring-1 ring-white/20"
|
||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||
/>
|
||||
<span className="truncate">{item.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only">{item.title} by {item.author}</span>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Scroll arrow button ─────────────────────────────────────── */
|
||||
|
||||
function ScrollBtn({ direction, onClick, visible }) {
|
||||
if (!visible) return null
|
||||
const isLeft = direction === 'left'
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
className={`absolute top-1/2 z-30 -translate-y-1/2 hidden lg:flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/80 ${isLeft ? 'left-2' : 'right-2'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{isLeft
|
||||
? <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Rail section (infinite loop + mouse-wheel scroll) ───────── */
|
||||
|
||||
function Rail({ title, emoji, items, seeAllHref }) {
|
||||
const scrollRef = useRef(null)
|
||||
const isResettingRef = useRef(false)
|
||||
const scrollEndTimer = useRef(null)
|
||||
const itemCount = items.length
|
||||
|
||||
/* Triple items so we can loop seamlessly: [clone|original|clone] */
|
||||
const loopItems = useMemo(() => {
|
||||
if (!items.length) return []
|
||||
return [...items, ...items, ...items]
|
||||
}, [items])
|
||||
|
||||
/* Pixel width of one item-set (measured from the DOM) */
|
||||
const getSetWidth = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || el.children.length < itemCount + 1) return 0
|
||||
return el.children[itemCount].offsetLeft - el.children[0].offsetLeft
|
||||
}, [itemCount])
|
||||
|
||||
/* Centre on the middle (real) set after mount / data change */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !itemCount) return
|
||||
requestAnimationFrame(() => {
|
||||
const sw = getSetWidth()
|
||||
if (sw) {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = sw
|
||||
el.style.scrollBehavior = ''
|
||||
}
|
||||
})
|
||||
}, [loopItems, getSetWidth, itemCount])
|
||||
|
||||
/* After scroll settles, silently jump back to the middle set if in a clone zone */
|
||||
const resetIfNeeded = useCallback(() => {
|
||||
if (isResettingRef.current) return
|
||||
const el = scrollRef.current
|
||||
if (!el || !itemCount) return
|
||||
const setW = getSetWidth()
|
||||
if (setW === 0) return
|
||||
|
||||
if (el.scrollLeft < setW) {
|
||||
isResettingRef.current = true
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft += setW
|
||||
el.style.scrollBehavior = ''
|
||||
requestAnimationFrame(() => { isResettingRef.current = false })
|
||||
} else if (el.scrollLeft >= setW * 2) {
|
||||
isResettingRef.current = true
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft -= setW
|
||||
el.style.scrollBehavior = ''
|
||||
requestAnimationFrame(() => { isResettingRef.current = false })
|
||||
}
|
||||
}, [getSetWidth, itemCount])
|
||||
|
||||
/* Scroll listener: debounced boundary check + resize re-centre */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const onScroll = () => {
|
||||
clearTimeout(scrollEndTimer.current)
|
||||
scrollEndTimer.current = setTimeout(resetIfNeeded, 80)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
const onResize = () => {
|
||||
const sw = getSetWidth()
|
||||
if (sw) {
|
||||
el.style.scrollBehavior = 'auto'
|
||||
el.scrollLeft = sw
|
||||
el.style.scrollBehavior = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
clearTimeout(scrollEndTimer.current)
|
||||
}
|
||||
}, [loopItems, resetIfNeeded, getSetWidth])
|
||||
|
||||
/* Mouse-wheel → horizontal scroll (re-attach when items arrive) */
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !loopItems.length) return
|
||||
const onWheel = (e) => {
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
e.preventDefault()
|
||||
el.scrollLeft += e.deltaY
|
||||
}
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [loopItems])
|
||||
|
||||
const scroll = useCallback((dir) => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const amount = el.clientWidth * 0.75
|
||||
el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-5 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{emoji && <span className="mr-1.5">{emoji}</span>}{title}
|
||||
</h2>
|
||||
{seeAllHref && (
|
||||
<a href={seeAllHref} className="text-sm text-nova-300 hover:text-white transition">
|
||||
See all →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Permanent edge fades for infinite illusion */}
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 w-24 bg-gradient-to-r from-[#0F1724] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-20 w-24 bg-gradient-to-l from-[#0F1724] to-transparent" />
|
||||
|
||||
<ScrollBtn direction="left" onClick={() => scroll('left')} visible={true} />
|
||||
<ScrollBtn direction="right" onClick={() => scroll('right')} visible={true} />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{loopItems.map((item, idx) => (
|
||||
<RailCard key={`${item.id || item.url}-${idx}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main export ─────────────────────────────────────────────── */
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const [similarApiItems, setSimilarApiItems] = useState([])
|
||||
const [similarLoaded, setSimilarLoaded] = useState(false)
|
||||
const [trendingItems, setTrendingItems] = useState([])
|
||||
|
||||
const relatedCards = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
|
||||
}, [related])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadSimilar = async () => {
|
||||
if (!artwork?.id) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems(items)
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSimilar()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadTrending = async () => {
|
||||
const categoryId = artwork?.categories?.[0]?.id
|
||||
if (!categoryId) {
|
||||
setTrendingItems([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('trending fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
|
||||
if (!isCancelled) setTrendingItems(items)
|
||||
} catch {
|
||||
if (!isCancelled) setTrendingItems([])
|
||||
}
|
||||
}
|
||||
|
||||
loadTrending()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.categories])
|
||||
|
||||
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
|
||||
|
||||
const tagBasedFallback = useMemo(() => {
|
||||
return relatedCards.filter((item) => String(item.author || '').trim().toLowerCase() !== authorName)
|
||||
}, [relatedCards, authorName])
|
||||
|
||||
const similarItems = useMemo(() => {
|
||||
if (!similarLoaded) return []
|
||||
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
|
||||
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
|
||||
return trendingItems.slice(0, 12)
|
||||
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
|
||||
|
||||
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
|
||||
|
||||
if (similarItems.length === 0 && trendingRailItems.length === 0) return null
|
||||
|
||||
const categoryName = artwork?.categories?.[0]?.name
|
||||
const trendingLabel = categoryName
|
||||
? `Trending in ${categoryName}`
|
||||
: 'Trending'
|
||||
|
||||
const trendingHref = categoryName
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<Rail title="Similar Artworks" emoji="✨" items={similarItems} />
|
||||
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,19 +4,52 @@ export default function ArtworkTags({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const categories = (artwork?.categories || []).map((category) => ({
|
||||
key: `cat-${category.id || category.slug}`,
|
||||
label: category.name,
|
||||
href: category.url || `/${category.content_type_slug}/${category.slug}`,
|
||||
}))
|
||||
const seen = new Set()
|
||||
const contentTypeSeen = new Set()
|
||||
const categoryPills = []
|
||||
|
||||
// Add content types (e.g. "Wallpapers") first, then categories, then tags
|
||||
for (const category of artwork?.categories || []) {
|
||||
const ctSlug = category.content_type_slug
|
||||
if (ctSlug && !contentTypeSeen.has(ctSlug)) {
|
||||
contentTypeSeen.add(ctSlug)
|
||||
const ctName = ctSlug.charAt(0).toUpperCase() + ctSlug.slice(1)
|
||||
categoryPills.push({
|
||||
key: `ct-${ctSlug}`,
|
||||
label: ctName,
|
||||
href: `/${ctSlug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (category.parent && !seen.has(category.parent.id)) {
|
||||
seen.add(category.parent.id)
|
||||
categoryPills.push({
|
||||
key: `cat-${category.parent.id}`,
|
||||
label: category.parent.name,
|
||||
href: category.parent.url || `/${category.parent.content_type_slug}/${category.parent.slug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
if (!seen.has(category.id)) {
|
||||
seen.add(category.id)
|
||||
categoryPills.push({
|
||||
key: `cat-${category.id}`,
|
||||
label: category.name,
|
||||
href: category.url || `/${category.content_type_slug}/${category.slug}`,
|
||||
isCategory: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||
key: `tag-${tag.id || tag.slug}`,
|
||||
label: tag.name,
|
||||
href: `/tag/${tag.slug || ''}`,
|
||||
isCategory: false,
|
||||
}))
|
||||
|
||||
return [...categories, ...artworkTags]
|
||||
return [...categoryPills, ...artworkTags]
|
||||
}, [artwork])
|
||||
|
||||
if (tags.length === 0) return null
|
||||
@@ -24,31 +57,34 @@ export default function ArtworkTags({ artwork }) {
|
||||
const visible = expanded ? tags : tags.slice(0, 12)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Tags & Categories</h2>
|
||||
{tags.length > 12 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : `Show all (${tags.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{visible.map((tag) => (
|
||||
<div>
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-accent/70">Tags & Categories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((tag, idx) => (
|
||||
<a
|
||||
key={tag.key}
|
||||
href={tag.href}
|
||||
className="inline-flex items-center rounded-full border border-nova-600 bg-nova-900/30 px-3 py-1 text-xs text-white hover:border-accent hover:text-accent"
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200',
|
||||
tag.isCategory
|
||||
? 'border-accent/30 bg-accent/10 text-accent hover:border-accent/50 hover:bg-accent/20'
|
||||
: 'border-white/[0.08] bg-white/[0.03] text-white/60 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{tags.length > 12 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-dashed border-white/[0.1] px-3 py-1.5 text-xs text-white/40 transition hover:border-white/20 hover:text-white/60"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : `+${tags.length - 12} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
165
resources/js/components/artwork/CreatorSpotlight.jsx
Normal file
165
resources/js/components/artwork/CreatorSpotlight.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
function toCard(item) {
|
||||
return {
|
||||
id: item?.id || item?.slug || item?.url,
|
||||
title: item?.title,
|
||||
author: item?.author,
|
||||
url: item?.url,
|
||||
thumb: item?.thumb,
|
||||
thumbSrcSet: item?.thumb_srcset,
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
|
||||
const user = artwork?.user || {}
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const creatorItems = useMemo(() => {
|
||||
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
|
||||
const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase()
|
||||
const notCurrent = item?.url && item.url !== artwork?.canonical_url
|
||||
return sameAuthor && notCurrent
|
||||
})
|
||||
|
||||
const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : [])
|
||||
return source.slice(0, 12).map(toCard)
|
||||
}, [related, authorName, artwork?.canonical_url])
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
const nextState = !following
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow failed')
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.followers_count === 'number') {
|
||||
setFollowersCount(payload.followers_count)
|
||||
}
|
||||
setFollowing(Boolean(payload?.is_following))
|
||||
} catch {
|
||||
setFollowing(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Follow + Profile buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Follow
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,181 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
|
||||
/**
|
||||
* Comment form with emoji picker and Markdown-lite support.
|
||||
*
|
||||
* Props:
|
||||
* artworkId number Target artwork
|
||||
* onPosted (comment) => void Called when comment is successfully posted
|
||||
* isLoggedIn boolean
|
||||
* loginUrl string Where to redirect non-authenticated users
|
||||
*/
|
||||
/* ── Toolbar icon components ──────────────────────────────────────────────── */
|
||||
function BoldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ItalicIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Toolbar button wrapper ───────────────────────────────────────────────── */
|
||||
function ToolbarBtn({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(e) => { e.preventDefault(); onClick() }}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main component ───────────────────────────────────────────────────────── */
|
||||
export default function CommentForm({
|
||||
artworkId,
|
||||
onPosted,
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
parentId = null,
|
||||
replyTo = null,
|
||||
onCancelReply = null,
|
||||
compact = false,
|
||||
}) {
|
||||
const [content, setContent] = useState('')
|
||||
const [tab, setTab] = useState('write') // 'write' | 'preview'
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const textareaRef = useRef(null)
|
||||
const formRef = useRef(null)
|
||||
|
||||
// Insert text at current cursor position
|
||||
// Auto-focus when entering reply mode
|
||||
useEffect(() => {
|
||||
if (replyTo && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}, [replyTo])
|
||||
|
||||
/* ── Helpers to wrap selected text ────────────────────────────────────── */
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = selected
|
||||
? start + replacement.length
|
||||
: start + before.length
|
||||
const cursorEnd = selected
|
||||
? start + replacement.length
|
||||
: start + before.length + 4
|
||||
el.selectionStart = cursorPos
|
||||
el.selectionEnd = cursorEnd
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const lines = selected ? selected.split('\n') : ['']
|
||||
const prefixed = lines.map(l => prefix + l).join('\n')
|
||||
|
||||
const next = content.slice(0, start) + prefixed + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start
|
||||
el.selectionEnd = start + prefixed.length
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const isUrl = /^https?:\/\//.test(selected)
|
||||
const replacement = isUrl
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (isUrl) {
|
||||
el.selectionStart = start + 1
|
||||
el.selectionEnd = start + 5
|
||||
} else {
|
||||
const urlStart = start + replacement.length - 1
|
||||
el.selectionStart = urlStart - 8
|
||||
el.selectionEnd = urlStart - 1
|
||||
}
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
// Insert text at cursor (for emoji picker)
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
@@ -32,11 +185,9 @@ export default function CommentForm({
|
||||
|
||||
const start = el.selectionStart ?? content.length
|
||||
const end = el.selectionEnd ?? content.length
|
||||
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
// Restore cursor after the inserted text
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start + text.length
|
||||
el.selectionEnd = start + text.length
|
||||
@@ -48,6 +199,34 @@ export default function CommentForm({
|
||||
insertAtCursor(emoji)
|
||||
}, [insertAtCursor])
|
||||
|
||||
/* ── Keyboard shortcuts ───────────────────────────────────────────────── */
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
if (!mod) return
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
e.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
e.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [wrapSelection, insertLink])
|
||||
|
||||
/* ── Submit ───────────────────────────────────────────────────────────── */
|
||||
const handleSubmit = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault()
|
||||
@@ -66,14 +245,18 @@ export default function CommentForm({
|
||||
try {
|
||||
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
||||
content: trimmed,
|
||||
parent_id: parentId || null,
|
||||
})
|
||||
|
||||
setContent('')
|
||||
setTab('write')
|
||||
onPosted?.(data.data)
|
||||
onCancelReply?.()
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422) {
|
||||
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
|
||||
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
|
||||
const fieldErrors = err.response.data?.errors ?? {}
|
||||
const allErrors = Object.values(fieldErrors).flat()
|
||||
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
|
||||
} else {
|
||||
setErrors(['Something went wrong. Please try again.'])
|
||||
}
|
||||
@@ -81,62 +264,174 @@ export default function CommentForm({
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted],
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
|
||||
)
|
||||
|
||||
/* ── Logged-out state ─────────────────────────────────────────────────── */
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
|
||||
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
|
||||
Sign in
|
||||
</a>{' '}
|
||||
to leave a comment.
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4 backdrop-blur-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 shrink-0 text-white/25">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
<p className="text-sm text-white/40">
|
||||
<a href={loginUrl} className="font-medium text-accent transition-colors hover:text-accent/80">
|
||||
Sign in
|
||||
</a>{' '}
|
||||
to join the conversation.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Editor ───────────────────────────────────────────────────────────── */
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
{/* Textarea */}
|
||||
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
disabled={submitting}
|
||||
aria-label="Comment text"
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* Toolbar at bottom-right of textarea */}
|
||||
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
'text-xs tabular-nums transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
|
||||
].join(' ')}
|
||||
aria-live="polite"
|
||||
>
|
||||
{content.length}/10 000
|
||||
<form id={parentId ? `reply-form-${parentId}` : 'comment-form'} ref={formRef} onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Reply indicator */}
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-accent/[0.06] px-3 py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5 text-accent/60 shrink-0">
|
||||
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-xs text-white/50">
|
||||
Replying to <span className="font-semibold text-white/70">{replyTo}</span>
|
||||
</span>
|
||||
|
||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="ml-auto text-[11px] font-medium text-white/30 transition-colors hover:text-white/60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown hint */}
|
||||
<p className="text-xs text-white/25 px-1">
|
||||
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
|
||||
</p>
|
||||
<div className={`rounded-2xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
|
||||
|
||||
{/* ── Top bar: tabs + emoji ─────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'write'
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'preview'
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={[
|
||||
'text-[11px] tabular-nums font-medium transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
|
||||
].join(' ')}
|
||||
>
|
||||
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
|
||||
</span>
|
||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Formatting toolbar (write mode only) ──────────────────────── */}
|
||||
{tab === 'write' && (
|
||||
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
|
||||
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
|
||||
<BoldIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
|
||||
<ItalicIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
|
||||
<CodeIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
|
||||
<LinkIcon />
|
||||
</ToolbarBtn>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
|
||||
|
||||
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
|
||||
<ListIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
|
||||
<QuoteIcon />
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Write tab ─────────────────────────────────────────────────── */}
|
||||
{tab === 'write' && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={replyTo ? `Reply to ${replyTo}…` : 'Share your thoughts…'}
|
||||
rows={compact ? 2 : 4}
|
||||
maxLength={10000}
|
||||
disabled={submitting}
|
||||
aria-label="Comment text"
|
||||
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Preview tab ───────────────────────────────────────────────── */}
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[7rem] px-4 py-3">
|
||||
{content.trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/25 italic">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bottom hint ───────────────────────────────────────────────── */}
|
||||
{tab === 'write' && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-[11px] text-white/15">
|
||||
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-1" role="alert">
|
||||
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i} className="text-xs text-red-400 px-1">
|
||||
<li key={i} className="text-xs font-medium text-red-400">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
@@ -148,9 +443,19 @@ export default function CommentForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !content.trim()}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
||||
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
|
||||
>
|
||||
{submitting ? 'Posting…' : 'Post comment'}
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Posting…
|
||||
</span>
|
||||
) : (
|
||||
'Post comment'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -131,7 +131,7 @@ function CommentItem({ comment }) {
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
|
||||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -1,42 +1,80 @@
|
||||
import React, { useCallback, useOptimistic, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/* ── Reaction definitions ────────────────────────────────────────────────── */
|
||||
const REACTIONS = [
|
||||
{ slug: 'thumbs_up', emoji: '👍', label: 'Like' },
|
||||
{ slug: 'heart', emoji: '❤️', label: 'Love' },
|
||||
{ slug: 'fire', emoji: '🔥', label: 'Fire' },
|
||||
{ slug: 'laugh', emoji: '😂', label: 'Haha' },
|
||||
{ slug: 'clap', emoji: '👏', label: 'Clap' },
|
||||
{ slug: 'wow', emoji: '😮', label: 'Wow' },
|
||||
]
|
||||
|
||||
/* ── Small heart outline icon for the trigger ─────────────────────────────── */
|
||||
function HeartOutlineIcon({ className }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction bar for an artwork or comment.
|
||||
* Facebook-style reaction bar.
|
||||
*
|
||||
* - Compact trigger button (heart icon or the user's reaction)
|
||||
* - Floating picker that appears on hover/click with scale animation
|
||||
* - Summary row showing unique reaction emoji + total count
|
||||
*
|
||||
* Props:
|
||||
* entityType 'artwork' | 'comment'
|
||||
* entityId number
|
||||
* initialTotals Record<slug, { emoji, label, count, mine }>
|
||||
* isLoggedIn boolean — if false, clicking shows a prompt
|
||||
* entityType 'artwork' | 'comment'
|
||||
* entityId number
|
||||
* initialTotals Record<slug, { emoji, label, count, mine }>
|
||||
* isLoggedIn boolean
|
||||
*/
|
||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
||||
const [totals, setTotals] = useState(initialTotals)
|
||||
const [loading, setLoading] = useState(null) // slug being toggled
|
||||
const [loading, setLoading] = useState(null)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const containerRef = useRef(null)
|
||||
const hoverTimeout = useRef(null)
|
||||
|
||||
const endpoint =
|
||||
entityType === 'artwork'
|
||||
? `/api/artworks/${entityId}/reactions`
|
||||
: `/api/comments/${entityId}/reactions`
|
||||
|
||||
// Close picker when clicking outside
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
const handler = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setPickerOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [pickerOpen])
|
||||
|
||||
const toggle = useCallback(
|
||||
async (slug) => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) return // prevent double-click
|
||||
if (loading) return
|
||||
setLoading(slug)
|
||||
setPickerOpen(false)
|
||||
|
||||
// Optimistic update
|
||||
setTotals((prev) => {
|
||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||
const entry = prev[slug] ?? { count: 0, mine: false, emoji: REACTIONS.find(r => r.slug === slug)?.emoji, label: REACTIONS.find(r => r.slug === slug)?.label }
|
||||
return {
|
||||
...prev,
|
||||
[slug]: {
|
||||
...entry,
|
||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
||||
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
@@ -46,14 +84,13 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
const { data } = await axios.post(endpoint, { reaction: slug })
|
||||
setTotals(data.totals)
|
||||
} catch {
|
||||
// Rollback
|
||||
setTotals((prev) => {
|
||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||
return {
|
||||
...prev,
|
||||
[slug]: {
|
||||
...entry,
|
||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
||||
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
@@ -65,46 +102,127 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
[endpoint, isLoggedIn, loading],
|
||||
)
|
||||
|
||||
// Compute summary data
|
||||
const entries = Object.entries(totals)
|
||||
const activeReactions = entries.filter(([, info]) => info.count > 0)
|
||||
const totalCount = activeReactions.reduce((sum, [, info]) => sum + info.count, 0)
|
||||
const myReaction = entries.find(([, info]) => info.mine)?.[0] ?? null
|
||||
const myReactionData = myReaction ? REACTIONS.find(r => r.slug === myReaction) : null
|
||||
|
||||
if (entries.length === 0) return null
|
||||
// Hover handlers for desktop — open on hover with a small delay
|
||||
const onMouseEnter = () => {
|
||||
clearTimeout(hoverTimeout.current)
|
||||
hoverTimeout.current = setTimeout(() => setPickerOpen(true), 200)
|
||||
}
|
||||
const onMouseLeave = () => {
|
||||
clearTimeout(hoverTimeout.current)
|
||||
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Reactions"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-2"
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{entries.map(([slug, info]) => {
|
||||
const { emoji, label, count, mine } = info
|
||||
const isProcessing = loading === slug
|
||||
{/* ── Trigger button ──────────────────────────────────────────── */}
|
||||
<div className="relative" onMouseEnter={onMouseEnter}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (myReaction) {
|
||||
// Quick-toggle: remove own reaction
|
||||
toggle(myReaction)
|
||||
} else {
|
||||
// Quick-like with thumbs_up
|
||||
toggle('thumbs_up')
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
myReaction
|
||||
? 'text-accent'
|
||||
: 'text-white/40 hover:text-white/70',
|
||||
].join(' ')}
|
||||
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
|
||||
>
|
||||
{myReaction ? (
|
||||
<span className="text-base leading-none">{myReactionData?.emoji}</span>
|
||||
) : (
|
||||
<HeartOutlineIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{myReaction ? myReactionData?.label : 'React'}</span>
|
||||
</button>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slug}
|
||||
type="button"
|
||||
disabled={isProcessing}
|
||||
onClick={() => toggle(slug)}
|
||||
aria-label={`${label} — ${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
|
||||
aria-pressed={mine}
|
||||
className={[
|
||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
|
||||
'border transition-all duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
mine
|
||||
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
|
||||
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{/* ── Floating picker ─────────────────────────────────────── */}
|
||||
{pickerOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 z-[200] animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
onMouseEnter={() => { clearTimeout(hoverTimeout.current) }}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<span aria-hidden="true">{emoji}</span>
|
||||
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center gap-0.5 rounded-full bg-nova-800/95 border border-white/[0.1] px-2 py-1.5 shadow-xl shadow-black/40 backdrop-blur-xl">
|
||||
{REACTIONS.map((r, i) => {
|
||||
const isActive = totals[r.slug]?.mine
|
||||
return (
|
||||
<button
|
||||
key={r.slug}
|
||||
type="button"
|
||||
onClick={() => toggle(r.slug)}
|
||||
disabled={loading === r.slug}
|
||||
aria-label={`${r.label}${isActive ? ' (selected)' : ''}`}
|
||||
className={[
|
||||
'group/reaction relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200',
|
||||
'hover:bg-white/[0.08] hover:scale-125 hover:-translate-y-1',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
'disabled:opacity-50',
|
||||
isActive ? 'bg-white/[0.1] scale-110' : '',
|
||||
].join(' ')}
|
||||
style={{ animationDelay: `${i * 30}ms` }}
|
||||
title={r.label}
|
||||
>
|
||||
<span className="text-xl leading-none transition-transform duration-150 group-hover/reaction:scale-110">
|
||||
{r.emoji}
|
||||
</span>
|
||||
{/* Tooltip */}
|
||||
<span className="pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 rounded bg-black/80 px-1.5 py-0.5 text-[10px] font-medium text-white/90 opacity-0 transition-opacity group-hover/reaction:opacity-100 whitespace-nowrap">
|
||||
{r.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Summary: stacked emoji + count ───────────────────────── */}
|
||||
{totalCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
|
||||
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{/* Stacked emoji circles (Facebook-style, max 3) */}
|
||||
<span className="inline-flex items-center -space-x-1">
|
||||
{activeReactions.slice(0, 3).map(([slug, info], i) => (
|
||||
<span
|
||||
key={slug}
|
||||
className="relative flex items-center justify-center w-5 h-5 rounded-full bg-nova-700 border border-nova-800 text-xs leading-none"
|
||||
style={{ zIndex: 3 - i }}
|
||||
title={info.label}
|
||||
>
|
||||
{info.emoji}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums text-white/50 group-hover/summary:text-white/70 transition-colors">
|
||||
{totalCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
|
||||
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
|
||||
const cdnBase = 'https://files.skinbase.org';
|
||||
const avatarSrc = art.avatar_url || `${cdnBase}/avatars/default.webp`;
|
||||
const avatarSrc = art.avatar_url || `${cdnBase}/default/avatar_default.webp`;
|
||||
|
||||
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||
|
||||
Reference in New Issue
Block a user