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

@@ -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,
)
}