optimizations
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
@@ -118,6 +119,87 @@ function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function sendDiscoveryEvent(endpoint, payload) {
|
||||
if (!endpoint) return
|
||||
|
||||
void fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function sendFeedbackSignal(endpoint, payload) {
|
||||
if (!endpoint) {
|
||||
throw new Error('missing_feedback_endpoint')
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
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('feedback_request_failed')
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
async function requestJson(endpoint, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.message || 'Request failed.')
|
||||
error.payload = payload
|
||||
throw error
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
|
||||
const endpoint = item?.discovery_endpoint
|
||||
const artworkId = Number(item?.id ?? 0)
|
||||
if (!endpoint || artworkId <= 0) return
|
||||
|
||||
sendDiscoveryEvent(endpoint, {
|
||||
event_type: eventType,
|
||||
artwork_id: artworkId,
|
||||
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
|
||||
meta: {
|
||||
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
|
||||
source: item?.recommendation_source || null,
|
||||
reason: item?.recommendation_reason || null,
|
||||
score: item?.recommendation_score ?? null,
|
||||
...extraMeta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function HeartIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
@@ -145,6 +227,34 @@ function ViewIcon(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function HideIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TagIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionLink({ href, label, children, onClick }) {
|
||||
return (
|
||||
<a
|
||||
@@ -185,6 +295,113 @@ function BadgePill({ className = '', iconClass = '', children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionPickerModal({
|
||||
open,
|
||||
artworkTitle,
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
notice,
|
||||
createUrl,
|
||||
attachingCollectionId,
|
||||
onAttach,
|
||||
onClose,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close add to collection dialog"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
|
||||
<div className="border-b border-white/10 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-6">
|
||||
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
|
||||
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
|
||||
) : collections.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks • {collection.visibility}</div>
|
||||
</div>
|
||||
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAttach(collection)}
|
||||
disabled={collection.already_attached || attachingCollectionId === collection.id}
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
|
||||
>
|
||||
<CollectionIcon className="h-3.5 w-3.5" />
|
||||
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
|
||||
</button>
|
||||
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
|
||||
<CollectionIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
|
||||
<a href={createUrl} className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-plus" />
|
||||
Create Collection
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkCard({
|
||||
artwork,
|
||||
variant = 'default',
|
||||
@@ -206,9 +423,18 @@ export default function ArtworkCard({
|
||||
decoding = 'async',
|
||||
fetchPriority,
|
||||
onLike,
|
||||
onDismissed,
|
||||
showActions = true,
|
||||
metricBadge = null,
|
||||
}) {
|
||||
let inertiaProps = {}
|
||||
|
||||
try {
|
||||
inertiaProps = usePage()?.props || {}
|
||||
} catch {
|
||||
inertiaProps = {}
|
||||
}
|
||||
|
||||
const item = artwork || {}
|
||||
const rawAuthor = item.author || item.creator
|
||||
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||
@@ -255,10 +481,51 @@ export default function ArtworkCard({
|
||||
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
|
||||
const [likeBusy, setLikeBusy] = useState(false)
|
||||
const [downloadBusy, setDownloadBusy] = useState(false)
|
||||
const [hideBusy, setHideBusy] = useState(false)
|
||||
const [dislikeBusy, setDislikeBusy] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
|
||||
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
|
||||
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
|
||||
const [collectionOptions, setCollectionOptions] = useState([])
|
||||
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
|
||||
const [collectionPickerError, setCollectionPickerError] = useState('')
|
||||
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
|
||||
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
|
||||
const openTrackedRef = useRef(false)
|
||||
const primaryTag = useMemo(() => {
|
||||
if (item?.primary_tag && typeof item.primary_tag === 'object') {
|
||||
return item.primary_tag
|
||||
}
|
||||
|
||||
if (Array.isArray(item?.tags) && item.tags.length > 0) {
|
||||
return item.tags[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}, [item.primary_tag, item.tags])
|
||||
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
|
||||
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
|
||||
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
|
||||
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
|
||||
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
|
||||
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
|
||||
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
|
||||
const collectionOptionsEndpoint = canAddToCollection
|
||||
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(Boolean(item.viewer?.is_liked))
|
||||
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
|
||||
setDismissed(false)
|
||||
setCollectionPickerOpen(false)
|
||||
setCollectionOptionsLoading(false)
|
||||
setCollectionOptionsLoaded(false)
|
||||
setCollectionOptions([])
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
setAttachingCollectionId(null)
|
||||
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
|
||||
|
||||
const articleData = useMemo(() => ({
|
||||
@@ -268,6 +535,16 @@ export default function ArtworkCard({
|
||||
'data-art-img': image,
|
||||
}), [href, image, item.id, title])
|
||||
|
||||
const handleOpen = () => {
|
||||
if (openTrackedRef.current) return
|
||||
openTrackedRef.current = true
|
||||
|
||||
trackRecommendationFeedback(item, 'click', {
|
||||
interaction_origin: 'artwork-card-open',
|
||||
target_url: href,
|
||||
})
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!item.id || likeBusy) {
|
||||
onLike?.(item)
|
||||
@@ -294,6 +571,12 @@ export default function ArtworkCard({
|
||||
throw new Error('like_request_failed')
|
||||
}
|
||||
|
||||
if (nextState) {
|
||||
trackRecommendationFeedback(item, 'favorite', {
|
||||
interaction_origin: 'artwork-card-like',
|
||||
})
|
||||
}
|
||||
|
||||
onLike?.(item)
|
||||
} catch {
|
||||
setLiked(!nextState)
|
||||
@@ -309,6 +592,11 @@ export default function ArtworkCard({
|
||||
|
||||
setDownloadBusy(true)
|
||||
try {
|
||||
trackRecommendationFeedback(item, 'download', {
|
||||
interaction_origin: 'artwork-card-download',
|
||||
target_url: downloadHref,
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadHref
|
||||
link.rel = 'noopener noreferrer'
|
||||
@@ -322,6 +610,125 @@ export default function ArtworkCard({
|
||||
}
|
||||
}
|
||||
|
||||
const dismissArtwork = (kind) => {
|
||||
setDismissed(true)
|
||||
onDismissed?.(item, kind)
|
||||
}
|
||||
|
||||
const handleHideArtwork = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canHideRecommendation || hideBusy) return
|
||||
|
||||
setHideBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(hideArtworkEndpoint, {
|
||||
artwork_id: Number(item.id),
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
primary_tag_slug: primaryTag?.slug || null,
|
||||
interaction_origin: 'artwork-card-hide',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('hide-artwork')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setHideBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDislikePrimaryTag = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDislikePrimaryTag || dislikeBusy) return
|
||||
|
||||
setDislikeBusy(true)
|
||||
try {
|
||||
await sendFeedbackSignal(dislikeTagEndpoint, {
|
||||
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
|
||||
tag_slug: primaryTag?.slug || undefined,
|
||||
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
|
||||
source: item.recommendation_source || 'recommendation-card',
|
||||
meta: {
|
||||
artwork_id: Number(item.id),
|
||||
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
|
||||
reason: item.recommendation_reason || null,
|
||||
interaction_origin: 'artwork-card-dislike-tag',
|
||||
},
|
||||
})
|
||||
|
||||
dismissArtwork('dislike-tag')
|
||||
} catch {
|
||||
// Keep the card visible if the feedback request fails.
|
||||
} finally {
|
||||
setDislikeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCollectionPicker = async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
|
||||
|
||||
setCollectionPickerOpen(true)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
if (collectionOptionsLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
setCollectionOptionsLoading(true)
|
||||
try {
|
||||
const payload = await requestJson(collectionOptionsEndpoint)
|
||||
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
|
||||
setCollectionOptionsLoaded(true)
|
||||
} catch (error) {
|
||||
setCollectionPickerError(error.message || 'Unable to load collections.')
|
||||
} finally {
|
||||
setCollectionOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttachToCollection = async (collection) => {
|
||||
if (!collection?.attach_url || attachingCollectionId === collection.id) return
|
||||
|
||||
setAttachingCollectionId(collection.id)
|
||||
setCollectionPickerError('')
|
||||
setCollectionPickerNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(collection.attach_url, {
|
||||
method: 'POST',
|
||||
body: { artwork_ids: [Number(item.id)] },
|
||||
})
|
||||
|
||||
setCollectionOptions((current) => current.map((entry) => (
|
||||
entry.id === collection.id
|
||||
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
|
||||
: entry
|
||||
)))
|
||||
setCollectionPickerNotice(`Added to ${collection.title}.`)
|
||||
} catch (error) {
|
||||
const firstError = error?.payload?.errors
|
||||
? Object.values(error.payload.errors).flat().find(Boolean)
|
||||
: null
|
||||
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
|
||||
} finally {
|
||||
setAttachingCollectionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'embed') {
|
||||
return (
|
||||
<article
|
||||
@@ -333,6 +740,7 @@ export default function ArtworkCard({
|
||||
href={href}
|
||||
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
|
||||
<img
|
||||
@@ -371,15 +779,17 @@ export default function ArtworkCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<>
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
onClick={handleOpen}
|
||||
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<span className="sr-only">{cardLabel}</span>
|
||||
@@ -426,7 +836,7 @@ export default function ArtworkCard({
|
||||
|
||||
{showActions && (
|
||||
<div className={cx(
|
||||
'absolute right-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
relativePublishedAt ? 'top-12' : 'top-3'
|
||||
)}>
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
@@ -440,6 +850,18 @@ export default function ArtworkCard({
|
||||
<ActionLink href={href} label="View artwork">
|
||||
<ViewIcon className="h-4 w-4" />
|
||||
</ActionLink>
|
||||
|
||||
{canAddToCollection ? (
|
||||
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
|
||||
<CollectionIcon className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
|
||||
{canHideRecommendation ? (
|
||||
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
|
||||
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -482,9 +904,36 @@ export default function ArtworkCard({
|
||||
{metadataLine}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canDislikePrimaryTag ? (
|
||||
<div className="pointer-events-auto mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDislikePrimaryTag}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
|
||||
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<CollectionPickerModal
|
||||
open={collectionPickerOpen}
|
||||
artworkTitle={title}
|
||||
collections={collectionOptions}
|
||||
loading={collectionOptionsLoading}
|
||||
error={collectionPickerError}
|
||||
notice={collectionPickerNotice}
|
||||
createUrl={collectionCreateUrl}
|
||||
attachingCollectionId={attachingCollectionId}
|
||||
onAttach={handleAttachToCollection}
|
||||
onClose={() => setCollectionPickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
import React from 'react'
|
||||
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(' ')
|
||||
}
|
||||
@@ -14,6 +75,36 @@ function getArtworkKey(item, 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',
|
||||
@@ -30,13 +121,81 @@ export default function ArtworkGallery({
|
||||
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'
|
||||
: '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}>
|
||||
{visibleItems.map((item, index) => {
|
||||
<>
|
||||
<div className={cx(baseClassName, className)} {...containerProps}>
|
||||
{visibleArtworkItems.map((item, index) => {
|
||||
const cardProps = resolveCardProps?.(item, index) || {}
|
||||
const { className: resolvedClassName = '', ...restCardProps } = cardProps
|
||||
|
||||
@@ -48,11 +207,14 @@ export default function ArtworkGallery({
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(cardClassName, resolvedClassName)}
|
||||
onDismissed={handleDismissed}
|
||||
{...restCardProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' })
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
|
||||
|
||||
Reference in New Issue
Block a user