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