278 lines
13 KiB
JavaScript
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>
|
|
)
|
|
}
|