updated gallery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
94
resources/js/components/ui/NovaConfirmDialog.jsx
Normal file
94
resources/js/components/ui/NovaConfirmDialog.jsx
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user