fixed gallery

This commit is contained in:
2026-02-22 17:09:34 +01:00
parent 48e2055b6a
commit 5c97488e80
33 changed files with 2062 additions and 550 deletions

View File

@@ -0,0 +1,69 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import ArtworkHero from '../components/artwork/ArtworkHero'
import ArtworkMeta from '../components/artwork/ArtworkMeta'
import ArtworkActions from '../components/artwork/ArtworkActions'
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'
function ArtworkPage({ artwork, related, presentMd, presentLg, presentXl, presentSq, canonicalUrl }) {
if (!artwork) return null
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} />
<div className="mt-6 lg:hidden">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
</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} />
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkStats artwork={artwork} />
<ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} />
</div>
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
</div>
</aside>
</div>
<ArtworkRelated related={related} />
</main>
)
}
// Auto-mount if the Blade view provided data attributes
const el = document.getElementById('artwork-page')
if (el) {
const parse = (key, fallback = null) => {
try {
return JSON.parse(el.dataset[key] || 'null') ?? fallback
} catch {
return fallback
}
}
const root = createRoot(el)
root.render(
<ArtworkPage
artwork={parse('artwork')}
related={parse('related', [])}
presentMd={parse('presentMd')}
presentLg={parse('presentLg')}
presentXl={parse('presentXl')}
presentSq={parse('presentSq')}
canonicalUrl={parse('canonical', '')}
/>,
)
}
export default ArtworkPage

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,42 @@
import React from 'react'
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${number}`
}
export default function ArtworkStats({ artwork }) {
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>
)
}

View 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>
)
}