optimizations
This commit is contained in:
@@ -1,49 +1,138 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import CollectionCard from '../collections/CollectionCard'
|
||||
import CollectionEmptyState from '../collections/CollectionEmptyState'
|
||||
|
||||
/**
|
||||
* TabCollections
|
||||
* Collections feature placeholder.
|
||||
*/
|
||||
export default function TabCollections({ collections }) {
|
||||
if (collections?.length > 0) {
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
|
||||
>
|
||||
{col.cover_image ? (
|
||||
<div className="aspect-video overflow-hidden bg-black/30">
|
||||
<img
|
||||
src={col.cover_image}
|
||||
alt={col.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-white truncate">{col.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function deleteCollection(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to delete collection.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
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) {
|
||||
throw new Error(payload?.message || 'Unable to update collection presentation.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const FILTERS = ['all', 'featured', 'smart', 'manual']
|
||||
|
||||
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
|
||||
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}, [collections])
|
||||
|
||||
async function handleDelete(collection) {
|
||||
if (!collection?.delete_url) return
|
||||
if (!window.confirm(`Delete "${collection.title}"? Artworks will remain untouched.`)) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
await deleteCollection(collection.delete_url)
|
||||
setItems((current) => current.filter((item) => item.id !== collection.id))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeature(collection) {
|
||||
const url = collection?.is_featured ? collection?.unfeature_url : collection?.feature_url
|
||||
const method = collection?.is_featured ? 'DELETE' : 'POST'
|
||||
if (!url) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
const payload = await requestJson(url, { method })
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === collection.id
|
||||
? {
|
||||
...item,
|
||||
is_featured: payload?.collection?.is_featured ?? !item.is_featured,
|
||||
featured_at: payload?.collection?.featured_at ?? item.featured_at,
|
||||
updated_at: payload?.collection?.updated_at ?? item.updated_at,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(collection, direction) {
|
||||
const index = items.findIndex((item) => item.id === collection.id)
|
||||
const nextIndex = index + direction
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || !reorderUrl) return
|
||||
|
||||
const next = [...items]
|
||||
const temp = next[index]
|
||||
next[index] = next[nextIndex]
|
||||
next[nextIndex] = temp
|
||||
setItems(next)
|
||||
|
||||
try {
|
||||
const payload = await requestJson(reorderUrl, {
|
||||
method: 'POST',
|
||||
body: { collection_ids: next.map((item) => item.id) },
|
||||
})
|
||||
if (Array.isArray(payload?.collections)) {
|
||||
setItems(payload.collections)
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}
|
||||
}
|
||||
|
||||
const featuredItems = items.filter((collection) => collection.is_featured)
|
||||
const smartItems = items.filter((collection) => collection.mode === 'smart')
|
||||
const filteredItems = items.filter((collection) => {
|
||||
if (filter === 'featured') return collection.is_featured
|
||||
if (filter === 'smart') return collection.mode === 'smart'
|
||||
if (filter === 'manual') return collection.mode !== 'smart'
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
@@ -51,15 +140,84 @@ export default function TabCollections({ collections }) {
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated showcases from the gallery</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
|
||||
Collections now support featured presentation, smart rule-based curation, and richer profile storytelling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{featuredUrl ? <a href={featuredUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Browse Featured</a> : null}
|
||||
{isOwner && createUrl ? <a href={createUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus fa-fw" />Create Collection</a> : null}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm mx-auto">
|
||||
Group your artworks into curated collections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{FILTERS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFilter(value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${filter === value ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && featuredItems.length > 0 && filter === 'all' ? (
|
||||
<section className="mb-6 overflow-hidden rounded-[28px] border border-amber-300/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.08),rgba(255,255,255,0.04),rgba(56,189,248,0.08))] p-5 shadow-[0_26px_70px_rgba(2,6,23,0.22)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Featured Collections</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Premium profile showcases</h3>
|
||||
</div>
|
||||
{isOwner ? <p className="text-xs uppercase tracking-[0.18em] text-slate-300">{featuredItems.length}/{featureLimit} featured</p> : null}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={`featured-${collection.id}`}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < featuredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isOwner && items.length > 0 && featuredItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Feature your best collections to pin them at the top of your profile.</div> : null}
|
||||
{isOwner && items.length > 0 && smartItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Create a smart collection from your tags or categories to keep a showcase updated automatically.</div> : null}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CollectionEmptyState isOwner={isOwner} createUrl={createUrl} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < filteredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user