fixed gallery
This commit is contained in:
@@ -147,3 +147,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 8 — Virtualization hints (applied via JS IntersectionObserver) */
|
||||
.nova-card[data-virtual-hidden] {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 320px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nova-card[data-virtual-visible] {
|
||||
content-visibility: visible;
|
||||
contain-intrinsic-size: none;
|
||||
pointer-events: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
69
resources/js/Pages/ArtworkPage.jsx
Normal file
69
resources/js/Pages/ArtworkPage.jsx
Normal 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
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +1,96 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use App\Banner;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
@push('head')
|
||||
<title>{{ $meta['title'] }}</title>
|
||||
<meta name="description" content="{{ $meta['description'] }}">
|
||||
<link rel="canonical" href="{{ $meta['canonical'] }}">
|
||||
|
||||
// Determine a sensible category/context for this artwork so the
|
||||
// legacy layout (sidebar + hero) can be rendered similarly to
|
||||
// category listing pages.
|
||||
$category = $artwork->categories->first() ?? null;
|
||||
$contentType = $category ? $category->contentType : null;
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:site_name" content="Skinbase">
|
||||
<meta property="og:title" content="{{ $meta['title'] }}">
|
||||
<meta property="og:description" content="{{ $meta['description'] }}">
|
||||
<meta property="og:url" content="{{ $meta['canonical'] }}">
|
||||
@if(!empty($meta['og_image']))
|
||||
<meta property="og:image" content="{{ $meta['og_image'] }}">
|
||||
<meta property="og:image:type" content="image/webp">
|
||||
@if(!empty($meta['og_width']))
|
||||
<meta property="og:image:width" content="{{ $meta['og_width'] }}">
|
||||
@endif
|
||||
@if(!empty($meta['og_height']))
|
||||
<meta property="og:image:height" content="{{ $meta['og_height'] }}">
|
||||
@endif
|
||||
@endif
|
||||
|
||||
if ($contentType) {
|
||||
$rootCategories = Category::where('content_type_id', $contentType->id)
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
} else {
|
||||
$rootCategories = collect();
|
||||
}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $meta['title'] }}">
|
||||
<meta name="twitter:description" content="{{ $meta['description'] }}">
|
||||
@if(!empty($meta['og_image']))
|
||||
<meta name="twitter:image" content="{{ $meta['og_image'] }}">
|
||||
@endif
|
||||
|
||||
$subcategories = $category ? $category->children()->orderBy('sort_order')->get() : collect();
|
||||
// Provide an empty paginator to satisfy any shared pagination partials
|
||||
$artworks = new LengthAwarePaginator([], 0, 24, 1, ['path' => request()->url()]);
|
||||
@endphp
|
||||
@php
|
||||
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: null;
|
||||
$keywords = $artwork->tags->pluck('name')->merge($artwork->categories->pluck('name'))->filter()->unique()->implode(', ');
|
||||
$license = $artwork->license_url ?? null;
|
||||
|
||||
$imageObject = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ImageObject',
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'url' => $meta['canonical'],
|
||||
'contentUrl' => $meta['og_image'] ?? null,
|
||||
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
|
||||
'encodingFormat' => 'image/webp',
|
||||
'width' => !empty($meta['og_width']) ? (int) $meta['og_width'] : null,
|
||||
'height' => !empty($meta['og_height']) ? (int) $meta['og_height'] : null,
|
||||
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
'license' => $license,
|
||||
'keywords' => $keywords !== '' ? $keywords : null,
|
||||
];
|
||||
|
||||
$creativeWork = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CreativeWork',
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'url' => $meta['canonical'],
|
||||
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
'license' => $license,
|
||||
'keywords' => $keywords !== '' ? $keywords : null,
|
||||
'image' => $meta['og_image'] ?? null,
|
||||
];
|
||||
|
||||
$imageObject = array_filter($imageObject, static fn ($value) => $value !== null && $value !== '');
|
||||
$creativeWork = array_filter($creativeWork, static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$preloadSrcset = ($presentMd['url'] ?? '') . ' 640w, ' . ($presentLg['url'] ?? '') . ' 1280w, ' . ($presentXl['url'] ?? '') . ' 1920w';
|
||||
@endphp
|
||||
|
||||
@if(!empty($presentLg['url']))
|
||||
<link rel="preload" as="image"
|
||||
href="{{ $presentLg['url'] }}"
|
||||
imagesrcset="{{ trim($preloadSrcset) }}"
|
||||
imagesizes="(min-width: 1280px) 1200px, (min-width: 768px) 90vw, 100vw">
|
||||
@endif
|
||||
|
||||
<script type="application/ld+json">{!! json_encode($imageObject, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
|
||||
<script type="application/ld+json">{!! json_encode($creativeWork, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="flex min-h-[calc(100vh-64px)]">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
|
||||
<div class="p-4">
|
||||
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
|
||||
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</span>
|
||||
<span class="text-sm text-white/90">Menu</span>
|
||||
</button>
|
||||
|
||||
<div class="mt-6 text-sm text-neutral-400">
|
||||
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
|
||||
<ul class="space-y-2">
|
||||
@foreach($rootCategories as $root)
|
||||
<li>
|
||||
<a class="flex items-center gap-2 hover:text-white" href="{{ $root->url }}"><span class="opacity-70">📁</span> {{ $root->name }}</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
|
||||
<ul class="space-y-2 pr-2">
|
||||
@foreach($subcategories as $sub)
|
||||
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="flex-1">
|
||||
<div class="relative overflow-hidden nb-hero-radial">
|
||||
<div class="absolute inset-0 opacity-35"></div>
|
||||
|
||||
<div class="relative px-6 py-8 md:px-10 md:py-10">
|
||||
<div class="text-sm text-neutral-400">
|
||||
@if($contentType)
|
||||
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
|
||||
@endif
|
||||
@if($category)
|
||||
@foreach ($category->breadcrumbs as $crumb)
|
||||
<span class="opacity-50">›</span> <a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$breadcrumbs = $category ? (is_array($category->breadcrumbs) ? $category->breadcrumbs : [$category]) : [];
|
||||
$headerCategory = !empty($breadcrumbs) ? end($breadcrumbs) : ($category ?? null);
|
||||
@endphp
|
||||
|
||||
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $headerCategory->name ?? $artwork->title }}</h1>
|
||||
|
||||
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
|
||||
<div class="p-5 md:p-6">
|
||||
<div class="text-lg font-semibold text-white/90">{{ $artwork->title }}</div>
|
||||
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $artwork->description ?? ($headerCategory->description ?? ($contentType->name ?? 'Artwork')) !!}</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artwork detail -->
|
||||
<section class="px-6 pb-10 md:px-10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="col-span-2">
|
||||
<div class="rounded-2xl overflow-hidden bg-black/20 border border-white/10 shadow-lg">
|
||||
<img src="{{ $artwork->thumbnail_url ?? '/images/placeholder.jpg' }}" alt="{{ $artwork->title }}" class="w-full h-auto object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<aside class="p-4 bg-white/3 rounded-2xl border border-white/6">
|
||||
<h3 class="font-semibold text-white">{{ $artwork->title }}</h3>
|
||||
<p class="text-sm text-neutral-400 mt-2">{!! $artwork->description ?? 'No description provided.' !!}</p>
|
||||
<div class="mt-4">
|
||||
<a href="{{ $artwork->file_path ?? '#' }}" class="inline-block px-4 py-2 bg-indigo-600 text-white rounded">Download</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if(isset($similarItems) && $similarItems->isNotEmpty())
|
||||
<section class="mt-8" data-similar-analytics data-algo-version="{{ $similarAlgoVersion ?? '' }}" data-artwork-id="{{ $artwork->id }}">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-lg md:text-xl font-semibold text-white/95">Similar artworks</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach($similarItems as $item)
|
||||
<article class="rounded-2xl overflow-hidden border border-white/10 bg-black/20 shadow-lg">
|
||||
<a
|
||||
href="{{ $item['url'] }}"
|
||||
class="group block"
|
||||
data-similar-click
|
||||
data-similar-id="{{ $item['id'] }}"
|
||||
data-similar-title="{{ e($item['title']) }}"
|
||||
>
|
||||
<div class="aspect-[16/10] bg-neutral-900">
|
||||
<img
|
||||
src="{{ $item['thumb'] }}"
|
||||
@if(!empty($item['thumb_srcset'])) srcset="{{ $item['thumb_srcset'] }}" @endif
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="{{ $item['title'] }}"
|
||||
class="h-full w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="truncate text-sm font-medium text-white/90">{{ $item['title'] }}</div>
|
||||
@if(!empty($item['author']))
|
||||
<div class="mt-1 truncate text-xs text-neutral-400">by {{ $item['author'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="artwork-page"
|
||||
data-artwork='@json($artworkData)'
|
||||
data-related='@json($relatedItems)'
|
||||
data-present-md='@json($presentMd)'
|
||||
data-present-lg='@json($presentLg)'
|
||||
data-present-xl='@json($presentXl)'
|
||||
data-present-sq='@json($presentSq)'
|
||||
data-cdn='@json(rtrim((string) config("cdn.files_url", "https://files.skinbase.org"), "/"))'
|
||||
data-canonical='@json($meta["canonical"])'>
|
||||
</div>
|
||||
</div> <!-- end .legacy-page -->
|
||||
|
||||
@php
|
||||
$jsonLdType = str_starts_with((string) ($artwork->mime_type ?? ''), 'image/') ? 'ImageObject' : 'CreativeWork';
|
||||
$keywords = $artwork->tags()->pluck('name')->values()->all();
|
||||
|
||||
$jsonLd = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $jsonLdType,
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => trim(strip_tags((string) ($artwork->description ?? ''))),
|
||||
'author' => [
|
||||
'@type' => 'Person',
|
||||
'name' => (string) optional($artwork->user)->name,
|
||||
],
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
'url' => request()->url(),
|
||||
'image' => (string) ($artwork->thumbnail_url ?? ''),
|
||||
'keywords' => $keywords,
|
||||
];
|
||||
@endphp
|
||||
<script type="application/ld+json">{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
|
||||
@if(isset($similarItems) && $similarItems->isNotEmpty())
|
||||
<script>
|
||||
(function () {
|
||||
var section = document.querySelector('[data-similar-analytics]');
|
||||
if (!section) return;
|
||||
|
||||
var algoVersion = section.getAttribute('data-algo-version') || '';
|
||||
var sourceArtworkId = section.getAttribute('data-artwork-id') || '';
|
||||
var anchors = section.querySelectorAll('[data-similar-click]');
|
||||
|
||||
var impressionPayload = {
|
||||
event: 'similar_artworks_impression',
|
||||
source_artwork_id: sourceArtworkId,
|
||||
algo_version: algoVersion,
|
||||
item_ids: Array.prototype.map.call(anchors, function (anchor) {
|
||||
return anchor.getAttribute('data-similar-id');
|
||||
})
|
||||
};
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
|
||||
function sendAnalytics(payload) {
|
||||
var endpoint = '/api/analytics/similar-artworks';
|
||||
var body = JSON.stringify(payload);
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
var blob = new Blob([body], { type: 'application/json' });
|
||||
navigator.sendBeacon(endpoint, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body,
|
||||
keepalive: true
|
||||
}).catch(function () {
|
||||
// ignore analytics transport errors
|
||||
});
|
||||
}
|
||||
|
||||
window.dataLayer.push(impressionPayload);
|
||||
anchors.forEach(function (anchor, index) {
|
||||
sendAnalytics({
|
||||
event_type: 'impression',
|
||||
source_artwork_id: Number(sourceArtworkId),
|
||||
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
|
||||
algo_version: algoVersion,
|
||||
position: index + 1,
|
||||
items_count: anchors.length
|
||||
});
|
||||
});
|
||||
|
||||
anchors.forEach(function (anchor, index) {
|
||||
anchor.addEventListener('click', function () {
|
||||
window.dataLayer.push({
|
||||
event: 'similar_artworks_click',
|
||||
source_artwork_id: sourceArtworkId,
|
||||
algo_version: algoVersion,
|
||||
similar_artwork_id: anchor.getAttribute('data-similar-id'),
|
||||
similar_artwork_title: anchor.getAttribute('data-similar-title') || '',
|
||||
position: index + 1
|
||||
});
|
||||
|
||||
sendAnalytics({
|
||||
event_type: 'click',
|
||||
source_artwork_id: Number(sourceArtworkId),
|
||||
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
|
||||
algo_version: algoVersion,
|
||||
position: index + 1
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
@vite(['resources/js/Pages/ArtworkPage.jsx'])
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.nb-hero-fade {
|
||||
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
$gridV2 = request()->query('grid') === 'v2';
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$seoPage = max(1, (int) request()->query('page', 1));
|
||||
$seoBase = url()->current();
|
||||
$seoQ = request()->query(); unset($seoQ['page']);
|
||||
$seoUrl = fn(int $p) => $seoBase . ($p > 1
|
||||
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
|
||||
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
|
||||
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
|
||||
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
|
||||
? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<link rel="canonical" href="{{ $seoUrl($seoPage) }}">
|
||||
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
|
||||
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
|
||||
<meta name="robots" content="index,follow">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
|
||||
@isset($page_robots)
|
||||
@@ -29,28 +30,6 @@
|
||||
|
||||
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js'])
|
||||
<style>
|
||||
/* Gallery loading overlay */
|
||||
.nova-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
}
|
||||
.nova-loader-spinner {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid rgba(255,255,255,0.08);
|
||||
border-top-color: rgba(255,255,255,0.9);
|
||||
animation: novaSpin 0.9s linear infinite;
|
||||
box-shadow: 0 6px 18px rgba(2,6,23,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@keyframes novaSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Card enter animation */
|
||||
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }
|
||||
.nova-card-enter.nova-card-enter-active { transition: transform 380ms cubic-bezier(.2,.9,.2,1), opacity 380ms ease-out; opacity: 1; transform: none; }
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
|
||||
@php
|
||||
$seoPage = max(1, (int) request()->query('page', 1));
|
||||
$seoBase = url()->current();
|
||||
$seoQ = request()->query(); unset($seoQ['page']);
|
||||
$seoUrl = fn(int $p) => $seoBase . ($p > 1
|
||||
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
|
||||
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
|
||||
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
|
||||
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
|
||||
? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<link rel="canonical" href="{{ $seoUrl($seoPage) }}">
|
||||
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
|
||||
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
|
||||
<meta name="robots" content="index,follow">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
|
||||
@@ -1,85 +1,142 @@
|
||||
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
|
||||
{{-- News and forum columns --}}
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
<div class="row news-row">
|
||||
<div class="col-sm-6">
|
||||
@forelse ($forumNews as $item)
|
||||
<div class="panel panel-skinbase effect2">
|
||||
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
|
||||
<div class="panel-body">
|
||||
<div class="text-muted news-head">
|
||||
Written by {{ $item->uname }} on {{ Carbon::parse($item->post_date)->format('j F Y \@ H:i') }}
|
||||
</div>
|
||||
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
|
||||
<br>
|
||||
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p>No forum news available.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@forelse ($ourNews as $news)
|
||||
<div class="panel panel-skinbase effect2">
|
||||
<div class="panel-heading"><h3 class="panel-title">{{ $news->headline }}</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="text-muted news-head">
|
||||
<i class="fa fa-user"></i> {{ $news->uname }}
|
||||
<i class="fa fa-calendar"></i> {{ Carbon::parse($news->create_date)->format('j F Y \@ H:i') }}
|
||||
<i class="fa fa-info"></i> {{ $news->category_name }}
|
||||
<i class="fa fa-info"></i> {{ $news->views }} reads
|
||||
<i class="fa fa-comment"></i> {{ $news->num_comments }} comments
|
||||
</div>
|
||||
<section class="px-6 pb-14 pt-2 md:px-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
@if (!empty($news->picture))
|
||||
@php $nid = floor($news->news_id / 100); @endphp
|
||||
<div class="col-md-4">
|
||||
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}" class="img-responsive" alt="{{ $news->headline }}">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
{!! $news->preview !!}
|
||||
</div>
|
||||
@else
|
||||
{!! $news->preview !!}
|
||||
{{-- ── LEFT: Forum News ── --}}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-sky-500/15 text-sky-400">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h6m-6 4h4M5 20H4a2 2 0 01-2-2V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-1l-4 4-4-4z"/></svg>
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Forum News</h2>
|
||||
</div>
|
||||
|
||||
@forelse ($forumNews as $item)
|
||||
<article class="group rounded-xl bg-white/[0.03] border border-white/[0.06] hover:border-sky-500/30 hover:bg-white/[0.05] transition-all duration-200 p-4">
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}"
|
||||
class="block text-sm font-semibold text-white/90 group-hover:text-sky-300 transition-colors leading-snug mb-1 line-clamp-2">
|
||||
{{ $item->topic }}
|
||||
</a>
|
||||
<div class="flex items-center gap-3 text-xs text-white/40 mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ $item->uname }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ Carbon::parse($item->post_date)->format('j M Y') }}
|
||||
</span>
|
||||
</div>
|
||||
@if (!empty($item->preview))
|
||||
<p class="text-xs text-white/50 leading-relaxed line-clamp-3">{{ Str::limit(strip_tags($item->preview), 200) }}</p>
|
||||
@endif
|
||||
|
||||
<a class="clearfix btn btn-xs btn-info text-white" href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}">More</a>
|
||||
</article>
|
||||
@empty
|
||||
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-6 text-sm text-white/40 text-center">
|
||||
No forum news available.
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p>No news available.</p>
|
||||
@endforelse
|
||||
|
||||
{{-- Site info --}}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Info</strong></div>
|
||||
<div class="panel-body">
|
||||
<h4>Photography, Wallpapers and Skins... Thats Skinbase</h4>
|
||||
<p>Skinbase is the site dedicated to <strong>Photography</strong>, <strong>Wallpapers</strong> and <strong>Skins</strong> for <u>popular applications</u> for every major operating system like Windows, Mac OS X, Linux, iOS and Android</p>
|
||||
<em>Our members every day uploads new artworks to our site, so don't hesitate and check Skinbase frequently for updates. We also have forum where you can discuss with other members with anything.</em>
|
||||
<p>On the site toolbar you can click on Categories and start browsing our atwork (<i>photo</i>, <i>desktop themes</i>, <i>pictures</i>) and of course you can <u>download</u> them for free!</p>
|
||||
<p>We are also active on all major <b>social</b> sites, find us there too</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Latest forum activity --}}
|
||||
<div class="panel panel-default activity-panel">
|
||||
<div class="panel-heading"><strong>Latest Forum Activity</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="list-group effect2">
|
||||
{{-- ── RIGHT: Site News + Info + Forum Activity ── --}}
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- Site News --}}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-violet-500/15 text-violet-400">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10l6 6v8a2 2 0 01-2 2z"/><path stroke-linecap="round" stroke-linejoin="round" d="M13 4v6h6"/></svg>
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Site News</h2>
|
||||
</div>
|
||||
|
||||
@forelse ($ourNews as $news)
|
||||
@php $nid = floor($news->news_id / 100); @endphp
|
||||
<article class="group rounded-xl bg-white/[0.03] border border-white/[0.06] hover:border-violet-500/30 hover:bg-white/[0.05] transition-all duration-200 p-4 mb-3 last:mb-0">
|
||||
<a href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}"
|
||||
class="block text-sm font-semibold text-white/90 group-hover:text-violet-300 transition-colors leading-snug mb-1 line-clamp-2">
|
||||
{{ $news->headline }}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40 mb-3">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ $news->uname }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ Carbon::parse($news->create_date)->format('j M Y') }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
{{ number_format($news->views) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
{{ $news->num_comments }}
|
||||
</span>
|
||||
</div>
|
||||
@if (!empty($news->picture))
|
||||
<div class="flex gap-3">
|
||||
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}"
|
||||
class="w-20 h-14 object-cover rounded-lg flex-shrink-0 ring-1 ring-white/10"
|
||||
alt="{{ e($news->headline) }}" loading="lazy">
|
||||
<p class="text-xs text-white/50 leading-relaxed line-clamp-3">{!! Str::limit(strip_tags($news->preview ?? ''), 180) !!}</p>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-xs text-white/50 leading-relaxed line-clamp-3">{!! Str::limit(strip_tags($news->preview ?? ''), 240) !!}</p>
|
||||
@endif
|
||||
</article>
|
||||
@empty
|
||||
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-6 text-sm text-white/40 text-center">
|
||||
No news available.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- About Skinbase --}}
|
||||
<div class="rounded-xl bg-gradient-to-br from-sky-500/10 to-violet-500/10 border border-white/[0.07] p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-md bg-sky-500/20 text-sky-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</span>
|
||||
<h3 class="text-sm font-semibold text-white/80">About Skinbase</h3>
|
||||
</div>
|
||||
<p class="text-xs text-white/55 leading-relaxed">
|
||||
Skinbase is dedicated to <span class="text-white/80 font-medium">Photography</span>, <span class="text-white/80 font-medium">Wallpapers</span> and <span class="text-white/80 font-medium">Skins</span> for popular applications on Windows, macOS, Linux, iOS and Android.
|
||||
Browse categories, discover curated artwork, and download everything for free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Latest Forum Activity --}}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-emerald-500/15 text-emerald-400">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/></svg>
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Forum Activity</h2>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden divide-y divide-white/[0.05]">
|
||||
@forelse ($latestForumActivity as $topic)
|
||||
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
|
||||
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}"
|
||||
class="flex items-center justify-between gap-3 px-4 py-3 text-sm text-white/70 hover:bg-white/[0.04] hover:text-white transition-colors group">
|
||||
<span class="truncate group-hover:text-emerald-300 transition-colors">{{ $topic->topic }}</span>
|
||||
<span class="flex-shrink-0 inline-flex items-center gap-1 text-xs text-white/35 group-hover:text-white/60 transition-colors">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
{{ $topic->numPosts }}
|
||||
</span>
|
||||
</a>
|
||||
@empty
|
||||
<p>No recent forum activity.</p>
|
||||
<div class="px-4 py-5 text-sm text-white/40 text-center">No recent forum activity.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- end right column --}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
@php
|
||||
$gridV2 = request()->query('grid') === 'v2';
|
||||
$seoPage = (int) request()->query('page', 1);
|
||||
$seoBase = url()->current();
|
||||
$seoCanonical = $seoPage > 1 ? $seoBase . '?page=' . $seoPage : $seoBase;
|
||||
$seoPrev = $seoPage > 1
|
||||
? ($seoPage === 2 ? $seoBase : $seoBase . '?page=' . ($seoPage - 1))
|
||||
: null;
|
||||
$seoNext = (isset($latestUploads) && method_exists($latestUploads, 'hasMorePages') && $latestUploads->hasMorePages())
|
||||
? $seoBase . '?page=' . ($seoPage + 1)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<link rel="canonical" href="{{ $seoCanonical ?? url()->current() }}">
|
||||
@if(!empty($seoPrev ?? null))<link rel="prev" href="{{ $seoPrev }}">@endif
|
||||
@if(!empty($seoNext ?? null))<link rel="next" href="{{ $seoNext }}">@endif
|
||||
@endpush
|
||||
|
||||
{{-- Latest uploads grid — use same Nova gallery layout as /browse --}}
|
||||
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
|
||||
<div class="{{ ($gridV2 ?? false) ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
|
||||
@forelse($latestUploads as $upload)
|
||||
<x-artwork-card :art="$upload" />
|
||||
@empty
|
||||
@@ -24,7 +41,7 @@
|
||||
</section>
|
||||
|
||||
@push('styles')
|
||||
@if(! $gridV2)
|
||||
@if(! ($gridV2 ?? false))
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user