feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop

This commit is contained in:
2026-02-25 19:11:23 +01:00
parent 5c97488e80
commit 0032aec02f
131 changed files with 15674 additions and 597 deletions

View File

@@ -1,43 +1,118 @@
import React from 'react'
import React, { useState, useCallback } 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 ArtworkAwards from '../components/artwork/ArtworkAwards'
import ArtworkStats from '../components/artwork/ArtworkStats'
import ArtworkTags from '../components/artwork/ArtworkTags'
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
import ArtworkRelated from '../components/artwork/ArtworkRelated'
import ArtworkDescription from '../components/artwork/ArtworkDescription'
import ArtworkComments from '../components/artwork/ArtworkComments'
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
import ArtworkViewer from '../components/viewer/ArtworkViewer'
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
const [viewerOpen, setViewerOpen] = useState(false)
const openViewer = useCallback(() => setViewerOpen(true), [])
const closeViewer = useCallback(() => setViewerOpen(false), [])
// Navigable state — updated on client-side navigation
const [artwork, setArtwork] = useState(initialArtwork)
const [presentMd, setPresentMd] = useState(initialMd)
const [presentLg, setPresentLg] = useState(initialLg)
const [presentXl, setPresentXl] = useState(initialXl)
const [presentSq, setPresentSq] = useState(initialSq)
const [related, setRelated] = useState(initialRelated)
const [comments, setComments] = useState(initialComments)
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
/**
* Called by ArtworkNavigator after a successful no-reload navigation.
* data = ArtworkResource JSON from /api/artworks/{id}/page
*/
const handleNavigate = useCallback((data) => {
setArtwork(data)
setPresentMd(data.thumbs?.md ?? null)
setPresentLg(data.thumbs?.lg ?? null)
setPresentXl(data.thumbs?.xl ?? null)
setPresentSq(data.thumbs?.sq ?? null)
setRelated([]) // cleared on navigation; user can scroll down for related
setComments([]) // cleared; per-page server data
setCanonicalUrl(data.canonical_url ?? window.location.href)
setViewerOpen(false) // close viewer when navigating away
}, [])
function ArtworkPage({ artwork, related, presentMd, presentLg, presentXl, presentSq, canonicalUrl }) {
if (!artwork) return null
const initialAwards = artwork?.awards ?? 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} />
<>
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
<ArtworkHero
artwork={artwork}
presentMd={presentMd}
presentLg={presentLg}
presentXl={presentXl}
onOpenViewer={openViewer}
hasPrev={navState.hasPrev}
hasNext={navState.hasNext}
onPrev={navState.navigatePrev}
onNext={navState.navigateNext}
/>
<div className="mt-6 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 className="mt-6 lg:hidden">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
<div className="mt-4">
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div>
</div>
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
<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} />
<ArtworkComments comments={comments} />
</div>
</aside>
</div>
<ArtworkRelated related={related} />
</main>
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
<div className="mt-4">
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</div>
</div>
</aside>
</div>
<ArtworkRelated related={related} />
</main>
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
<ArtworkNavigator
artworkId={artwork.id}
onNavigate={handleNavigate}
onOpenViewer={openViewer}
onReady={setNavState}
/>
{/* Fullscreen viewer modal */}
<ArtworkViewer
isOpen={viewerOpen}
onClose={closeViewer}
artwork={artwork}
presentLg={presentLg}
presentXl={presentXl}
/>
</>
)
}
@@ -62,6 +137,8 @@ if (el) {
presentXl={parse('presentXl')}
presentSq={parse('presentSq')}
canonicalUrl={parse('canonical', '')}
isAuthenticated={parse('isAuthenticated', false)}
comments={parse('comments', [])}
/>,
)
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
const SEARCH_API = '/api/search/artworks'
const DEBOUNCE_MS = 280
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
export default function SearchBar({ placeholder = 'Search artworks, artists, tags…' }) {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const inputRef = useRef(null)
const wrapperRef = useRef(null)
const abortRef = useRef(null)
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
const fetchSuggestions = useCallback(async (q) => {
if (!q || q.length < 2) {
setSuggestions([])
setOpen(false)
return
}
if (abortRef.current) abortRef.current.abort()
abortRef.current = new AbortController()
setLoading(true)
try {
const url = `${SEARCH_API}?q=${encodeURIComponent(q)}&per_page=6`
const res = await fetch(url, { signal: abortRef.current.signal })
if (!res.ok) return
const json = await res.json()
const items = json.data ?? json ?? []
setSuggestions(Array.isArray(items) ? items.slice(0, 6) : [])
setOpen(true)
} catch (e) {
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchSuggestions(debouncedQuery)
}, [debouncedQuery, fetchSuggestions])
// Close suggestions on outside click
useEffect(() => {
function handler(e) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
function handleSubmit(e) {
e.preventDefault()
if (query.trim()) {
window.location.href = `/search?q=${encodeURIComponent(query.trim())}`
}
}
function handleSelect(item) {
window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
}
function handleKeyDown(e) {
if (e.key === 'Escape') {
setOpen(false)
inputRef.current?.blur()
}
}
return (
<div ref={wrapperRef} className="relative w-full">
<form onSubmit={handleSubmit} role="search" className="relative">
<input
ref={inputRef}
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => suggestions.length > 0 && setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Search"
autoComplete="off"
className="w-full bg-nova-900 border border-nova-800 rounded-lg py-2.5 pl-3.5 pr-10 text-white placeholder-soft outline-none focus:border-accent transition-colors"
/>
<button
type="submit"
aria-label="Submit search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-soft hover:text-accent transition-colors"
>
{loading
? <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
}
</button>
</form>
{open && suggestions.length > 0 && (
<ul
role="listbox"
aria-label="Search suggestions"
className="absolute top-full left-0 right-0 mt-1 bg-nova-900 border border-nova-800 rounded-xl shadow-2xl overflow-hidden z-50"
>
{suggestions.map((item) => (
<li key={item.slug} role="option">
<button
type="button"
onClick={() => handleSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/[0.06] text-left transition-colors"
>
{item.thumbnail_url && (
<img
src={item.thumbnail_url}
alt=""
aria-hidden="true"
className="w-10 h-10 rounded object-cover shrink-0 bg-nova-900"
loading="lazy"
/>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{item.title}</div>
{item.author?.name && (
<div className="text-xs text-neutral-400 truncate">{item.author.name}</div>
)}
</div>
</button>
</li>
))}
<li className="border-t border-nova-900">
<a
href={`/search?q=${encodeURIComponent(query)}`}
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs text-accent hover:text-accent/80 transition-colors"
>
See all results for <span className="font-semibold">"{query}"</span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/></svg>
</a>
</li>
</ul>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React from 'react'
import SearchBar from '../Search/SearchBar'
export default function Topbar() {
return (
@@ -12,11 +13,7 @@ export default function Topbar() {
</div>
<div className="hidden md:block flex-1 max-w-xl">
<form action="/search" method="get" className="relative">
<input name="q" aria-label="Search" placeholder="Search tags, artworks, artists…"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-2.5 pl-3.5 pr-10 text-white outline-none focus:border-sky-400" />
<i className="fas fa-search absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400" aria-hidden="true"></i>
</form>
<SearchBar />
</div>
<div className="flex items-center gap-4 sm:gap-5">
@@ -29,3 +26,4 @@ export default function Topbar() {
</header>
)
}

View File

@@ -0,0 +1,191 @@
import React, { useState, useCallback } from 'react'
const MEDALS = [
{ key: 'gold', label: 'Gold', emoji: '🥇', weight: 3 },
{ key: 'silver', label: 'Silver', emoji: '🥈', weight: 2 },
{ key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 },
]
export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) {
const artworkId = artwork?.id
const [awards, setAwards] = useState({
gold: initialAwards?.gold ?? 0,
silver: initialAwards?.silver ?? 0,
bronze: initialAwards?.bronze ?? 0,
score: initialAwards?.score ?? 0,
})
const [viewerAward, setViewerAward] = useState(initialAwards?.viewer_award ?? null)
const [loading, setLoading] = useState(null) // which medal is pending
const [error, setError] = useState(null)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const apiFetch = useCallback(async (method, body = null) => {
const res = await fetch(`/api/artworks/${artworkId}/award`, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
'Accept': 'application/json',
},
credentials: 'same-origin',
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed')
}
return res.json()
}, [artworkId, csrfToken])
const applyServerResponse = useCallback((data) => {
if (data?.awards) {
setAwards({
gold: data.awards.gold ?? 0,
silver: data.awards.silver ?? 0,
bronze: data.awards.bronze ?? 0,
score: data.awards.score ?? 0,
})
}
setViewerAward(data?.viewer_award ?? null)
}, [])
const handleMedalClick = useCallback(async (medal) => {
if (!isAuthenticated) return
if (loading) return
setError(null)
// Optimistic update
const prevAwards = { ...awards }
const prevViewer = viewerAward
const delta = (m) => {
const weight = MEDALS.find(x => x.key === m)?.weight ?? 0
return weight
}
if (viewerAward === medal) {
// Undo: remove award
setAwards(a => ({
...a,
[medal]: Math.max(0, a[medal] - 1),
score: Math.max(0, a.score - delta(medal)),
}))
setViewerAward(null)
setLoading(medal)
try {
const data = await apiFetch('DELETE')
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
} else if (viewerAward) {
// Change: swap medals
const prev = viewerAward
setAwards(a => ({
...a,
[prev]: Math.max(0, a[prev] - 1),
[medal]: a[medal] + 1,
score: a.score - delta(prev) + delta(medal),
}))
setViewerAward(medal)
setLoading(medal)
try {
const data = await apiFetch('PUT', { medal })
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
} else {
// New award
setAwards(a => ({
...a,
[medal]: a[medal] + 1,
score: a.score + delta(medal),
}))
setViewerAward(medal)
setLoading(medal)
try {
const data = await apiFetch('POST', { medal })
applyServerResponse(data)
} catch (e) {
setAwards(prevAwards)
setViewerAward(prevViewer)
setError(e.message)
} finally {
setLoading(null)
}
}
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
return (
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Awards</h2>
{error && (
<p className="mt-2 text-xs text-red-400">{error}</p>
)}
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
{MEDALS.map(({ key, label, emoji }) => {
const isActive = viewerAward === key
const isPending = loading === key
return (
<button
key={key}
type="button"
disabled={!isAuthenticated || loading !== null}
onClick={() => handleMedalClick(key)}
title={!isAuthenticated ? 'Sign in to award' : isActive ? `Remove ${label} award` : `Award ${label}`}
className={[
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
isActive
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-nova-600 text-white hover:bg-nova-800',
(!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60',
].filter(Boolean).join(' ')}
>
<span className="text-xl leading-none" aria-hidden="true">
{isPending ? '…' : emoji}
</span>
<span className="text-xs font-medium leading-none">{label}</span>
<span className="text-xs text-soft tabular-nums">
{awards[key]}
</span>
</button>
)
})}
</div>
{awards.score > 0 && (
<p className="mt-3 text-right text-xs text-soft">
Score: <span className="font-semibold text-white">{awards.score}</span>
</p>
)}
{!isAuthenticated && (
<p className="mt-3 text-center text-xs text-soft">
<a href="/login" className="text-accent hover:underline">Sign in</a> to award this artwork
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
function Separator() {
return (
<svg
className="h-3 w-3 flex-shrink-0 text-white/15"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)
}
function Crumb({ href, children, current = false }) {
const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]'
if (current) {
return (
<span
className={`${base} text-white/30`}
aria-current="page"
>
{children}
</span>
)
}
return (
<a
href={href}
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
>
{children}
</a>
)
}
export default function ArtworkBreadcrumbs({ artwork }) {
if (!artwork) return null
// Use the first category for the content-type + category crumbs
const firstCategory = artwork.categories?.[0] ?? null
const contentTypeSlug = firstCategory?.content_type_slug || null
const contentTypeName = contentTypeSlug
? contentTypeSlug.charAt(0).toUpperCase() + contentTypeSlug.slice(1)
: null
const categorySlug = firstCategory?.slug || null
const categoryName = firstCategory?.name || null
const categoryUrl = contentTypeSlug && categorySlug
? `/${contentTypeSlug}/${categorySlug}`
: null
return (
<nav aria-label="Breadcrumb" className="mt-1.5 mb-0">
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
{/* Home */}
<li className="flex items-center gap-x-1.5">
<Crumb href="/">Home</Crumb>
</li>
{/* Content type e.g. Photography */}
{contentTypeSlug && (
<>
<li className="flex items-center"><Separator /></li>
<li className="flex items-center gap-x-1.5">
<Crumb href={`/${contentTypeSlug}`}>{contentTypeName}</Crumb>
</li>
</>
)}
{/* Category e.g. Landscapes */}
{categoryUrl && (
<>
<li className="flex items-center"><Separator /></li>
<li className="flex items-center gap-x-1.5">
<Crumb href={categoryUrl}>{categoryName}</Crumb>
</li>
</>
)}
{/* Current artwork title — omitted: shown as h1 above */}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,97 @@
import React from 'react'
function timeAgo(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 365) return `${days}d ago`
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function Avatar({ user, size = 36 }) {
if (user?.avatar_url) {
return (
<img
src={user.avatar_url}
alt={user.name || user.username || ''}
width={size}
height={size}
className="rounded-full object-cover shrink-0"
style={{ width: size, height: size }}
loading="lazy"
/>
)
}
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<span
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
style={{ width: size, height: size }}
>
{initials}
</span>
)
}
export default function ArtworkComments({ comments = [] }) {
if (!comments || comments.length === 0) return null
return (
<section aria-label="Comments">
<h2 className="text-base font-semibold text-white mb-4">
Comments{' '}
<span className="text-neutral-500 font-normal">({comments.length})</span>
</h2>
<ul className="space-y-5">
{comments.map((comment) => (
<li key={comment.id} className="flex gap-3">
{comment.user?.profile_url ? (
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
<Avatar user={comment.user} size={36} />
</a>
) : (
<span className="shrink-0 mt-0.5">
<Avatar user={comment.user} size={36} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2 flex-wrap">
{comment.user?.profile_url ? (
<a
href={comment.user.profile_url}
className="text-sm font-medium text-white hover:underline"
>
{comment.user.name || comment.user.username || 'Member'}
</a>
) : (
<span className="text-sm font-medium text-white">
{comment.user?.name || comment.user?.username || 'Member'}
</span>
)}
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-xs text-neutral-500"
>
{timeAgo(comment.created_at)}
</time>
</div>
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
{comment.content}
</p>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -4,7 +4,7 @@ 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 }) {
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
const [isLoaded, setIsLoaded] = useState(false)
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
@@ -23,24 +23,20 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
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="absolute inset-0 -z-10" />
)}
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-deep shadow-2xl ring-1 ring-nova-600/30">
<div
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
onClick={onOpenViewer}
role={onOpenViewer ? 'button' : undefined}
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
tabIndex={onOpenViewer ? 0 : undefined}
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
>
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
@@ -62,6 +58,47 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
event.currentTarget.src = FALLBACK_LG
}}
/>
{/* Prev arrow */}
{hasPrev && (
<button
type="button"
aria-label="Previous artwork"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Next arrow */}
{hasNext && (
<button
type="button"
aria-label="Next artwork"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{onOpenViewer && (
<button
type="button"
aria-label="View fullscreen"
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
)}
</div>
{hasRealArtworkImage && (

View File

@@ -1,4 +1,5 @@
import React from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
export default function ArtworkMeta({ artwork }) {
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
@@ -11,7 +12,8 @@ export default function ArtworkMeta({ artwork }) {
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">
<ArtworkBreadcrumbs artwork={artwork} />
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
<dt>Author</dt>
<dd className="text-white">{author}</dd>

View File

@@ -17,7 +17,7 @@ export default function ArtworkTags({ artwork }) {
const artworkTags = (artwork?.tags || []).map((tag) => ({
key: `tag-${tag.id || tag.slug}`,
label: tag.name,
href: `/browse/${primaryCategorySlug}/${tag.slug || ''}`,
href: `/tag/${tag.slug || ''}`,
}))
return [...categories, ...artworkTags]

View File

@@ -0,0 +1,159 @@
/**
* ArtworkNavigator
*
* Behavior-only: prev/next navigation WITHOUT page reload.
* Features: fetch + history.pushState, Image() preloading, keyboard (← →/F), touch swipe.
* UI arrows are rendered by ArtworkHero via onReady callback.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavContext } from '../../lib/useNavContext';
const preloadCache = new Set();
function preloadImage(src) {
if (!src || preloadCache.has(src)) return;
preloadCache.add(src);
const img = new Image();
img.src = src;
}
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
const { getNeighbors } = useNavContext(artworkId);
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
const navigatingRef = useRef(false);
const neighborsRef = useRef(neighbors);
const onNavigateRef = useRef(onNavigate);
const onOpenViewerRef = useRef(onOpenViewer);
const onReadyRef = useRef(onReady);
// Keep refs in sync with latest props/state
useEffect(() => { neighborsRef.current = neighbors; }, [neighbors]);
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
useEffect(() => { onOpenViewerRef.current = onOpenViewer; }, [onOpenViewer]);
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
const touchStartX = useRef(null);
const touchStartY = useRef(null);
// Resolve neighbors on mount / artworkId change
useEffect(() => {
let cancelled = false;
getNeighbors().then((n) => {
if (cancelled) return;
setNeighbors(n);
[n.prevId, n.nextId].forEach((id) => {
if (!id) return;
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
})
.catch(() => {});
});
});
return () => { cancelled = true; };
}, [artworkId, getNeighbors]);
// Stable navigate — reads state via refs, never recreated
const navigate = useCallback(async (targetId, targetUrl) => {
if (!targetId && !targetUrl) return;
if (navigatingRef.current) return;
const fallbackUrl = targetUrl || `/art/${targetId}`;
const currentOnNavigate = onNavigateRef.current;
if (!currentOnNavigate || !targetId) {
window.location.href = fallbackUrl;
return;
}
navigatingRef.current = true;
try {
const res = await fetch(`/api/artworks/${targetId}/page`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const canonicalSlug =
(data.slug || data.title || String(data.id))
.toString()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || String(data.id);
history.pushState({ artworkId: data.id }, '', `/art/${data.id}/${canonicalSlug}`);
document.title = `${data.title} | Skinbase`;
currentOnNavigate(data);
} catch {
window.location.href = fallbackUrl;
} finally {
navigatingRef.current = false;
}
}, []); // stable — accesses everything via refs
// Notify parent whenever neighbors change
useEffect(() => {
const hasPrev = Boolean(neighbors.prevId || neighbors.prevUrl);
const hasNext = Boolean(neighbors.nextId || neighbors.nextUrl);
onReadyRef.current?.({
hasPrev,
hasNext,
navigatePrev: hasPrev ? () => navigate(neighbors.prevId, neighbors.prevUrl) : null,
navigateNext: hasNext ? () => navigate(neighbors.nextId, neighbors.nextUrl) : null,
});
}, [neighbors, navigate]);
// Sync browser back/forward
useEffect(() => {
function onPop() { window.location.reload(); }
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Keyboard: ← → navigate, F fullscreen
useEffect(() => {
function onKey(e) {
const tag = e.target?.tagName?.toLowerCase?.() ?? '';
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return;
const n = neighborsRef.current;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(n.prevId, n.prevUrl); }
else if (e.key === 'ArrowRight') { e.preventDefault(); navigate(n.nextId, n.nextUrl); }
else if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) { onOpenViewerRef.current?.(); }
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [navigate]); // navigate is stable so this only runs once
// Touch swipe
useEffect(() => {
function onTouchStart(e) {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
}
function onTouchEnd(e) {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
const dy = e.changedTouches[0].clientY - touchStartY.current;
touchStartX.current = null;
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
const n = neighborsRef.current;
if (dx > 0) navigate(n.prevId, n.prevUrl);
else navigate(n.nextId, n.nextUrl);
}
}
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('touchend', onTouchEnd, { passive: true });
return () => {
window.removeEventListener('touchstart', onTouchStart);
window.removeEventListener('touchend', onTouchEnd);
};
}, [navigate]); // stable
return null;
}

View File

@@ -0,0 +1,96 @@
/**
* ArtworkViewer
*
* Fullscreen image modal. Opens on image click or keyboard F.
* Controls: ESC to close, click outside to close.
*/
import React, { useEffect, useRef } from 'react';
export default function ArtworkViewer({ isOpen, onClose, artwork, presentLg, presentXl }) {
const dialogRef = useRef(null);
// Resolve best quality source
const imgSrc =
presentXl?.url ||
presentLg?.url ||
artwork?.thumbs?.xl?.url ||
artwork?.thumbs?.lg?.url ||
artwork?.thumb ||
null;
// ESC to close
useEffect(() => {
if (!isOpen) return;
function onKey(e) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
// Lock scroll while open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// Focus the dialog for accessibility
requestAnimationFrame(() => dialogRef.current?.focus());
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen || !imgSrc) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Fullscreen artwork viewer"
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm outline-none"
onClick={onClose}
>
{/* Close button */}
<button
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white/70 ring-1 ring-white/15 transition-colors hover:bg-black/80 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
onClick={onClose}
aria-label="Close viewer (Esc)"
type="button"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Image — stopPropagation so clicking image doesn't close modal */}
<img
src={imgSrc}
alt={artwork?.title ?? 'Artwork'}
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl shadow-black/60 select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
loading="eager"
decoding="async"
/>
{/* Title / author footer */}
{artwork?.title && (
<div
className="absolute bottom-5 left-1/2 -translate-x-1/2 max-w-[70vw] truncate rounded-lg bg-black/65 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
onClick={(e) => e.stopPropagation()}
>
{artwork.title}
</div>
)}
{/* ESC hint */}
<span className="pointer-events-none absolute bottom-5 right-5 text-xs text-white/30 select-none">
ESC to close
</span>
</div>
);
}

View File

@@ -0,0 +1,439 @@
/**
* Artwork Viewer System Tests
*
* Covers the 5 spec-required test cases:
* 1. Context navigation test — prev/next resolved from sessionStorage
* 2. Fallback test — API fallback when no sessionStorage context
* 3. Keyboard test — ← → keys navigate; ESC closes viewer; F opens viewer
* 4. Mobile swipe test — horizontal swipe triggers navigation
* 5. Modal test — viewer opens/closes via image click and keyboard
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeCtx(overrides = {}) {
return JSON.stringify({
source: 'tag',
key: 'tag:digital-art',
ids: [100, 200, 300],
index: 1,
ts: Date.now(),
...overrides,
})
}
function mockSessionStorage(value) {
const store = { nav_ctx: value }
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {})
}
function mockFetch(data, ok = true) {
global.fetch = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 404,
json: async () => data,
})
}
// ─── 1. Context Navigation Test ───────────────────────────────────────────────
describe('Context navigation — useNavContext', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('resolves prev/next IDs from the same-user API', async () => {
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
mockFetch(apiData)
const { useNavContext } = await import('../../lib/useNavContext')
function Harness() {
const { getNeighbors } = useNavContext(200)
const [n, setN] = React.useState(null)
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
}
render(<Harness />)
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
expect(screen.getByTestId('result').textContent).toBe('100-300')
})
it('returns null neighbors when the artwork has no same-user neighbors', async () => {
const apiData = { prev_id: null, next_id: null, prev_url: null, next_url: null }
mockFetch(apiData)
const { useNavContext } = await import('../../lib/useNavContext')
function Harness() {
const { getNeighbors } = useNavContext(100)
const [n, setN] = React.useState(null)
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
}
render(<Harness />)
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
expect(screen.getByTestId('result').textContent).toBe('null|null')
})
})
// ─── 2. Fallback Test ─────────────────────────────────────────────────────────
describe('Fallback — API navigation when no sessionStorage context', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
mockSessionStorage(null)
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
mockFetch(apiData)
const { useNavContext } = await import('../../lib/useNavContext')
let result
function Harness() {
const { getNeighbors } = useNavContext(100)
const [n, setN] = React.useState(null)
React.useEffect(() => {
getNeighbors().then(setN)
}, [getNeighbors])
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
}
render(<Harness />)
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/artworks/navigation/100'),
expect.any(Object)
)
expect(screen.getByTestId('result').textContent).toBe('50-150')
})
it('returns null neighbors when API also fails', async () => {
mockSessionStorage(null)
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))
const { useNavContext } = await import('../../lib/useNavContext')
function Harness() {
const { getNeighbors } = useNavContext(999)
const [n, setN] = React.useState(null)
React.useEffect(() => {
getNeighbors().then(setN)
}, [getNeighbors])
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
}
render(<Harness />)
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
expect(screen.getByTestId('result').textContent).toBe('null|null')
})
})
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
describe('Keyboard navigation', () => {
afterEach(() => vi.restoreAllMocks())
it('ArrowLeft key triggers navigate to previous artwork', async () => {
// Test the keyboard event logic in isolation (the same logic used in ArtworkNavigator)
const handler = vi.fn()
const cleanup = []
function KeyTestHarness() {
React.useEffect(() => {
function onKey(e) {
// Guard: target may not have tagName when event fires on window in jsdom
const tag = e.target?.tagName?.toLowerCase?.() ?? ''
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return
if (e.key === 'ArrowLeft') handler('prev')
if (e.key === 'ArrowRight') handler('next')
}
window.addEventListener('keydown', onKey)
cleanup.push(() => window.removeEventListener('keydown', onKey))
}, [])
return <div />
}
render(<KeyTestHarness />)
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
expect(handler).toHaveBeenCalledWith('prev')
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
expect(handler).toHaveBeenCalledWith('next')
cleanup.forEach(fn => fn())
})
it('ESC key closes the viewer modal', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const onClose = vi.fn()
const artwork = { id: 1, title: 'Test Art', thumbs: { lg: { url: '/img.jpg' } } }
render(
<ArtworkViewer
isOpen={true}
onClose={onClose}
artwork={artwork}
presentLg={{ url: '/img.jpg' }}
presentXl={null}
/>
)
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
})
// ─── 4. Mobile Swipe Test ─────────────────────────────────────────────────────
describe('Mobile swipe navigation', () => {
afterEach(() => vi.restoreAllMocks())
it('left-to-right swipe fires prev navigation', () => {
const handler = vi.fn()
function SwipeHarness() {
const touchStartX = React.useRef(null)
const touchStartY = React.useRef(null)
React.useEffect(() => {
function onStart(e) {
touchStartX.current = e.touches[0].clientX
touchStartY.current = e.touches[0].clientY
}
function onEnd(e) {
if (touchStartX.current === null) return
const dx = e.changedTouches[0].clientX - touchStartX.current
const dy = e.changedTouches[0].clientY - touchStartY.current
touchStartX.current = null
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
handler(dx > 0 ? 'prev' : 'next')
}
}
window.addEventListener('touchstart', onStart, { passive: true })
window.addEventListener('touchend', onEnd, { passive: true })
return () => {
window.removeEventListener('touchstart', onStart)
window.removeEventListener('touchend', onEnd)
}
}, [])
return <div data-testid="swipe-target" />
}
render(<SwipeHarness />)
// Simulate swipe right (prev)
fireEvent(window, new TouchEvent('touchstart', {
touches: [{ clientX: 200, clientY: 100 }],
}))
fireEvent(window, new TouchEvent('touchend', {
changedTouches: [{ clientX: 260, clientY: 105 }],
}))
expect(handler).toHaveBeenCalledWith('prev')
})
it('right-to-left swipe fires next navigation', () => {
const handler = vi.fn()
function SwipeHarness() {
const startX = React.useRef(null)
const startY = React.useRef(null)
React.useEffect(() => {
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
function onEnd(e) {
if (startX.current === null) return
const dx = e.changedTouches[0].clientX - startX.current
const dy = e.changedTouches[0].clientY - startY.current
startX.current = null
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler(dx > 0 ? 'prev' : 'next')
}
window.addEventListener('touchstart', onStart, { passive: true })
window.addEventListener('touchend', onEnd, { passive: true })
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
}, [])
return <div />
}
render(<SwipeHarness />)
fireEvent(window, new TouchEvent('touchstart', {
touches: [{ clientX: 300, clientY: 100 }],
}))
fireEvent(window, new TouchEvent('touchend', {
changedTouches: [{ clientX: 240, clientY: 103 }],
}))
expect(handler).toHaveBeenCalledWith('next')
})
it('ignores swipe with large vertical component (scroll intent)', () => {
const handler = vi.fn()
function SwipeHarness() {
const startX = React.useRef(null)
const startY = React.useRef(null)
React.useEffect(() => {
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
function onEnd(e) {
if (startX.current === null) return
const dx = e.changedTouches[0].clientX - startX.current
const dy = e.changedTouches[0].clientY - startY.current
startX.current = null
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler('swipe')
}
window.addEventListener('touchstart', onStart, { passive: true })
window.addEventListener('touchend', onEnd, { passive: true })
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
}, [])
return <div />
}
render(<SwipeHarness />)
// Diagonal swipe — large vertical component, should be ignored
fireEvent(window, new TouchEvent('touchstart', {
touches: [{ clientX: 100, clientY: 100 }],
}))
fireEvent(window, new TouchEvent('touchend', {
changedTouches: [{ clientX: 200, clientY: 250 }],
}))
expect(handler).not.toHaveBeenCalled()
})
})
// ─── 5. Modal Test ────────────────────────────────────────────────────────────
describe('ArtworkViewer modal', () => {
afterEach(() => vi.restoreAllMocks())
it('does not render when isOpen=false', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const artwork = { id: 1, title: 'Art', thumbs: {} }
render(
<ArtworkViewer isOpen={false} onClose={() => {}} artwork={artwork} presentLg={null} presentXl={null} />
)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('renders with title when isOpen=true', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const artwork = { id: 1, title: 'My Artwork', thumbs: {} }
render(
<ArtworkViewer
isOpen={true}
onClose={() => {}}
artwork={artwork}
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
presentXl={null}
/>
)
expect(screen.getByRole('dialog')).not.toBeNull()
expect(screen.getByAltText('My Artwork')).not.toBeNull()
expect(screen.getByText('My Artwork')).not.toBeNull()
})
it('calls onClose when clicking the backdrop', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const onClose = vi.fn()
const artwork = { id: 1, title: 'Art', thumbs: {} }
const { container } = render(
<ArtworkViewer
isOpen={true}
onClose={onClose}
artwork={artwork}
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
presentXl={null}
/>
)
// Click the backdrop (the dialog wrapper itself)
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog)
expect(onClose).toHaveBeenCalled()
})
it('does NOT call onClose when clicking the image', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const onClose = vi.fn()
const artwork = { id: 1, title: 'Art', thumbs: {} }
render(
<ArtworkViewer
isOpen={true}
onClose={onClose}
artwork={artwork}
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
presentXl={null}
/>
)
const img = screen.getByRole('img', { name: 'Art' })
fireEvent.click(img)
expect(onClose).not.toHaveBeenCalled()
})
it('calls onClose on ESC keydown', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const onClose = vi.fn()
const artwork = { id: 1, title: 'Art', thumbs: {} }
render(
<ArtworkViewer
isOpen={true}
onClose={onClose}
artwork={artwork}
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
presentXl={null}
/>
)
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('prefers presentXl over presentLg for image src', async () => {
const { default: ArtworkViewer } = await import('./ArtworkViewer')
const artwork = { id: 1, title: 'Art', thumbs: {} }
render(
<ArtworkViewer
isOpen={true}
onClose={() => {}}
artwork={artwork}
presentLg={{ url: 'https://cdn/lg.jpg' }}
presentXl={{ url: 'https://cdn/xl.jpg' }}
/>
)
const img = screen.getByRole('img', { name: 'Art' })
expect(img.getAttribute('src')).toBe('https://cdn/xl.jpg')
})
})

View File

@@ -0,0 +1,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import SearchBar from './Search/SearchBar'
function mount() {
const container = document.getElementById('topbar-search-root')
if (!container) return
createRoot(container).render(<SearchBar />)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount)
} else {
mount()
}

View File

@@ -0,0 +1,124 @@
/**
* Nova Gallery Navigation Context
*
* Stores artwork list context in sessionStorage when a card is clicked,
* so the artwork page can provide prev/next navigation without page reload.
*
* Context shape:
* { source, key, ids: number[], index: number, page: string, ts: number }
*/
(function () {
'use strict';
var STORAGE_KEY = 'nav_ctx';
function getPageContext() {
var path = window.location.pathname;
var search = window.location.search;
// /tag/{slug}
var tagMatch = path.match(/^\/tag\/([^/]+)\/?$/);
if (tagMatch) return { source: 'tag', key: 'tag:' + tagMatch[1] };
// /browse/{contentType}/{category...}
var browseMatch = path.match(/^\/browse\/([^/]+)(?:\/(.+))?\/?$/);
if (browseMatch) {
var browsePart = browseMatch[1] + (browseMatch[2] ? '/' + browseMatch[2] : '');
return { source: 'browse', key: 'browse:' + browsePart };
}
// /search?q=...
if (path === '/search' || path.startsWith('/search?')) {
var q = new URLSearchParams(search).get('q') || '';
return { source: 'search', key: 'search:' + q };
}
// /@{username}
var profileMatch = path.match(/^\/@([^/]+)\/?$/);
if (profileMatch) return { source: 'profile', key: 'profile:' + profileMatch[1] };
// /members/...
if (path.startsWith('/members')) return { source: 'members', key: 'members' };
// home
if (path === '/' || path === '/home') return { source: 'home', key: 'home' };
return { source: 'page', key: 'page:' + path };
}
function collectIds() {
var cards = document.querySelectorAll('article[data-art-id]');
var ids = [];
for (var i = 0; i < cards.length; i++) {
var raw = cards[i].getAttribute('data-art-id');
var id = parseInt(raw, 10);
if (id > 0 && !isNaN(id)) ids.push(id);
}
return ids;
}
function saveContext(artId, ids, context) {
var index = ids.indexOf(artId);
if (index === -1) index = 0;
var ctx = {
source: context.source,
key: context.key,
ids: ids,
index: index,
page: window.location.href,
ts: Date.now(),
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ctx));
} catch (_) {
// quota exceeded or private mode — silently skip
}
}
function findArticle(el) {
var node = el;
while (node && node !== document.body) {
if (node.tagName === 'ARTICLE' && node.hasAttribute('data-art-id')) {
return node;
}
node = node.parentElement;
}
return null;
}
function init() {
// Only act on pages that have artwork cards (not the artwork detail page itself)
var cards = document.querySelectorAll('article[data-art-id]');
if (cards.length === 0) return;
// Don't inject on the artwork detail page (has #artwork-page mount)
if (document.getElementById('artwork-page')) return;
var context = getPageContext();
document.addEventListener(
'click',
function (event) {
var article = findArticle(event.target);
if (!article) return;
// Make sure click was on or inside the card's <a> link
var link = article.querySelector('a[href]');
if (!link) return;
var artId = parseInt(article.getAttribute('data-art-id'), 10);
if (!artId || isNaN(artId)) return;
var currentIds = collectIds();
saveContext(artId, currentIds, context);
},
true // capture phase: store before navigation fires
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,43 @@
/**
* useNavContext
*
* Provides prev/next artwork IDs scoped to the same author via API.
*/
import { useCallback } from 'react';
// Module-level cache for API calls
const fallbackCache = new Map();
async function fetchFallback(artworkId) {
const key = String(artworkId);
if (fallbackCache.has(key)) return fallbackCache.get(key);
try {
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
const data = await res.json();
const result = {
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null,
};
fallbackCache.set(key, result);
return result;
} catch {
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
}
}
export function useNavContext(currentArtworkId) {
/**
* Always resolve via API to guarantee same-user navigation.
*/
const getNeighbors = useCallback(async () => {
return fetchFallback(currentArtworkId);
}, [currentArtworkId]);
return { getNeighbors };
}

View File

@@ -2,6 +2,9 @@
// - dropdown menus via [data-dropdown]
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
// Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js';
(function () {
function initBlurPreviewImages() {
var selector = 'img[data-blur-preview]';

View File

@@ -89,7 +89,9 @@
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"])'>
data-canonical='@json($meta["canonical"])'
data-comments='@json($comments)'
data-is-authenticated='@json(auth()->check())'>
</div>
@vite(['resources/js/Pages/ArtworkPage.jsx'])

View File

@@ -118,7 +118,11 @@
}
@endphp
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject">
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject"
data-art-id="{{ $art->id ?? '' }}"
data-art-url="{{ $cardUrl }}"
data-art-title="{{ e($title) }}"
data-art-img="{{ $imgSrc }}">
<meta itemprop="name" content="{{ $title }}">
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
<meta itemprop="creator" content="{{ $author }}">

View File

@@ -114,7 +114,7 @@
</div>
<section class="px-6 pb-10 pt-8 md:px-10" data-nova-gallery data-gallery-type="{{ $gallery_type ?? 'browse' }}">
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 force-5' }}" data-gallery-grid>
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5' }}" data-gallery-grid>
@forelse ($artworks as $art)
<x-artwork-card
:art="$art"

View File

@@ -28,7 +28,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="shortcut icon" href="/favicon.ico">
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js'])
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
<style>
/* Card enter animation */
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }

View File

@@ -70,18 +70,8 @@
<!-- Search -->
<div class="flex-1 flex items-center justify-center">
<div class="w-full max-w-lg relative">
<input
class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-sm text-white placeholder:text-sb-muted/80 outline-none focus:border-sb-blue/60"
placeholder="Search tags, artworks, artists..." />
<button
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-md hover:bg-white/5 text-sb-muted hover:text-white">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" />
</svg>
</button>
<div class="w-full max-w-lg">
<div id="topbar-search-root"></div>
</div>
</div>

View File

@@ -1,8 +1,50 @@
@extends('layouts.nova')
@php($gridV2 = request()->query('grid') === 'v2')
@php
$uname = $user->username ?? $user->name ?? 'Unknown';
$displayName = $user->name ?? $uname;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128);
$genderMap = [
'M' => ['label' => 'Male', 'icon' => 'fa-mars', 'color' => 'text-blue-400'],
'F' => ['label' => 'Female', 'icon' => 'fa-venus', 'color' => 'text-pink-400'],
'X' => ['label' => 'N/A', 'icon' => 'fa-question', 'color' => 'text-gray-400'],
];
$genderCode = strtoupper((string) ($profile?->gender ?? 'X'));
$gender = $genderMap[$genderCode] ?? $genderMap['X'];
$birthdate = null;
if ($profile?->birthdate) {
try {
$bd = \Carbon\Carbon::parse($profile->birthdate);
if ($bd->year > 1900) {
$birthdate = $bd->format('F d, Y');
}
} catch (\Throwable) {}
}
$website = $profile?->website ?? null;
if ($website && !preg_match('#^https?://#i', $website)) {
$website = 'https://' . $website;
}
$about = $profile?->about ?? null;
$lastVisit = null;
if ($user->last_visit_at) {
try { $lastVisit = \Carbon\Carbon::parse($user->last_visit_at); } catch (\Throwable) {}
}
$socialIcons = [
'twitter' => ['icon' => 'fa-brands fa-x-twitter', 'label' => 'X / Twitter'],
'deviantart' => ['icon' => 'fa-brands fa-deviantart', 'label' => 'DeviantArt'],
'instagram' => ['icon' => 'fa-brands fa-instagram', 'label' => 'Instagram'],
'behance' => ['icon' => 'fa-brands fa-behance', 'label' => 'Behance'],
'artstation' => ['icon' => 'fa-solid fa-palette', 'label' => 'ArtStation'],
'youtube' => ['icon' => 'fa-brands fa-youtube', 'label' => 'YouTube'],
'website' => ['icon' => 'fa-solid fa-link', 'label' => 'Website'],
];
$seoPage = max(1, (int) request()->query('page', 1));
$seoBase = url()->current();
$seoQ = request()->query(); unset($seoQ['page']);
@@ -10,8 +52,7 @@
? '?' . 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;
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl')) ? $artworks->nextPageUrl() : null;
@endphp
@push('head')
@@ -19,47 +60,626 @@
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="index,follow">
<meta property="og:title" content="Profile: {{ e($uname) }} Skinbase.org">
<meta property="og:image" content="{{ $avatarUrl }}">
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
<style>
.profile-hero-bg {
background: linear-gradient(135deg,
rgba(15,23,36,0.98) 0%,
rgba(21,30,46,0.95) 50%,
rgba(9,16,26,0.98) 100%);
position: relative;
}
.profile-hero-bg::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12), transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08), transparent 50%);
pointer-events: none;
}
.nova-panel {
background: var(--panel-dark);
border: 1px solid var(--sb-line);
border-radius: 0.75rem;
overflow: hidden;
}
.nova-panel-header {
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--sb-line);
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.82rem;
color: var(--sb-text);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nova-panel-body { padding: 1rem; }
.stat-item { text-align: center; padding: 0.5rem 0.75rem; }
.stat-item .stat-value {
font-size: 1.15rem;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.stat-item .stat-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--sb-muted);
margin-top: 1px;
}
.profile-table td:first-child {
color: var(--sb-muted);
font-size: 0.75rem;
white-space: nowrap;
padding-right: 0.75rem;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
}
.profile-table td:last-child {
color: var(--sb-text);
font-size: 0.82rem;
text-align: right;
}
.profile-table tr {
border-bottom: 1px solid rgba(42,42,51,0.5);
}
.profile-table tr:last-child { border-bottom: none; }
.profile-table td { vertical-align: middle; }
.comment-avatar {
width: 38px; height: 38px;
border-radius: 50%; object-fit: cover; flex-shrink: 0;
}
.follower-avatar {
width: 34px; height: 34px;
border-radius: 50%; object-fit: cover;
border: 1px solid var(--sb-line);
transition: opacity 0.2s;
}
.follower-avatar:hover { opacity: 0.85; }
.fav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 4px;
}
.fav-grid a img {
width: 100%; aspect-ratio: 1; object-fit: cover;
border-radius: 4px; transition: opacity 0.2s;
display: block;
}
.fav-grid a:hover img { opacity: 0.82; }
.follow-btn { transition: all 0.2s ease; }
</style>
@endpush
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Profile: {{ $user->uname }}</h1>
<p>{{ $user->name ?? '' }}</p>
</header>
</div>
<div class="row">
<div class="col-md-8">
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Newest Artworks</strong></div>
<div class="panel-body">
<div class="{{ $gridV2 ? 'gallery' : 'gallery-grid' }}" data-nova-gallery data-gallery-type="profile" data-gallery-grid>
@foreach($artworks as $art)
<x-artwork-card :art="$art" />
@endforeach
</div>
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
<x-skeleton.artwork-card />
</div>
<div class="hidden mt-8" data-gallery-skeleton></div>
</div>
</div>
</div>
{{-- ═══════════════════════════════════════════════════════════
PROFILE HERO
═══════════════════════════════════════════════════════════ --}}
<div class="profile-hero-bg border-b border-[--sb-line]">
<div class="relative z-10 max-w-screen-xl mx-auto px-4 py-8">
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-5">
<div class="col-md-4">
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>User</strong></div>
<div class="panel-body">
<img src="{{ \App\Support\AvatarUrl::forUser((int) $user->user_id, null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
<h3>{{ $user->uname }}</h3>
<p>{{ $user->about_me ?? '' }}</p>
</div>
{{-- Avatar --}}
<div class="shrink-0">
<img src="{{ $avatarUrl }}"
alt="{{ e($uname) }}"
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-[--sb-line] shadow-lg">
</div>
{{-- Name + meta --}}
<div class="flex-1 text-center sm:text-left min-w-0">
<h1 class="text-2xl sm:text-3xl font-bold text-white leading-tight truncate">
{{ e($uname) }}
</h1>
@if($displayName && $displayName !== $uname)
<p class="text-[--sb-muted] text-sm mt-0.5">{{ e($displayName) }}</p>
@endif
@if($countryName)
<p class="text-[--sb-muted] text-sm mt-1 flex items-center justify-center sm:justify-start gap-1.5">
@if($profile?->country_code)
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
alt="{{ e($countryName) }}"
class="w-5 h-auto rounded-sm inline-block"
onerror="this.style.display='none'">
@endif
{{ e($countryName) }}
</p>
@endif
@if($lastVisit)
<p class="text-[--sb-muted] text-xs mt-1">
<i class="fa-solid fa-clock fa-fw mr-1"></i>
Last seen {{ $lastVisit->diffForHumans() }}
</p>
@endif
</div>
{{-- Action buttons --}}
<div class="shrink-0 flex flex-col gap-2 items-center sm:items-end">
@if(!$isOwner)
@auth
<div x-data="{
following: {{ $viewerIsFollowing ? 'true' : 'false' }},
count: {{ (int) $followerCount }},
loading: false,
async toggle() {
this.loading = true;
try {
const r = await fetch('{{ route('profile.follow', ['username' => strtolower((string)$uname)]) }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json'
}
});
const d = await r.json();
if (r.ok) { this.following = d.following; this.count = d.follower_count; }
} catch(e) {}
this.loading = false;
}
}">
<button @click="toggle" :disabled="loading" class="follow-btn inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-all"
:class="following
? 'bg-green-500/10 border-green-500/40 text-green-400 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-400'
: 'bg-[--sb-blue]/10 border-[--sb-blue]/40 text-[--sb-blue] hover:bg-[--sb-blue]/20'">
<i class="fa-solid fa-fw"
:class="loading ? 'fa-circle-notch fa-spin' : (following ? 'fa-user-check' : 'fa-user-plus')"></i>
<span x-text="following ? 'Following' : 'Follow'"></span>
<span class="text-xs opacity-60" x-text="'(' + count + ')'"></span>
</button>
</div>
@else
<a href="{{ route('login') }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[--sb-blue]/40 text-[--sb-blue] hover:bg-[--sb-blue]/10 transition-all">
<i class="fa-solid fa-user-plus fa-fw"></i> Follow
<span class="text-xs opacity-60">({{ $followerCount }})</span>
</a>
@endauth
@else
<a href="{{ route('dashboard.profile') }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[--sb-line] text-[--sb-text] hover:bg-white/5 transition-all">
<i class="fa-solid fa-pen fa-fw"></i> Edit Profile
</a>
@endif
</div>
</div>
</div>
</div>
{{-- ═══════════════════════════════════════════════════════════
STATS STRIP
═══════════════════════════════════════════════════════════ --}}
<div class="bg-[--sb-panel2] border-b border-[--sb-line]">
<div class="max-w-screen-xl mx-auto px-4">
<div class="flex divide-x divide-[--sb-line] overflow-x-auto">
@foreach([
['value' => number_format($stats->uploads ?? 0), 'label' => 'Uploads', 'icon' => 'fa-cloud-arrow-up'],
['value' => number_format($stats->downloads ?? 0), 'label' => 'Downloads', 'icon' => 'fa-download'],
['value' => number_format($stats->profile_views ?? 0), 'label' => 'Profile Views', 'icon' => 'fa-eye'],
['value' => number_format($followerCount), 'label' => 'Followers', 'icon' => 'fa-users'],
['value' => number_format($stats->awards ?? 0), 'label' => 'Awards', 'icon' => 'fa-trophy'],
] as $si)
<div class="stat-item flex-1 py-3">
<div class="stat-value">{{ $si['value'] }}</div>
<div class="stat-label">
<i class="fa-solid {{ $si['icon'] }} fa-fw mr-0.5"></i>{{ $si['label'] }}
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- ═══════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════ --}}
<div class="max-w-screen-xl mx-auto px-4 py-6">
<div class="flex flex-col lg:flex-row gap-5">
{{-- ─── LEFT COLUMN (artworks) ─── --}}
<div class="flex-1 min-w-0 space-y-5">
{{-- Featured Artworks --}}
@if(isset($featuredArtworks) && $featuredArtworks->isNotEmpty())
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-star text-yellow-400 fa-fw"></i>
Featured Artworks
</div>
<div class="nova-panel-body">
<div class="flex flex-col md:flex-row gap-4">
@php $feat = $featuredArtworks->first() @endphp
{{-- Main featured --}}
<a href="/art/{{ $feat->id }}/{{ \Illuminate\Support\Str::slug($feat->name) }}"
class="flex-1 group block min-w-0">
<div class="overflow-hidden rounded-lg bg-black">
<img src="{{ $feat->thumb }}"
alt="{{ e($feat->name) }}"
class="w-full object-cover transition-transform duration-300 group-hover:scale-105"
style="aspect-ratio:4/3;">
</div>
<h4 class="mt-2 text-sm font-medium text-white truncate">{{ e($feat->name) }}</h4>
@if($feat->label)
<p class="text-xs text-[--sb-muted]">{{ e($feat->label) }}</p>
@endif
@if($feat->featured_at)
<p class="text-xs text-[--sb-muted] mt-0.5">
<i class="fa-solid fa-calendar fa-fw"></i>
Featured {{ \Carbon\Carbon::parse($feat->featured_at)->format('d M, Y') }}
</p>
@endif
</a>
{{-- Side featured (2nd & 3rd) --}}
@if($featuredArtworks->count() > 1)
<div class="md:w-44 space-y-2">
@foreach($featuredArtworks->slice(1) as $sideArt)
<a href="/art/{{ $sideArt->id }}/{{ \Illuminate\Support\Str::slug($sideArt->name) }}"
class="block group">
<div class="overflow-hidden rounded-md bg-black">
<img src="{{ $sideArt->thumb }}"
alt="{{ e($sideArt->name) }}"
class="w-full object-cover transition-transform duration-300 group-hover:scale-105"
style="aspect-ratio:16/9;">
</div>
<p class="text-xs text-[--sb-muted] mt-1 truncate">{{ e($sideArt->name) }}</p>
</a>
@endforeach
</div>
@endif
</div>
</div>
</div>
@endif
{{-- Newest Artworks --}}
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-images fa-fw text-[--sb-blue]"></i>
Newest Artworks
<a href="/gallery/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
View Gallery <i class="fa-solid fa-arrow-right fa-fw"></i>
</a>
</div>
<div class="nova-panel-body">
@if(isset($artworks) && !$artworks->isEmpty())
<div class="gallery-grid"
data-nova-gallery
data-gallery-type="profile"
data-gallery-grid
data-profile-id="{{ $user->id }}">
@foreach($artworks as $art)
<x-artwork-card :art="$art" />
@endforeach
</div>
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
<x-skeleton.artwork-card />
</div>
<div class="hidden mt-6" data-gallery-skeleton></div>
@else
<p class="text-[--sb-muted] text-sm text-center py-8">
<i class="fa-solid fa-image fa-2x mb-3 block opacity-20"></i>
No artworks yet.
</p>
@endif
</div>
</div>
{{-- Favourites --}}
@if(isset($favourites) && $favourites->isNotEmpty())
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-heart fa-fw text-pink-400"></i>
Favourites
</div>
<div class="nova-panel-body">
<div class="fav-grid">
@foreach($favourites as $fav)
<a href="/art/{{ $fav->id }}/{{ \Illuminate\Support\Str::slug($fav->name) }}"
title="{{ e($fav->name) }}">
<img src="{{ $fav->thumb }}" alt="{{ e($fav->name) }}" loading="lazy">
</a>
@endforeach
</div>
</div>
</div>
@endif
</div>{{-- end left --}}
{{-- ─── RIGHT SIDEBAR ─── --}}
<div class="lg:w-80 xl:w-96 shrink-0 space-y-4">
{{-- Profile Info --}}
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-id-card fa-fw text-[--sb-blue]"></i>
Profile
</div>
<div class="nova-panel-body">
<table class="profile-table w-full">
<tr>
<td>Username</td>
<td>{{ e($uname) }}</td>
</tr>
@if($displayName && $displayName !== $uname)
<tr><td>Real Name</td><td>{{ e($displayName) }}</td></tr>
@endif
<tr>
<td>Gender</td>
<td>
<i class="fa-solid {{ $gender['icon'] }} fa-fw {{ $gender['color'] }}"></i>
{{ $gender['label'] }}
</td>
</tr>
@if($birthdate)
<tr><td>Birthday</td><td>{{ $birthdate }}</td></tr>
@endif
@if($countryName)
<tr>
<td>Country</td>
<td class="flex items-center justify-end gap-1.5">
@if($profile?->country_code)
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
alt="{{ e($countryName) }}"
class="w-4 h-auto rounded-sm"
onerror="this.style.display='none'">
@endif
{{ e($countryName) }}
</td>
</tr>
@endif
@if($website)
<tr>
<td>Website</td>
<td>
<a href="{{ e($website) }}" rel="nofollow noopener" target="_blank"
class="text-[--sb-blue] hover:underline text-xs inline-flex items-center gap-1">
<i class="fa-solid fa-link fa-fw"></i>
{{ e(parse_url($website, PHP_URL_HOST) ?? $website) }}
</a>
</td>
</tr>
@endif
@if($lastVisit)
<tr>
<td>Last Activity</td>
<td class="text-[11px]">
{{ $lastVisit->format('d.M.Y') }}
<i class="fa-solid fa-clock fa-fw ml-1 opacity-60"></i>
{{ $lastVisit->format('H:i') }}
</td>
</tr>
@endif
<tr>
<td>Member since</td>
<td>{{ $user->created_at ? $user->created_at->format('M Y') : 'N/A' }}</td>
</tr>
</table>
</div>
</div>
{{-- About Me --}}
@if($about)
<div class="nova-panel" x-data="{ expanded: false }">
<div class="nova-panel-header">
<i class="fa-solid fa-quote-left fa-fw text-purple-400"></i>
About Me
</div>
<div class="nova-panel-body">
<div class="text-sm text-[--sb-text] leading-relaxed"
:class="expanded ? '' : 'line-clamp-6'">
{!! nl2br(e($about)) !!}
</div>
@if(strlen($about) > 300)
<button @click="expanded = !expanded"
class="mt-2 text-xs text-[--sb-blue] hover:underline">
<span x-text="expanded ? '↑ Show less' : '↓ Read more'"></span>
</button>
@endif
</div>
</div>
@endif
{{-- Statistics --}}
@if($stats)
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-chart-bar fa-fw text-green-400"></i>
Statistics
</div>
<div class="nova-panel-body p-0">
<table class="profile-table w-full">
@foreach([
['Profile Views', number_format($stats->profile_views ?? 0), null],
['Uploads', number_format($stats->uploads ?? 0), null],
['Downloads', number_format($stats->downloads ?? 0), null],
['Page Views', number_format($stats->pageviews ?? 0), null],
['Featured Works',number_format($stats->awards ?? 0), 'fa-star text-yellow-400'],
] as [$label, $value, $iconClass])
<tr>
<td class="pl-4">{{ $label }}</td>
<td class="pr-4">
{{ $value }}
@if($iconClass)<i class="fa-solid {{ $iconClass }} text-xs ml-1"></i>@endif
</td>
</tr>
@endforeach
</table>
</div>
</div>
@endif
{{-- Social Links --}}
@if(isset($socialLinks) && $socialLinks->isNotEmpty())
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-share-nodes fa-fw text-[--sb-blue]"></i>
Social Links
</div>
<div class="nova-panel-body flex flex-wrap gap-2">
@foreach($socialLinks as $platform => $link)
@php
$si = $socialIcons[$platform] ?? ['icon' => 'fa-solid fa-link', 'label' => ucfirst($platform)];
$href = str_starts_with($link->url, 'http') ? $link->url : ('https://' . $link->url);
@endphp
<a href="{{ e($href) }}" rel="nofollow noopener" target="_blank"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs border border-[--sb-line] text-[--sb-text] hover:bg-white/5 hover:border-[--sb-blue]/40 transition-all">
<i class="{{ $si['icon'] }} fa-fw"></i>
{{ $si['label'] }}
</a>
@endforeach
</div>
</div>
@endif
{{-- Recent Followers --}}
@if(isset($recentFollowers) && $recentFollowers->isNotEmpty())
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-users fa-fw text-[--sb-blue]"></i>
Followers
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
{{ number_format($followerCount) }}
</span>
<a href="/following/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
All
</a>
</div>
<div class="nova-panel-body">
<div class="flex flex-wrap gap-1.5">
@foreach($recentFollowers as $follower)
<a href="{{ $follower->profile_url }}" title="{{ e($follower->uname) }}">
<img src="{{ $follower->avatar_url }}"
alt="{{ e($follower->uname) }}"
class="follower-avatar"
onerror="this.src='{{ \App\Support\AvatarUrl::default() }}'">
</a>
@endforeach
</div>
</div>
</div>
@elseif($followerCount > 0)
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-users fa-fw text-[--sb-blue]"></i>
Followers
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
{{ number_format($followerCount) }}
</span>
</div>
</div>
@endif
{{-- Profile Comments --}}
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-comments fa-fw text-orange-400"></i>
Comments
@if(isset($profileComments) && $profileComments->isNotEmpty())
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
{{ $profileComments->count() }}
</span>
@endif
</div>
<div class="nova-panel-body">
@if(!isset($profileComments) || $profileComments->isEmpty())
<p class="text-[--sb-muted] text-xs text-center py-3">No comments yet.</p>
@else
<div class="space-y-4">
@foreach($profileComments as $comment)
<div class="flex gap-3">
<a href="{{ $comment->author_profile_url }}" class="shrink-0">
<img src="{{ $comment->author_avatar }}"
alt="{{ e($comment->author_name) }}"
class="comment-avatar"
onerror="this.src='{{ \App\Support\AvatarUrl::default() }}'">
</a>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<a href="{{ $comment->author_profile_url }}"
class="text-xs font-semibold text-[--sb-text] hover:text-[--sb-blue] transition-colors">
{{ e($comment->author_name) }}
</a>
<span class="text-[--sb-muted] text-[10px] ml-auto whitespace-nowrap">
{{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}
</span>
</div>
<p class="text-xs text-[--sb-text] leading-relaxed break-words">
{!! nl2br(e($comment->body)) !!}
</p>
@if(!empty($comment->author_signature))
<p class="text-[--sb-muted] text-[10px] mt-1 italic border-t border-[--sb-line] pt-1 opacity-70">
{!! nl2br(e($comment->author_signature)) !!}
</p>
@endif
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Write Comment --}}
@auth
@if(auth()->id() !== $user->id)
<div class="nova-panel">
<div class="nova-panel-header">
<i class="fa-solid fa-pen fa-fw text-[--sb-blue]"></i>
Write a Comment
</div>
<div class="nova-panel-body">
@if(session('status') === 'Comment posted!')
<div class="mb-3 px-3 py-2 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-xs">
<i class="fa-solid fa-check fa-fw"></i> Comment posted!
</div>
@endif
<form method="POST"
action="{{ route('profile.comment', ['username' => strtolower((string)$uname)]) }}">
@csrf
<textarea name="body" rows="4" required minlength="2" maxlength="2000"
placeholder="Write a comment for {{ e($uname) }}..."
class="w-full bg-[--sb-bg] border border-[--sb-line] rounded-lg px-3 py-2 text-sm text-[--sb-text] placeholder:text-[--sb-muted]/60 resize-none focus:outline-none focus:border-[--sb-blue]/50 transition-colors"
>{{ old('body') }}</textarea>
@error('body')
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
@enderror
<div class="mt-2 text-right">
<button type="submit"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[--sb-blue]/90 hover:bg-[--sb-blue] text-white transition-colors">
<i class="fa-solid fa-paper-plane fa-fw"></i>
Post Comment
</button>
</div>
</form>
</div>
</div>
@endif
@else
<div class="nova-panel">
<div class="nova-panel-body text-center py-4">
<p class="text-[--sb-muted] text-sm">
<a href="{{ route('login') }}" class="text-[--sb-blue] hover:underline">Log in</a>
to leave a comment.
</p>
</div>
</div>
@endauth
</div>{{-- end right sidebar --}}
</div>{{-- end flex --}}
</div>{{-- end container --}}
@endsection
@push('scripts')

View File

@@ -0,0 +1,78 @@
@extends('layouts.nova')
@push('head')
<meta name="robots" content="noindex,follow">
<meta name="description" content="Search Skinbase artworks, photography, wallpapers and skins.">
@endpush
@section('content')
<div class="px-6 py-8 md:px-10" id="search-page" data-q="{{ $q ?? '' }}">
{{-- Search header --}}
<div class="mb-8 max-w-2xl">
<h1 class="text-2xl font-bold text-white mb-2">Search</h1>
<form action="/search" method="GET" class="relative" role="search">
<input
type="search"
name="q"
value="{{ $q ?? '' }}"
placeholder="Search artworks, artists, tags…"
autofocus
class="w-full bg-white/[0.05] border border-white/10 rounded-xl py-3 pl-4 pr-12 text-white placeholder-neutral-500 outline-none focus:border-sky-500 transition-colors"
>
<button type="submit" class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-sky-400 transition-colors">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
</button>
</form>
</div>
@if(isset($q) && $q !== '')
{{-- Sort + filter bar --}}
<div class="flex flex-wrap items-center gap-3 mb-6">
<span class="text-sm text-neutral-400">Sort by:</span>
@foreach(['latest' => 'Newest', 'popular' => 'Most viewed', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label)
<a href="{{ request()->fullUrlWithQuery(['sort' => $key]) }}"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{{ ($sort ?? 'latest') === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}">
{{ $label }}
</a>
@endforeach
@if($artworks->total() > 0)
<span class="ml-auto text-sm text-neutral-500">
{{ number_format($artworks->total()) }} {{ Str::plural('result', $artworks->total()) }}
</span>
@endif
</div>
{{-- Results grid --}}
@if($artworks->isEmpty())
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-14 text-center">
<p class="text-neutral-400 text-lg mb-2">No results for <span class="text-white">"{{ $q }}"</span></p>
<p class="text-sm text-neutral-500">Try a different keyword or browse by <a href="/browse" class="text-sky-400 hover:underline">category</a>.</p>
</div>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach($artworks as $artwork)
<x-artwork-card :art="$artwork" />
@endforeach
</div>
<div class="flex justify-center mt-10">
{{ $artworks->appends(request()->query())->links('pagination::tailwind') }}
</div>
@endif
@else
{{-- No query: show popular --}}
<div class="mb-4 flex items-center gap-2">
<span class="text-sm font-semibold text-white/70 uppercase tracking-wide">Popular right now</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach($popular as $artwork)
<x-artwork-card :art="$artwork" />
@endforeach
</div>
@endif
</div>
@endsection

View File

@@ -1,37 +1,83 @@
@extends('layouts.nova')
@push('head')
<link rel="canonical" href="{{ $page_canonical }}">
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
@if(!empty($ogImage))
<meta property="og:image" content="{{ $ogImage }}">
<meta property="og:image:alt" content="{{ $tag->name }} artworks on Skinbase">
@endif
<meta property="og:title" content="{{ $page_title }}">
<meta property="og:description" content="{{ $page_meta_description }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ $page_canonical }}">
<script type="application/ld+json">{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => 'Artworks tagged "' . $tag->name . '"',
'description' => $page_meta_description,
'url' => $page_canonical,
'image' => $ogImage,
'hasPart' => $artworks->getCollection()->take(6)->map(fn($a) => [
'@type' => 'ImageObject',
'name' => $a->title,
'url' => url('/' . ($a->slug ?? $a->id)),
'thumbnail' => $a->thumbUrl('sm'),
])->values()->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
@if($artworks->previousPageUrl())
<link rel="prev" href="{{ $artworks->previousPageUrl() }}">
@endif
@if($artworks->nextPageUrl())
<link rel="next" href="{{ $artworks->nextPageUrl() }}">
@endif
@endpush
@section('content')
<div class="container legacy-page">
<div class="effect2">
<div class="page-heading">
<h1 class="page-header">Tag: {{ $tag->name }}</h1>
<p class="text-muted">Browse artworks tagged with {{ $tag->name }}.</p>
<div class="px-6 py-8 md:px-10">
{{-- Header --}}
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg 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 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
</span>
<h1 class="text-2xl font-bold text-white">{{ $tag->name }}</h1>
</div>
<p class="text-sm text-neutral-400">
{{ number_format($artworks->total()) }} {{ Str::plural('artwork', $artworks->total()) }}
</p>
</div>
<div class="panel panel-skinbase effect2">
<div class="panel-body">
@if($artworks->isEmpty())
<div class="alert alert-info">No artworks found for this tag.</div>
@else
<div class="row">
@foreach($artworks as $artwork)
<div class="col-xs-6 col-sm-4 col-md-3" style="margin-bottom:16px">
<a href="/{{ $artwork->slug }}" title="{{ $artwork->title }}" style="display:block">
<img src="{{ $artwork->thumb_url ?? $artwork->thumb }}" class="img-responsive img-thumbnail" alt="{{ $artwork->title }}" style="width:100%;height:160px;object-fit:cover">
</a>
<div style="margin-top:6px;font-weight:700;line-height:1.2">
<a href="/{{ $artwork->slug }}">{{ str($artwork->title)->limit(60) }}</a>
</div>
</div>
@endforeach
</div>
<div class="paginationMenu text-center">
{{ $artworks->links('pagination::bootstrap-3') }}
</div>
@endif
</div>
{{-- Sort controls --}}
<div class="flex items-center gap-2 flex-wrap">
@foreach(['popular' => 'Most viewed', 'latest' => 'Newest', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label)
<a href="{{ route('tags.show', [$tag->slug, 'sort' => $key]) }}"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{{ $sort === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}">
{{ $label }}
</a>
@endforeach
</div>
</div>
{{-- Grid --}}
@if($artworks->isEmpty())
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-10 text-center text-neutral-400">
No artworks found for this tag yet.
</div>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach($artworks as $artwork)
<x-artwork-card :art="$artwork" />
@endforeach
</div>
<div class="flex justify-center mt-10">
{{ $artworks->appends(['sort' => $sort])->links('pagination::tailwind') }}
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,112 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
<h1 class="text-3xl font-bold text-white leading-tight">Top Authors</h1>
<p class="mt-1 text-sm text-white/50">Most popular members ranked by artwork {{ $metric === 'downloads' ? 'downloads' : 'views' }}.</p>
</div>
{{-- Metric switcher --}}
<nav class="flex items-center gap-2" aria-label="Ranking metric">
<a href="{{ request()->fullUrlWithQuery(['metric' => 'views']) }}"
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
{{ $metric === 'views' ? 'bg-sky-500/15 text-sky-300 border-sky-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
<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="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>
Views
</a>
<a href="{{ request()->fullUrlWithQuery(['metric' => 'downloads']) }}"
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
{{ $metric === 'downloads' ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Downloads
</a>
</nav>
</div>
</div>
{{-- ── Leaderboard ── --}}
<div class="px-6 pb-16 md:px-10">
@php $offset = ($authors->currentPage() - 1) * $authors->perPage(); @endphp
@if ($authors->isNotEmpty())
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
{{-- Table header --}}
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Author</span>
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">
{{ $metric === 'downloads' ? 'Downloads' : 'Views' }}
</span>
</div>
{{-- Rows --}}
<div class="divide-y divide-white/[0.04]">
@foreach ($authors as $i => $author)
@php
$rank = $offset + $i + 1;
$profileUrl = ($author->username ?? null)
? '/@' . $author->username
: '/profile/' . (int) $author->user_id;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $author->user_id, null, 40);
@endphp
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
{{-- Rank badge --}}
<div class="text-center">
@if ($rank === 1)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400/15 text-amber-300 text-xs font-bold ring-1 ring-amber-400/30">1</span>
@elseif ($rank === 2)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-400/15 text-slate-300 text-xs font-bold ring-1 ring-slate-400/30">2</span>
@elseif ($rank === 3)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-orange-700/20 text-orange-400 text-xs font-bold ring-1 ring-orange-600/30">3</span>
@else
<span class="text-sm text-white/30 font-medium">{{ $rank }}</span>
@endif
</div>
{{-- Author info --}}
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
<img src="{{ $avatarUrl }}" alt="{{ $author->uname }}"
class="w-9 h-9 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.08]">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-white/90">{{ $author->uname ?? 'Unknown' }}</div>
@if (!empty($author->username))
<div class="truncate text-xs text-white/35">{{ '@' . $author->username }}</div>
@endif
</div>
</a>
{{-- Metric count --}}
<div class="text-right flex-shrink-0">
<span class="text-sm font-semibold {{ $metric === 'downloads' ? 'text-emerald-400' : 'text-sky-400' }}">
{{ number_format($author->total ?? 0) }}
</span>
</div>
</div>
@endforeach
</div>
</div>
<div class="mt-8 flex justify-center">
{{ $authors->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No authors found.</p>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,82 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
<h1 class="text-3xl font-bold text-white leading-tight">Latest Comments</h1>
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
</div>
</div>
{{-- ── Comment cards grid ── --}}
<div class="px-6 pb-16 md:px-10">
@if ($comments->isNotEmpty())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@foreach ($comments as $comment)
@php
$artUrl = '/art/' . (int)($comment->id ?? 0) . '/' . ($comment->artwork_slug ?? 'artwork');
$userUrl = '/profile/' . (int)($comment->commenter_id ?? 0) . '/' . rawurlencode($comment->uname ?? 'user');
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($comment->commenter_id ?? 0), $comment->icon ?? null, 40);
$ago = \Carbon\Carbon::parse($comment->datetime ?? now())->diffForHumans();
$snippet = \Illuminate\Support\Str::limit(strip_tags($comment->comment_description ?? ''), 160);
@endphp
<article class="flex flex-col rounded-xl border border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05] transition-all duration-200 overflow-hidden">
{{-- Artwork thumbnail --}}
@if (!empty($comment->thumb))
<a href="{{ $artUrl }}" class="block overflow-hidden bg-neutral-900 flex-shrink-0">
<img src="{{ $comment->thumb }}" alt="{{ $comment->name ?? 'Artwork' }}"
class="w-full h-36 object-cover transition-transform duration-300 hover:scale-[1.03]"
loading="lazy">
</a>
@endif
<div class="flex flex-col flex-1 p-4 gap-3">
{{-- Commenter row --}}
<div class="flex items-center gap-2.5">
<a href="{{ $userUrl }}" class="flex-shrink-0">
<img src="{{ $avatarUrl }}" alt="{{ $comment->uname ?? 'User' }}"
class="w-8 h-8 rounded-full object-cover ring-1 ring-white/[0.08]">
</a>
<div class="min-w-0 flex-1">
<a href="{{ $userUrl }}" class="block truncate text-xs font-semibold text-white/85 hover:text-white transition-colors">
{{ $comment->uname ?? 'Unknown' }}
</a>
<span class="text-[10px] text-white/35">{{ $ago }}</span>
</div>
</div>
{{-- Comment text --}}
<p class="flex-1 text-sm text-white/60 leading-relaxed line-clamp-4">{{ $snippet }}</p>
{{-- Artwork link footer --}}
@if (!empty($comment->name))
<a href="{{ $artUrl }}"
class="mt-auto inline-flex items-center gap-1.5 text-xs text-sky-400/70 hover:text-sky-300 transition-colors truncate">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="truncate">{{ $comment->name }}</span>
</a>
@endif
</div>
</article>
@endforeach
</div>
<div class="mt-10 flex justify-center">
{{ $comments->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No comments found.</p>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,90 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
<h1 class="text-3xl font-bold text-white leading-tight">Monthly Top Commentators</h1>
<p class="mt-1 text-sm text-white/50">Members who posted the most comments in the last 30 days.</p>
</div>
<span class="flex-shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-violet-500/10 text-violet-300 ring-1 ring-violet-500/25">
<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="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>
Last 30 days
</span>
</div>
</div>
{{-- ── Leaderboard ── --}}
<div class="px-6 pb-16 md:px-10">
@php $offset = ($rows->currentPage() - 1) * $rows->perPage(); @endphp
@if ($rows->isNotEmpty())
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
{{-- Table header --}}
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Member</span>
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Comments</span>
</div>
{{-- Rows --}}
<div class="divide-y divide-white/[0.04]">
@foreach ($rows as $i => $row)
@php
$rank = $offset + $i + 1;
$profileUrl = '/profile/' . (int)($row->user_id ?? 0) . '/' . rawurlencode($row->uname ?? 'user');
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 40);
@endphp
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
{{-- Rank badge --}}
<div class="text-center">
@if ($rank === 1)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400/15 text-amber-300 text-xs font-bold ring-1 ring-amber-400/30">1</span>
@elseif ($rank === 2)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-400/15 text-slate-300 text-xs font-bold ring-1 ring-slate-400/30">2</span>
@elseif ($rank === 3)
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-orange-700/20 text-orange-400 text-xs font-bold ring-1 ring-orange-600/30">3</span>
@else
<span class="text-sm text-white/30 font-medium">{{ $rank }}</span>
@endif
</div>
{{-- Member info --}}
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
<img src="{{ $avatarUrl }}" alt="{{ $row->uname ?? 'User' }}"
class="w-9 h-9 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.08]">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-white/90">{{ $row->uname ?? 'Unknown' }}</div>
</div>
</a>
{{-- Comment count --}}
<div class="text-right flex-shrink-0">
<span class="text-sm font-semibold text-violet-400">
{{ number_format((int)($row->num_comments ?? 0)) }}
</span>
</div>
</div>
@endforeach
</div>
</div>
<div class="mt-8 flex justify-center">
{{ $rows->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No comment activity in the last 30 days.</p>
</div>
@endif
</div>
@endsection

View File

@@ -1,47 +1,111 @@
@extends('layouts.nova')
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Daily Uploads</h1>
<p>List of all latest uploaded Artworks - <strong>Skins</strong>, <strong>Photography</strong> and <strong>Wallpapers</strong> to Skinbase ordered by upload date.</p>
</header>
</div>
<div class="panel panel-default uploads-panel effect2">
<div class="panel-body">
<b>Choose date:</b>
<ul id="recentTab">
@foreach($dates as $i => $d)
<li id="tab-{{ $i+1 }}" data-iso="{{ $d['iso'] }}">{{ $d['label'] }}</li>
@endforeach
</ul>
<div id="myContent">
@include('web.partials.daily-uploads-grid', ['arts' => $recent])
</div>
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
<h1 class="text-3xl font-bold text-white leading-tight">Daily Uploads</h1>
<p class="mt-1 text-sm text-white/50">Browse all artworks uploaded on a specific date.</p>
</div>
<a href="{{ route('uploads.latest') }}"
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Latest Uploads
</a>
</div>
</div>
{{-- ── Date strip ── --}}
<div class="px-6 md:px-10 pb-5">
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none" id="dateStrip">
@foreach($dates as $i => $d)
<button type="button"
data-iso="{{ $d['iso'] }}"
id="tab-{{ $i+1 }}"
class="flex-shrink-0 rounded-lg px-3.5 py-1.5 text-xs font-medium border transition-colors
{{ $i === 0
? 'bg-sky-500/15 text-sky-300 border-sky-500/30 active-date-tab'
: 'border-white/[0.08] bg-white/[0.03] text-white/50 hover:text-white hover:bg-white/[0.07]' }}">
@if ($i === 0)
Today
@elseif ($i === 1)
Yesterday
@else
{{ \Carbon\Carbon::parse($d['iso'])->format('M j') }}
@endif
</button>
@endforeach
</div>
</div>
{{-- ── Active date label ── --}}
<div class="px-6 md:px-10 mb-4">
<p id="activeDateLabel" class="text-sm text-white/40">
Showing uploads from <strong class="text-white/70">{{ $dates[0]['label'] ?? 'today' }}</strong>
</p>
</div>
{{-- ── Grid container ── --}}
<div id="myContent" class="px-6 pb-16 md:px-10 min-h-48">
@include('web.partials.daily-uploads-grid', ['arts' => $recent])
</div>
{{-- ── Loading overlay (hidden) ── --}}
<template id="loadingTpl">
<div class="flex items-center justify-center py-20 text-white/30 text-sm gap-2">
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Loading artworks…
</div>
</template>
@push('scripts')
<script>
(function(){
function loadDate(iso, tabId){
var el = document.getElementById('myContent');
fetch('/daily-uploads?ajax=1&datum=' + encodeURIComponent(iso))
.then(function(r){ return r.text(); })
.then(function(html){ el.innerHTML = html; });
}
(function () {
var endpoint = '/uploads/daily';
var strip = document.getElementById('dateStrip');
var content = document.getElementById('myContent');
var dateLabel = document.getElementById('activeDateLabel');
var loadingTpl = document.getElementById('loadingTpl');
document.getElementById('recentTab').addEventListener('click', function(e){
var li = e.target.closest('li');
if (!li) return;
var iso = li.getAttribute('data-iso');
loadDate(iso, li.id);
function setActive(btn) {
strip.querySelectorAll('button').forEach(function (b) {
b.classList.remove('bg-sky-500/15', 'text-sky-300', 'border-sky-500/30', 'active-date-tab');
b.classList.add('border-white/[0.08]', 'bg-white/[0.03]', 'text-white/50');
});
})();
btn.classList.add('bg-sky-500/15', 'text-sky-300', 'border-sky-500/30', 'active-date-tab');
btn.classList.remove('border-white/[0.08]', 'bg-white/[0.03]', 'text-white/50');
}
function loadDate(iso, label) {
content.innerHTML = loadingTpl.innerHTML;
dateLabel.innerHTML = 'Showing uploads from <strong class="text-white/70">' + label + '</strong>';
fetch(endpoint + '?ajax=1&datum=' + encodeURIComponent(iso), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.text(); })
.then(function (html) { content.innerHTML = html; })
.catch(function () {
content.innerHTML = '<p class="text-center text-white/30 py-16 text-sm">Failed to load artworks.</p>';
});
}
strip.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-iso]');
if (!btn || btn.classList.contains('active-date-tab')) return;
setActive(btn);
var label = btn.textContent.trim();
loadDate(btn.getAttribute('data-iso'), label);
});
})();
</script>
@endpush

View File

@@ -0,0 +1,73 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Downloads</p>
<h1 class="text-3xl font-bold text-white leading-tight">Most Downloaded Today</h1>
<p class="mt-1 text-sm text-white/50">
Artworks downloaded the most on <time datetime="{{ now()->toDateString() }}">{{ now()->format('d F Y') }}</time>.
</p>
</div>
<div class="flex-shrink-0 flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/25">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
Live today
</span>
</div>
</div>
</div>
{{-- ── Artwork grid ── --}}
<div class="px-6 pb-16 md:px-10">
@if ($artworks && $artworks->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
@foreach ($artworks as $art)
@php
$card = (object)[
'id' => $art->id ?? null,
'name' => $art->name ?? 'Artwork',
'thumb' => $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'category_name' => $art->category_name ?? '',
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
];
$downloads = (int) ($art->num_downloads ?? 0);
@endphp
{{-- Wrap card to overlay download badge --}}
<div class="relative">
<x-artwork-card :art="$card" />
@if ($downloads > 0)
<div class="absolute top-2 left-2 z-40 pointer-events-none">
<span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold bg-black/60 text-emerald-300 backdrop-blur-sm ring-1 ring-emerald-500/30">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ number_format($downloads) }}
</span>
</div>
@endif
</div>
@endforeach
</div>
<div class="mt-10 flex justify-center">
{{ $artworks->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<svg class="mx-auto mb-3 w-10 h-10 text-white/20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<p class="text-white/40 text-sm">No downloads recorded today yet.</p>
<p class="text-white/25 text-xs mt-1">Check back later as the day progresses.</p>
</div>
@endif
</div>
@endsection

View File

@@ -7,7 +7,7 @@
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="min-h-screen">
@include('web.home.featured')
@include('web.home.uploads')

View File

@@ -1,37 +1,46 @@
{{-- Featured row use Nova cards for consistent layout with browse/gallery --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@if(!empty($featured))
<div>
@include('web.partials._artwork_card', ['art' => $featured])
</div>
@else
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Featured Artwork</strong></div>
<div class="panel-body text-neutral-400">No featured artwork set.</div>
</div>
@endif
<section class="px-6 pt-8 pb-6 md:px-10">
<div class="flex items-center gap-2 mb-6">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-amber-500/15 text-amber-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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
</span>
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Featured</h2>
</div>
@if(!empty($memberFeatured))
<div>
@include('web.partials._artwork_card', ['art' => $memberFeatured])
</div>
@else
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>Member Featured</strong></div>
<div class="panel-body text-neutral-400">No member featured artwork.</div>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@if(!empty($featured))
<div>
@include('web.partials._artwork_card', ['art' => $featured])
</div>
@else
<div class="rounded-2xl ring-1 ring-white/5 bg-white/[0.03] p-4">
<p class="text-sm text-neutral-400">No featured artwork set.</p>
</div>
@endif
<div>
<div class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg p-4 text-center">
<a href="{{ route('register') }}" title="Join Skinbase" class="inline-block mb-3">
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="w-full h-40 object-cover rounded-lg">
</a>
<div class="text-lg font-semibold text-white/90">Join Skinbase World</div>
<p class="mt-2 text-sm text-neutral-400">Join Skinbase and be part of our community. Upload, share and explore curated photography and skins.</p>
<a href="{{ route('register') }}" class="mt-3 inline-block px-4 py-2 rounded-md bg-sky-500 text-white">Create an account</a>
@if(!empty($memberFeatured))
<div>
@include('web.partials._artwork_card', ['art' => $memberFeatured])
</div>
@else
<div class="rounded-2xl ring-1 ring-white/5 bg-white/[0.03] p-4">
<p class="text-sm text-neutral-400">No member featured artwork.</p>
</div>
@endif
<div>
<div class="group relative flex flex-col overflow-hidden rounded-2xl ring-1 ring-white/5 bg-white/[0.03] shadow-lg h-full">
<a href="{{ route('register') }}" title="Join Skinbase" class="block shrink-0">
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="w-full h-48 object-cover">
</a>
<div class="flex flex-col flex-1 p-5 text-center">
<div class="text-lg font-semibold text-white/90">Join Skinbase World</div>
<p class="mt-2 text-sm text-neutral-400 flex-1">Join our community upload, share and explore curated photography and skins.</p>
<a href="{{ route('register') }}" class="mt-4 inline-block px-4 py-2 rounded-lg bg-sky-500 hover:bg-sky-400 transition-colors text-white text-sm font-medium">Create an account</a>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,48 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Members</p>
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title ?? 'Member Photos' }}</h1>
<p class="mt-1 text-sm text-white/50">Artwork submitted by the Skinbase community.</p>
</div>
</div>
{{-- ── Artwork grid ── --}}
<div class="px-6 pb-16 md:px-10">
@php $items = is_object($artworks) && method_exists($artworks, 'toArray') ? $artworks : collect($artworks ?? []); @endphp
@if (!empty($artworks) && (is_countable($artworks) ? count($artworks) > 0 : true))
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
@foreach ($artworks as $art)
@php
$card = (object)[
'id' => $art->id ?? null,
'name' => $art->name ?? $art->title ?? 'Artwork',
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? $art->author ?? '',
'category_name' => $art->category_name ?? '',
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
];
@endphp
<x-artwork-card :art="$card" />
@endforeach
</div>
@if (is_object($artworks) && method_exists($artworks, 'links'))
<div class="mt-10 flex justify-center">
{{ $artworks->withQueryString()->links() }}
</div>
@endif
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No artworks found.</p>
</div>
@endif
</div>
@endsection

View File

@@ -1,14 +1,11 @@
@if($arts && count($arts))
<div class="container_photo gallery_box">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
@foreach($arts as $art)
<div class="photo_frame">
<a href="/art/{{ $art->id }}/{{ \Illuminate\Support\Str::slug($art->name ?? '') }}">
<img src="{{ $art->thumb }}" srcset="{{ $art->thumb_srcset }}" loading="lazy" decoding="async" alt="{{ $art->name }}" class="img-responsive">
</a>
</div>
<x-artwork-card :art="$art" />
@endforeach
</div>
<br style="clear:both"><br>
@else
<p class="text-muted">No uploads for this date.</p>
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No uploads for this date.</p>
</div>
@endif

View File

@@ -0,0 +1,174 @@
@extends('layouts.nova')
@php
use Illuminate\Support\Str;
// One accent colour set per content-type (cycles if more than 4)
$accents = [
['ring' => 'ring-sky-500/30', 'bg' => 'bg-sky-500/10', 'text' => 'text-sky-400', 'badge' => 'bg-sky-500/15 text-sky-300', 'pill' => 'hover:bg-sky-500/15 hover:text-sky-300', 'dot' => 'bg-sky-400', 'border' => 'border-sky-500/25'],
['ring' => 'ring-violet-500/30', 'bg' => 'bg-violet-500/10', 'text' => 'text-violet-400', 'badge' => 'bg-violet-500/15 text-violet-300', 'pill' => 'hover:bg-violet-500/15 hover:text-violet-300', 'dot' => 'bg-violet-400', 'border' => 'border-violet-500/25'],
['ring' => 'ring-amber-500/30', 'bg' => 'bg-amber-500/10', 'text' => 'text-amber-400', 'badge' => 'bg-amber-500/15 text-amber-300', 'pill' => 'hover:bg-amber-500/15 hover:text-amber-300', 'dot' => 'bg-amber-400', 'border' => 'border-amber-500/25'],
['ring' => 'ring-emerald-500/30', 'bg' => 'bg-emerald-500/10','text' => 'text-emerald-400','badge' => 'bg-emerald-500/15 text-emerald-300','pill' => 'hover:bg-emerald-500/15 hover:text-emerald-300','dot' => 'bg-emerald-400','border' => 'border-emerald-500/25'],
];
// Map content-type slug → icon SVG paths
$typeIcons = [
'photography' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>',
'wallpapers' => '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
'skins' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
'other' => '<path stroke-linecap="round" stroke-linejoin="round" d="M5 3a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V7.414A2 2 0 0020.414 6L15 .586A2 2 0 0013.586 0H5z"/>',
];
$defaultIcon = '<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>';
@endphp
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
<h1 class="text-3xl font-bold text-white leading-tight">Browse Sections</h1>
<p class="mt-1 text-sm text-white/50">Explore all artwork categories photography, wallpapers, skins and more.</p>
</div>
{{-- Quick-jump anchor links --}}
<nav class="flex flex-wrap gap-2" aria-label="Section jump links">
@foreach ($contentTypes as $i => $ct)
@php $a = $accents[$i % count($accents)]; @endphp
<a href="#section-{{ $ct->slug }}"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium border border-white/[0.08] bg-white/[0.04] {{ $a['text'] }} hover:{{ $a['bg'] }} transition-colors">
<span class="w-1.5 h-1.5 rounded-full {{ $a['dot'] }}"></span>
{{ $ct->name }}
</a>
@endforeach
</nav>
</div>
</div>
{{-- ── Content type sections ── --}}
<div class="px-6 pb-16 md:px-10 space-y-14">
@forelse ($contentTypes as $i => $ct)
@php
$a = $accents[$i % count($accents)];
$icon = $typeIcons[strtolower($ct->slug)] ?? $defaultIcon;
$totalCount = $artworkCountsByType[$ct->id] ?? 0;
$roots = $ct->rootCategories;
@endphp
<section id="section-{{ $ct->slug }}" class="scroll-mt-20">
{{-- Section heading ── --}}
<div class="flex items-center gap-3 mb-6">
<div class="flex-shrink-0 w-10 h-10 rounded-xl {{ $a['bg'] }} {{ $a['text'] }} flex items-center justify-center ring-1 {{ $a['ring'] }}">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
{!! $icon !!}
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-xl font-bold text-white">{{ $ct->name }}</h2>
@if ($totalCount > 0)
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $a['badge'] }}">
{{ number_format($totalCount) }} artworks
</span>
@endif
</div>
@if (!empty($ct->description))
<p class="mt-0.5 text-sm text-white/45 leading-snug">{{ $ct->description }}</p>
@endif
</div>
<a href="/{{ strtolower($ct->slug) }}"
class="hidden sm:inline-flex flex-shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium border {{ $a['border'] }} {{ $a['text'] }} {{ $a['bg'] }} hover:brightness-110 transition-all">
Browse all
<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="M9 5l7 7-7 7"/></svg>
</a>
</div>
{{-- Separator line --}}
<div class="h-px bg-white/[0.06] mb-6"></div>
@if ($roots->isEmpty())
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-6 py-8 text-center text-sm text-white/35">
No categories available yet.
</div>
@else
{{-- Root category cards grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@foreach ($roots as $root)
@php $subCats = $root->children; @endphp
<div class="group flex flex-col rounded-xl border border-white/[0.06] bg-white/[0.03] hover:border-white/[0.12] hover:bg-white/[0.05] transition-all duration-200 overflow-hidden">
{{-- Card header --}}
<div class="px-4 pt-4 pb-3 border-b border-white/[0.05]">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<a href="{{ $root->url }}"
class="block text-sm font-semibold text-white/90 group-hover:{{ $a['text'] }} transition-colors leading-snug truncate">
{{ $root->name }}
</a>
@if (!empty($root->description))
<p class="mt-1 text-xs text-white/40 leading-relaxed line-clamp-2">{{ $root->description }}</p>
@endif
</div>
@if (($root->artwork_count ?? 0) > 0)
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium {{ $a['badge'] }} whitespace-nowrap">
{{ number_format($root->artwork_count) }}
</span>
@endif
</div>
</div>
{{-- Sub-category pills --}}
<div class="px-4 py-3 flex-1">
@if ($subCats->isEmpty())
<span class="text-xs text-white/25 italic">No subcategories</span>
@else
<div class="flex flex-wrap gap-1.5">
@foreach ($subCats as $sub)
<a href="{{ $sub->url }}"
title="{{ $sub->name }}{{ ($sub->artwork_count ?? 0) > 0 ? ' · ' . number_format($sub->artwork_count) . ' artworks' : '' }}"
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-white/55 bg-white/[0.04] border border-white/[0.06] transition-colors {{ $a['pill'] }}">
{{ $sub->name }}
@if (($sub->artwork_count ?? 0) > 0)
<span class="text-white/30 text-[10px]">{{ number_format($sub->artwork_count) }}</span>
@endif
</a>
@endforeach
</div>
@endif
</div>
{{-- Card footer link --}}
<div class="px-4 pb-3 pt-1">
<a href="{{ $root->url }}"
class="text-xs {{ $a['text'] }} opacity-60 hover:opacity-100 transition-opacity inline-flex items-center gap-1">
View all
<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="M9 5l7 7-7 7"/></svg>
</a>
</div>
</div>
@endforeach
</div>
{{-- Mobile browse-all link --}}
<div class="mt-4 sm:hidden text-center">
<a href="/{{ strtolower($ct->slug) }}"
class="inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium border {{ $a['border'] }} {{ $a['text'] }} {{ $a['bg'] }}">
Browse all {{ $ct->name }}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</a>
</div>
@endif
</section>
@empty
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No sections available.</p>
</div>
@endforelse
</div>
@endsection

View File

@@ -0,0 +1,55 @@
@extends('layouts.nova')
@section('content')
{{-- ── Hero header ── --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
<h1 class="text-3xl font-bold text-white leading-tight">Latest Artworks</h1>
<p class="mt-1 text-sm text-white/50">Recently uploaded Skins, Photography and Wallpapers.</p>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('uploads.daily') }}"
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
<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>
Daily Uploads
</a>
</div>
</div>
</div>
{{-- ── Artwork grid ── --}}
<div class="px-6 pb-16 md:px-10">
@if ($artworks && $artworks->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
@foreach ($artworks as $art)
@php
$card = (object)[
'id' => $art->id,
'name' => $art->name,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'category_name' => $art->category_name ?? '',
];
@endphp
<x-artwork-card :art="$card" />
@endforeach
</div>
{{-- Pagination --}}
<div class="mt-10 flex justify-center">
{{ $artworks->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<p class="text-white/40 text-sm">No artworks found.</p>
</div>
@endif
</div>
@endsection