221 lines
6.8 KiB
JavaScript
221 lines
6.8 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react'
|
|
import ArtworkCard from './ArtworkCard'
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
async function revokeDismissSignal(entry) {
|
|
const item = entry?.item || null
|
|
const kind = entry?.kind || null
|
|
|
|
if (!item || !kind) {
|
|
throw new Error('missing_dismiss_entry')
|
|
}
|
|
|
|
const endpoint = kind === 'dislike-tag'
|
|
? item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint
|
|
: item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint
|
|
|
|
if (!endpoint) {
|
|
throw new Error('missing_revoke_endpoint')
|
|
}
|
|
|
|
const payload = kind === 'dislike-tag'
|
|
? {
|
|
tag_id: item?.primary_tag?.id ? Number(item.primary_tag.id) : undefined,
|
|
tag_slug: item?.primary_tag?.slug || item?.primary_tag?.name || undefined,
|
|
artwork_id: Number(item.id),
|
|
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
|
meta: {
|
|
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
|
reason: item?.recommendation_reason || null,
|
|
interaction_origin: 'artwork-gallery-undo-dislike-tag',
|
|
},
|
|
}
|
|
: {
|
|
artwork_id: Number(item.id),
|
|
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
|
meta: {
|
|
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
|
reason: item?.recommendation_reason || null,
|
|
interaction_origin: 'artwork-gallery-undo-hide',
|
|
},
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('revoke_request_failed')
|
|
}
|
|
|
|
return response.json().catch(() => null)
|
|
}
|
|
|
|
function cx(...parts) {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
function getArtworkKey(item, index) {
|
|
if (item?.id) return item.id
|
|
if (item?.title || item?.name || item?.author) {
|
|
return `${item.title || item.name || 'artwork'}-${item.author || item.author_name || item.uname || 'artist'}-${index}`
|
|
}
|
|
|
|
return `artwork-${index}`
|
|
}
|
|
|
|
function DismissNotice({ notice, onUndo, onClose }) {
|
|
if (!notice) return null
|
|
|
|
return (
|
|
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
|
|
<div className="pointer-events-auto rounded-2xl border border-amber-300/30 bg-slate-950/92 px-4 py-3 text-amber-50 shadow-2xl shadow-black/40 backdrop-blur">
|
|
<p className="text-[11px] uppercase tracking-[0.2em] text-amber-100/70">Discovery Feedback</p>
|
|
<p className="mt-1 text-sm font-medium">{notice.message}</p>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onUndo}
|
|
disabled={notice.busy}
|
|
className="inline-flex items-center rounded-full border border-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/85 transition hover:border-white/30 hover:text-white"
|
|
>
|
|
{notice.busy ? 'Undoing...' : 'Undo'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/55 transition hover:text-white/85"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ArtworkGallery({
|
|
items,
|
|
layout = 'grid',
|
|
compact = false,
|
|
showStats = true,
|
|
showAuthor = true,
|
|
className = '',
|
|
cardClassName = '',
|
|
limit,
|
|
containerProps = {},
|
|
resolveCardProps,
|
|
children,
|
|
}) {
|
|
if (!Array.isArray(items) || items.length === 0) return null
|
|
|
|
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
|
|
const [dismissedEntries, setDismissedEntries] = useState([])
|
|
const [dismissNotice, setDismissNotice] = useState(null)
|
|
const visibleArtworkItems = useMemo(
|
|
() => visibleItems.filter((item) => !dismissedEntries.some((entry) => entry.item?.id === item?.id)),
|
|
[dismissedEntries, visibleItems]
|
|
)
|
|
const baseClassName = layout === 'masonry'
|
|
? 'grid gap-6'
|
|
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
|
|
|
|
useEffect(() => {
|
|
if (!dismissNotice) {
|
|
return undefined
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
setDismissNotice(null)
|
|
}, 3200)
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId)
|
|
}
|
|
}, [dismissNotice])
|
|
|
|
function handleDismissed(item, kind) {
|
|
if (!item?.id) return
|
|
|
|
setDismissedEntries((current) => {
|
|
const next = current.filter((entry) => entry.item?.id !== item.id)
|
|
next.push({ item, kind })
|
|
return next
|
|
})
|
|
|
|
setDismissNotice({
|
|
itemId: item.id,
|
|
busy: false,
|
|
message: kind === 'dislike-tag'
|
|
? `We will show less content like #${item?.primary_tag?.slug || item?.primary_tag?.name || 'this tag'}.`
|
|
: 'Artwork hidden from this recommendation view.',
|
|
})
|
|
}
|
|
|
|
async function handleUndoDismiss() {
|
|
if (!dismissNotice?.itemId) {
|
|
setDismissNotice(null)
|
|
return
|
|
}
|
|
|
|
const entry = dismissedEntries.find((current) => current.item?.id === dismissNotice.itemId)
|
|
if (!entry) {
|
|
setDismissNotice(null)
|
|
return
|
|
}
|
|
|
|
setDismissNotice((current) => current ? { ...current, busy: true } : current)
|
|
|
|
try {
|
|
await revokeDismissSignal(entry)
|
|
} catch {
|
|
setDismissNotice({
|
|
itemId: entry.item.id,
|
|
busy: false,
|
|
message: 'Undo failed. The feedback signal is still active.',
|
|
})
|
|
return
|
|
}
|
|
|
|
setDismissedEntries((current) => current.filter((entry) => entry.item?.id !== dismissNotice.itemId))
|
|
setDismissNotice(null)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={cx(baseClassName, className)} {...containerProps}>
|
|
{visibleArtworkItems.map((item, index) => {
|
|
const cardProps = resolveCardProps?.(item, index) || {}
|
|
const { className: resolvedClassName = '', ...restCardProps } = cardProps
|
|
|
|
return (
|
|
<ArtworkCard
|
|
key={getArtworkKey(item, index)}
|
|
artwork={item}
|
|
compact={compact}
|
|
showStats={showStats}
|
|
showAuthor={showAuthor}
|
|
className={cx(cardClassName, resolvedClassName)}
|
|
onDismissed={handleDismissed}
|
|
{...restCardProps}
|
|
/>
|
|
)
|
|
})}
|
|
{children}
|
|
</div>
|
|
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
|
|
</>
|
|
)
|
|
}
|