updated gallery

This commit is contained in:
2026-03-17 18:34:26 +01:00
parent 7b37259a2c
commit 7da0fd39f7
52 changed files with 1216 additions and 870 deletions

View File

@@ -2,9 +2,12 @@ import React, { useState, useCallback } from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import Button from '../../components/ui/Button'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) {
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {} }) {
const [content, setContent] = useState(post?.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const breadcrumbs = [
@@ -18,6 +21,10 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
if (submitting) return
setSubmitting(true)
// Let the form submit normally for PRG
populateBotFingerprint(e.currentTarget).finally(() => {
e.currentTarget.submit()
})
e.preventDefault()
}, [submitting])
return (
@@ -39,6 +46,20 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
>
<input type="hidden" name="_token" value={csrfToken} />
<input type="hidden" name="_method" value="PUT" />
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
<input type="hidden" name="_bot_fingerprint" value="" />
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
{errors.bot ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
</div>
) : null}
{errors.captcha ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
</div>
) : null}
{/* Rich text editor */}
<div>
@@ -56,6 +77,16 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
<input type="hidden" name="content" value={content} />
</div>
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<a

View File

@@ -3,10 +3,13 @@ import Breadcrumbs from '../../components/forum/Breadcrumbs'
import Button from '../../components/ui/Button'
import TextInput from '../../components/ui/TextInput'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) {
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {} }) {
const [title, setTitle] = useState(oldValues.title ?? '')
const [content, setContent] = useState(oldValues.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const slug = category?.slug
@@ -25,6 +28,7 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
setSubmitting(true)
// Standard form submission to keep server-side validation + redirect
await populateBotFingerprint(e.currentTarget)
e.target.submit()
}, [submitting])
@@ -48,6 +52,20 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
>
<input type="hidden" name="_token" value={csrfToken} />
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
<input type="hidden" name="_bot_fingerprint" value="" />
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
{errors.bot ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
</div>
) : null}
{errors.captcha ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
</div>
) : null}
<TextInput
label="Title"
@@ -76,6 +94,16 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
<input type="hidden" name="content" value={content} />
</div>
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
{/* Submit */}
<div className="flex items-center justify-between pt-2">
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">

View File

@@ -20,6 +20,7 @@ export default function ForumThread({
canModerate = false,
csrfToken = '',
status = null,
captcha = {},
}) {
const [currentSort, setCurrentSort] = useState(sort)
@@ -161,6 +162,7 @@ export default function ForumThread({
prefill={replyPrefill}
quotedAuthor={quotedPost?.user?.name}
csrfToken={csrfToken}
captcha={captcha}
/>
)
) : (

View File

@@ -1,55 +1,5 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function FreshCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article>
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
{/* Gloss sheen */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
{/* Top-right View badge */}
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
</div>
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-6 h-6 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
export default function HomeFresh({ items }) {
if (!Array.isArray(items) || items.length === 0) return null
@@ -63,11 +13,10 @@ export default function HomeFresh({ items }) {
</a>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<FreshCard key={item.id} item={item} />
))}
</div>
<ArtworkGalleryGrid
items={items.slice(0, 8)}
showStats={false}
/>
</section>
)
}

View File

