Files
2026-03-28 19:15:39 +01:00

278 lines
13 KiB
JavaScript

import React from 'react'
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
async function requestJson(url, { method = 'GET', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const message = payload?.message || 'Request failed.'
throw new Error(message)
}
return payload
}
function formatUpdated(value) {
if (!value) return 'Updated recently'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Updated recently'
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date)
}
function StatPill({ icon, label, value }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">
<i className={`fa-solid ${icon} text-[10px] text-slate-400`} />
<span className="text-white">{value}</span>
<span>{label}</span>
</span>
)
}
export default function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMoveUp, onMoveDown, canMoveUp, canMoveDown, busy = false, saveContext = null, saveContextMeta = null }) {
const coverImage = collection?.cover_image
const [saved, setSaved] = React.useState(Boolean(collection?.saved))
const [saveBusy, setSaveBusy] = React.useState(false)
React.useEffect(() => {
setSaved(Boolean(collection?.saved))
setSaveBusy(false)
}, [collection?.id, collection?.saved])
function handleDelete(event) {
event.preventDefault()
event.stopPropagation()
onDelete?.(collection)
}
function stop(event) {
event.stopPropagation()
}
function handleToggleFeature(event) {
event.preventDefault()
event.stopPropagation()
onToggleFeature?.(collection)
}
async function handleSaveToggle(event) {
event.preventDefault()
event.stopPropagation()
if (saveBusy) return
const targetUrl = saved ? collection?.unsave_url : collection?.save_url
if (!targetUrl) {
if (collection?.login_url) {
window.location.assign(collection.login_url)
}
return
}
setSaveBusy(true)
try {
const payload = await requestJson(targetUrl, {
method: saved ? 'DELETE' : 'POST',
body: saved ? undefined : {
context: saveContext,
context_meta: saveContextMeta || undefined,
},
})
setSaved(Boolean(payload?.saved))
} catch (error) {
window.console?.error?.(error)
} finally {
setSaveBusy(false)
}
}
return (
<a
href={collection?.url || '#'}
className={`group relative overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition-all duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06] ${busy ? 'opacity-70' : ''} lg:max-w-[360px] lg:mx-auto`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_28%)] opacity-0 transition duration-300 group-hover:opacity-100" />
<div className="relative">
{coverImage ? (
<div className="aspect-[16/10] overflow-hidden bg-slate-950">
<img
src={coverImage}
alt={collection?.title || 'Collection cover'}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
loading="lazy"
/>
</div>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,rgba(8,17,31,1),rgba(15,23,42,1),rgba(8,17,31,1))] text-slate-500">
<i className="fa-solid fa-layer-group text-4xl" />
</div>
)}
<div className="p-5">
<div className="mb-3 flex flex-wrap items-center gap-2">
{collection?.is_featured ? (
<span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Featured
</span>
) : null}
{collection?.mode === 'smart' ? (
<span className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Smart
</span>
) : null}
{!isOwner && collection?.program_key ? (
<span className="inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100">
Program · {collection.program_key}
</span>
) : null}
{!isOwner && collection?.partner_label ? (
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">
Partner · {collection.partner_label}
</span>
) : null}
{!isOwner && collection?.sponsorship_label ? (
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Sponsor · {collection.sponsorship_label}
</span>
) : null}
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{collection?.title}</h3>
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
{(collection?.artworks_count ?? 0).toLocaleString()} artworks
</p>
</div>
</div>
{collection?.description_excerpt ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.description_excerpt}</p>
) : collection?.smart_summary ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.smart_summary}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<StatPill icon="fa-heart" label="likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
<StatPill icon="fa-bell" label="followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={(collection?.collaborators_count ?? 0).toLocaleString()} /> : null}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{collection?.is_featured ? 'Featured' : 'Updated'} {formatUpdated(collection?.featured_at || collection?.updated_at)}</span>
<div className="flex items-center gap-2" onClick={stop}>
{!isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? (
<button
type="button"
onClick={handleSaveToggle}
disabled={saveBusy}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] transition ${saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100 hover:bg-violet-400/15' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'} disabled:opacity-60`}
>
<i className={`fa-solid ${saveBusy ? 'fa-circle-notch fa-spin' : (saved ? 'fa-bookmark' : 'fa-bookmark')} text-[11px]`} />
{saved ? 'Saved' : 'Save'}
</button>
) : null}
<span className="inline-flex items-center gap-1 text-slate-200">
<i className="fa-solid fa-arrow-right text-[11px]" />
Open
</span>
</div>
</div>
{isOwner ? (
<div className="mt-4 flex flex-wrap gap-2" onClick={stop}>
{collection?.visibility === 'public' ? (
<button
type="button"
onClick={handleToggleFeature}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${collection?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'}`}
>
<i className={`fa-solid ${collection?.is_featured ? 'fa-star' : 'fa-sparkles'} fa-fw`} />
{collection?.is_featured ? 'Featured' : 'Feature'}
</button>
) : null}
<a
href={collection?.edit_url || 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-slate-100 transition hover:bg-white/[0.09]"
>
<i className="fa-solid fa-pen-to-square fa-fw" />
Edit
</a>
<a
href={collection?.manage_url || collection?.edit_url || '#'}
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15"
>
<i className="fa-solid fa-grip fa-fw" />
Manage Artworks
</a>
{onMoveUp ? (
<button
type="button"
disabled={!canMoveUp}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveUp(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveUp ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-up fa-fw" />
Up
</button>
) : null}
{onMoveDown ? (
<button
type="button"
disabled={!canMoveDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveDown(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveDown ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-down fa-fw" />
Down
</button>
) : null}
{collection?.delete_url ? (
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/15"
>
<i className="fa-solid fa-trash-can fa-fw" />
Delete
</button>
) : null}
</div>
) : null}
</div>
</div>
</a>
)
}