fixed gallery
This commit is contained in:
136
resources/js/components/artwork/ArtworkActions.jsx
Normal file
136
resources/js/components/artwork/ArtworkActions.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
|
||||
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const downloadUrl = 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
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Request failed')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const onToggleLike = async () => {
|
||||
const nextState = !liked
|
||||
setLiked(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
|
||||
} catch {
|
||||
setLiked(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
|
||||
} catch {
|
||||
setFavorited(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: artwork?.title || 'Artwork',
|
||||
url: shareUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const onReport = async () => {
|
||||
if (reporting) return
|
||||
setReporting(true)
|
||||
try {
|
||||
await postInteraction(`/api/artworks/${artwork.id}/report`, {
|
||||
reason: 'Reported from artwork page',
|
||||
})
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
setReporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Actions</h2>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
|
||||
download
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
|
||||
onClick={onToggleLike}
|
||||
>
|
||||
{liked ? 'Liked' : 'Like'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
|
||||
onClick={onToggleFavorite}
|
||||
>
|
||||
{favorited ? 'Saved' : 'Favorite'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
|
||||
onClick={onShare}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
|
||||
onClick={onReport}
|
||||
>
|
||||
{reporting ? 'Reporting…' : 'Report'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobilePriority && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 border-t border-nova-700 bg-panel/95 p-3 backdrop-blur lg:hidden">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
resources/js/components/artwork/ArtworkAuthor.jsx
Normal file
83
resources/js/components/artwork/ArtworkAuthor.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
const user = artwork?.user || {}
|
||||
const 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 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-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800"
|
||||
>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center justify-center rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
|
||||
onClick={onToggleFollow}
|
||||
>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
75
resources/js/components/artwork/ArtworkDescription.jsx
Normal file
75
resources/js/components/artwork/ArtworkDescription.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
const COLLAPSE_AT = 560
|
||||
|
||||
function renderMarkdownSafe(text) {
|
||||
const lines = text.split(/\n{2,}/)
|
||||
|
||||
return lines.map((line, lineIndex) => {
|
||||
const parts = []
|
||||
let rest = line
|
||||
let key = 0
|
||||
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
|
||||
|
||||
let match = linkPattern.exec(rest)
|
||||
let lastIndex = 0
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<a
|
||||
key={`lnk-${lineIndex}-${key++}`}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{match[1]}
|
||||
</a>,
|
||||
)
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
match = linkPattern.exec(rest)
|
||||
}
|
||||
|
||||
if (lastIndex < rest.length) {
|
||||
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={`p-${lineIndex}`} className="text-base leading-7 text-soft">
|
||||
{parts}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default function ArtworkDescription({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
|
||||
if (content.length === 0) return null
|
||||
|
||||
const collapsed = content.length > COLLAPSE_AT && !expanded
|
||||
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}…` : content
|
||||
const rendered = useMemo(() => renderMarkdownSafe(visibleText), [visibleText])
|
||||
|
||||
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>
|
||||
|
||||
{content.length > COLLAPSE_AT && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 text-sm font-medium text-accent hover:underline"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
73
resources/js/components/artwork/ArtworkHero.jsx
Normal file
73
resources/js/components/artwork/ArtworkHero.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
|
||||
const xlSource = presentXl?.url || artwork?.thumbs?.xl?.url || null
|
||||
|
||||
const md = mdSource || FALLBACK_MD
|
||||
const lg = lgSource || FALLBACK_LG
|
||||
const xl = xlSource || FALLBACK_XL
|
||||
|
||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||
|
||||
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
||||
|
||||
return (
|
||||
<figure className="w-full">
|
||||
<div className="relative mx-auto w-full max-w-[1280px]">
|
||||
{blurBackdropSrc && (
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 scale-105 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src={blurBackdropSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover opacity-35 blur-2xl"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-b from-nova-700/20 via-nova-900/15 to-deep/40" />
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-deep shadow-2xl ring-1 ring-nova-600/30">
|
||||
<img
|
||||
src={md}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/artwork/ArtworkMeta.jsx
Normal file
30
resources/js/components/artwork/ArtworkMeta.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
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>
|
||||
<dl className="mt-4 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>
|
||||
)
|
||||
}
|
||||
44
resources/js/components/artwork/ArtworkRelated.jsx
Normal file
44
resources/js/components/artwork/ArtworkRelated.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
|
||||
export default function ArtworkRelated({ related }) {
|
||||
if (!Array.isArray(related) || related.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg font-semibold text-white">Related Artworks</h2>
|
||||
|
||||
<div className="mt-5 flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 lg:grid lg:grid-cols-4 lg:gap-5 lg:overflow-visible">
|
||||
{related.slice(0, 12).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="group min-w-[75%] snap-start overflow-hidden rounded-xl border border-nova-700 bg-panel transition lg:min-w-0 lg:hover:border-nova-500"
|
||||
>
|
||||
<a href={item.url} className="block">
|
||||
<div className="relative aspect-video bg-deep">
|
||||
<img
|
||||
src={item.thumb || FALLBACK_MD}
|
||||
srcSet={item.thumb_srcset || undefined}
|
||||
sizes="(min-width: 1024px) 25vw, 75vw"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 lg:group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_MD
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-deep/25 to-transparent lg:opacity-0 lg:transition lg:duration-300 lg:group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="truncate text-sm font-semibold text-white">{item.title}</h3>
|
||||
<p className="truncate text-xs text-soft">by {item.author || 'Artist'}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
42
resources/js/components/artwork/ArtworkStats.jsx
Normal file
42
resources/js/components/artwork/ArtworkStats.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatCount(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return `${number}`
|
||||
}
|
||||
|
||||
export default function ArtworkStats({ artwork }) {
|
||||
const stats = artwork?.stats || {}
|
||||
const width = artwork?.dimensions?.width || 0
|
||||
const height = artwork?.dimensions?.height || 0
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Statistics</h2>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt className="text-soft">👁 Views</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.views)} views</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt className="text-soft">⬇️ Downloads</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.downloads)} downloads</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
|
||||
<dt className="text-soft">❤️ Likes</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.likes)} likes</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
|
||||
<dt className="text-soft">⭐ Favorites</dt>
|
||||
<dd className="mt-1 font-medium text-white">{formatCount(stats.favorites)} favorites</dd>
|
||||
</div>
|
||||
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
|
||||
<dt className="text-soft">Resolution</dt>
|
||||
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
58
resources/js/components/artwork/ArtworkTags.jsx
Normal file
58
resources/js/components/artwork/ArtworkTags.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
export default function ArtworkTags({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const primaryCategorySlug = artwork?.categories?.[0]?.slug || 'all'
|
||||
|
||||
const categories = (artwork?.categories || []).map((category) => ({
|
||||
key: `cat-${category.id || category.slug}`,
|
||||
label: category.name,
|
||||
href: category.content_type_slug && category.slug
|
||||
? `/browse/${category.content_type_slug}/${category.slug}`
|
||||
: `/browse/${category.slug || ''}`,
|
||||
}))
|
||||
|
||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||
key: `tag-${tag.id || tag.slug}`,
|
||||
label: tag.name,
|
||||
href: `/browse/${primaryCategorySlug}/${tag.slug || ''}`,
|
||||
}))
|
||||
|
||||
return [...categories, ...artworkTags]
|
||||
}, [artwork])
|
||||
|
||||
if (tags.length === 0) return null
|
||||
|
||||
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) => (
|
||||
<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"
|
||||
>
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user