@@ -1,55 +1,5 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
{/* Gloss sheen */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
{/* Top-right View badge */}
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
</div>
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-6 h-6 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
export default function HomeTrending({ items }) {
if (!Array.isArray(items) || items.length === 0) return null
@@ -65,11 +15,10 @@ export default function HomeTrending({ items }) {
</a>
</div>
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
<ArtworkGalleryGrid
items={items.slice(0, 8)}
className="xl:grid-cols-4"
/>
</section>
)
}

View File

@@ -1,45 +1,5 @@
import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
function ArtCard({ item }) {
const username = item.author_username ? `@${item.author_username}` : null
return (
<article>
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-5 h-5 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
/**
* Personalized trending: artworks matching user's top tags, sorted by trending score.
@@ -60,11 +20,7 @@ export default function HomeTrendingForYou({ items, preferences }) {
See all
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
<ArtworkGalleryGrid items={items.slice(0, 8)} compact />
</section>
)
}

View File

@@ -21,6 +21,7 @@ export default function Topbar({ user = null }) {
</div>
<div className="flex items-center gap-3 sm:gap-4">
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
{user ? (

View File

@@ -1,9 +1,12 @@
import React, { useState, useRef, useCallback } from 'react'
import Button from '../ui/Button'
import RichTextEditor from './RichTextEditor'
import TurnstileField from '../security/TurnstileField'
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken }) {
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken, captcha = {} }) {
const [content, setContent] = useState(prefill)
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const formRef = useRef(null)
@@ -16,16 +19,24 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
setError(null)
try {
const fingerprint = await buildBotFingerprint()
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Bot-Fingerprint': fingerprint,
'X-Captcha-Token': captchaToken,
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
body: JSON.stringify({ content: content.trim() }),
body: JSON.stringify({
content: content.trim(),
homepage_url: '',
_bot_fingerprint: fingerprint,
[captcha.inputName || 'cf-turnstile-response']: captchaToken,
}),
})
if (res.ok) {
@@ -33,9 +44,10 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
window.location.reload()
} else if (res.status === 422) {
const json = await res.json()
setError(json.errors?.content?.[0] ?? 'Validation error.')
setError(json.errors?.content?.[0] ?? json.errors?.bot?.[0] ?? json.message ?? 'Validation error.')
} else {
setError('Failed to post reply. Please try again.')
const json = await res.json().catch(() => ({}))
setError(json?.errors?.bot?.[0] ?? json?.message ?? 'Failed to post reply. Please try again.')
}
} catch {
setError('Network error. Please try again.')
@@ -66,6 +78,16 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
/>
{/* Submit */}
{captcha.siteKey ? (
<TurnstileField
provider={captcha.provider}
siteKey={captcha.siteKey}
scriptUrl={captcha.scriptUrl}
onToken={setCaptchaToken}
className="rounded-lg border border-white/10 bg-black/20 p-3"
/>
) : null}
<div className="flex items-center justify-end">
<Button
type="submit"

View File

@@ -157,23 +157,3 @@
transform: none;
}
.nb-react-drag-zone {
position: absolute;
left: 48px;
right: 48px;
bottom: 0;
height: 12px;
z-index: 1;
cursor: grab;
-webkit-tap-highlight-color: transparent;
}
.nb-react-drag-zone:active {
cursor: grabbing;
}
@media (hover: none) and (pointer: coarse) {
.nb-react-drag-zone {
display: none;
}
}

View File

@@ -12,16 +12,18 @@ export default function CategoryPillCarousel({
}) {
const viewportRef = useRef(null);
const stripRef = useRef(null);
const dragZoneRef = useRef(null);
const animationRef = useRef(0);
const suppressClickRef = useRef(false);
const dragStateRef = useRef({
active: false,
started: false,
captured: false,
pointerId: null,
pointerType: 'mouse',
startX: 0,
startY: 0,
startOffset: 0,
startedOnLink: false,
});
const [offset, setOffset] = useState(0);
@@ -157,13 +159,10 @@ export default function CategoryPillCarousel({
useEffect(() => {
const strip = stripRef.current;
const dragZone = dragZoneRef.current;
if (!strip || !dragZone) return;
if (!strip) return;
const onPointerDown = (event) => {
const isMouse = event.pointerType === 'mouse';
const fromDragZone = event.currentTarget === dragZone;
if (isMouse && !fromDragZone) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
@@ -172,16 +171,15 @@ export default function CategoryPillCarousel({
dragStateRef.current.active = true;
dragStateRef.current.started = false;
dragStateRef.current.captured = false;
dragStateRef.current.pointerId = event.pointerId;
dragStateRef.current.pointerType = event.pointerType || 'mouse';
dragStateRef.current.startX = event.clientX;
dragStateRef.current.startY = event.clientY;
dragStateRef.current.startOffset = offset;
dragStateRef.current.startedOnLink = !!event.target.closest('.nb-react-pill');
setDragging(false);
if (strip.setPointerCapture) {
try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
};
const onPointerMove = (event) => {
@@ -189,13 +187,24 @@ export default function CategoryPillCarousel({
if (!state.active || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
const threshold = state.pointerType === 'touch' ? 12 : 8;
const dy = event.clientY - state.startY;
const threshold = state.pointerType === 'touch'
? 12
: (state.startedOnLink ? 24 : 12);
if (!state.started) {
if (Math.abs(dx) <= threshold) {
if (Math.abs(dx) <= threshold || Math.abs(dx) <= Math.abs(dy)) {
return;
}
state.started = true;
if (!state.captured && strip.setPointerCapture) {
try {
strip.setPointerCapture(event.pointerId);
state.captured = true;
} catch (_) {
state.captured = false;
}
}
setDragging(true);
}
@@ -213,12 +222,14 @@ export default function CategoryPillCarousel({
suppressClickRef.current = state.started;
state.active = false;
state.started = false;
state.startedOnLink = false;
state.pointerId = null;
setDragging(false);
if (strip.releasePointerCapture) {
if (state.captured && strip.releasePointerCapture) {
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
}
state.captured = false;
};
const onClickCapture = (event) => {
@@ -234,22 +245,12 @@ export default function CategoryPillCarousel({
strip.addEventListener('pointercancel', onPointerUpOrCancel);
strip.addEventListener('click', onClickCapture, true);
dragZone.addEventListener('pointerdown', onPointerDown);
dragZone.addEventListener('pointermove', onPointerMove);
dragZone.addEventListener('pointerup', onPointerUpOrCancel);
dragZone.addEventListener('pointercancel', onPointerUpOrCancel);
return () => {
strip.removeEventListener('pointerdown', onPointerDown);
strip.removeEventListener('pointermove', onPointerMove);
strip.removeEventListener('pointerup', onPointerUpOrCancel);
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
strip.removeEventListener('click', onClickCapture, true);
dragZone.removeEventListener('pointerdown', onPointerDown);
dragZone.removeEventListener('pointermove', onPointerMove);
dragZone.removeEventListener('pointerup', onPointerUpOrCancel);
dragZone.removeEventListener('pointercancel', onPointerUpOrCancel);
};
}, [moveTo, offset]);
@@ -307,12 +308,6 @@ export default function CategoryPillCarousel({
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
</button>
<div
ref={dragZoneRef}
className="nb-react-drag-zone"
aria-hidden="true"
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, {
useState, useEffect, useRef, useCallback, memo,
} from 'react';
import ArtworkCard from './ArtworkCard';
import ArtworkGallery from '../artwork/ArtworkGallery';
import './MasonryGallery.css';
// ── Masonry helpers ────────────────────────────────────────────────────────
@@ -132,6 +132,8 @@ function mapRankApiArtwork(item) {
uname: item.author?.name ?? '',
username: item.author?.username ?? item.author?.name ?? '',
avatar_url: item.author?.avatar_url ?? null,
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
category_name: item.category?.name ?? '',
category_slug: item.category?.slug ?? '',
slug: item.slug ?? '',
@@ -164,6 +166,36 @@ async function fetchRankApiArtworks(endpoint, rankType) {
const SKELETON_COUNT = 10;
function getMasonryCardProps(art, idx) {
const title = (art.name || art.title || 'Untitled artwork').trim();
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
const categorySlug = (art.category_slug || '').toLowerCase();
const categoryName = (art.category_name || art.category || '').toLowerCase();
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
const wideCategoryNames = ['photography', 'wallpapers'];
const isWideEligible =
aspectRatio !== null &&
aspectRatio > 2.0 &&
(wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName));
return {
articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`,
articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined,
frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 hover:ring-white/15 hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]',
mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900',
mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined,
imageSrcSet: art.thumb_srcset || undefined,
imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw',
imageWidth: hasDimensions ? art.width : undefined,
imageHeight: hasDimensions ? art.height : undefined,
loading: idx < 8 ? 'eager' : 'lazy',
decoding: idx < 8 ? 'sync' : 'async',
fetchPriority: idx === 0 ? 'high' : undefined,
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
};
}
// ── Main component ────────────────────────────────────────────────────────
/**
* MasonryGallery
@@ -309,7 +341,7 @@ function MasonryGallery({
// ── Render ─────────────────────────────────────────────────────────────
return (
<section
className="px-6 pb-10 pt-2 md:px-10 is-enhanced"
className="pb-10 pt-2 is-enhanced"
data-nova-gallery
data-gallery-type={galleryType}
data-react-masonry-gallery
@@ -321,17 +353,14 @@ function MasonryGallery({
<>
<div
ref={gridRef}
className={gridClass}
data-gallery-grid
>
{artworks.map((art, idx) => (
<ArtworkCard
key={`${art.id}-${idx}`}
art={art}
loading={idx < 8 ? 'eager' : 'lazy'}
fetchPriority={idx === 0 ? 'high' : undefined}
/>
))}
<ArtworkGallery
items={artworks}
layout="masonry"
className={gridClass}
containerProps={{ 'data-gallery-grid': true }}
resolveCardProps={getMasonryCardProps}
/>
</div>
{/* Infinite scroll sentinel placed after the grid */}

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
import ProfileCoverEditor from './ProfileCoverEditor'
/**
@@ -13,6 +14,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingFollowState, setPendingFollowState] = useState(null)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
@@ -23,7 +26,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
const bio = profile?.bio || profile?.about || ''
const toggleFollow = async () => {
const persistFollowState = async (nextState) => {
if (loading) return
setLoading(true)
try {
@@ -43,6 +46,29 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
setLoading(false)
}
const toggleFollow = async () => {
const nextState = !following
if (!nextState) {
setPendingFollowState(nextState)
setConfirmOpen(true)
return
}
await persistFollowState(nextState)
}
const onConfirmUnfollow = async () => {
if (pendingFollowState === null) return
setConfirmOpen(false)
await persistFollowState(pendingFollowState)
setPendingFollowState(null)
}
const onCloseConfirm = () => {
setConfirmOpen(false)
setPendingFollowState(null)
}
return (
<>
<div className="max-w-6xl mx-auto px-4 pt-4">
@@ -228,6 +254,18 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
setCoverPosition(50)
}}
/>
<NovaConfirmDialog
open={confirmOpen}
title="Unfollow creator?"
message={`You will stop seeing updates from @${uname} in your following feed.`}
confirmLabel="Unfollow"
cancelLabel="Keep following"
confirmTone="danger"
onConfirm={onConfirmUnfollow}
onClose={onCloseConfirm}
busy={loading}
/>
</>
)
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import ArtworkCard from '../../gallery/ArtworkCard'
import ArtworkGallery from '../../artwork/ArtworkGallery'
function FavSkeleton() {
return (
@@ -57,16 +57,16 @@ export default function TabFavourites({ favourites, isOwner, username }) {
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((art, i) => (
<ArtworkCard
key={art.id ?? i}
art={art}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
<ArtworkGallery
items={items}
layout="grid"
className="grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
resolveCardProps={(_, index) => ({
loading: index < 8 ? 'eager' : 'lazy',
})}
>
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
</div>
</ArtworkGallery>
{nextCursor && (
<div className="mt-8 text-center">

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function NovaConfirmDialog({
open,
title = 'Please confirm',
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmTone = 'danger',
onConfirm,
onClose,
busy = false,
}) {
const backdropRef = useRef(null)
const cancelButtonRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
return () => window.clearTimeout(timeoutId)
}, [open])
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape' && !busy) {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [busy, onClose, open])
if (!open) return null
const confirmClassName = confirmTone === 'danger'
? 'border border-rose-400/25 bg-rose-500/12 text-rose-100 hover:bg-rose-500/18 focus-visible:ring-rose-300/50'
: 'border border-accent/25 bg-accent/90 text-deep hover:brightness-110 focus-visible:ring-accent/50'
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current && !busy) {
onClose?.()
}
}}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="nova-confirm-title"
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
>
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Skinbase Nova</p>
<h3 id="nova-confirm-title" className="mt-2 text-lg font-semibold text-white">
{title}
</h3>
</div>
<div className="px-6 py-5">
<p className="text-sm leading-6 text-white/70">{message}</p>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
ref={cancelButtonRef}
type="button"
onClick={() => onClose?.()}
disabled={busy}
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => onConfirm?.()}
disabled={busy}
className={`inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 ${confirmClassName}`}
>
{busy ? 'Working…' : confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
)
}