543 lines
30 KiB
JavaScript
543 lines
30 KiB
JavaScript
import React from 'react'
|
|
import { Head, usePage } from '@inertiajs/react'
|
|
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
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 || 'Request failed.')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
function buildFilterUrl(next, baseUrl) {
|
|
if (typeof window === 'undefined' && !baseUrl) return '#'
|
|
|
|
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
|
Object.entries(next).forEach(([key, value]) => {
|
|
if (value === null || value === undefined || value === '' || value === 'all') {
|
|
url.searchParams.delete(key)
|
|
} else {
|
|
url.searchParams.set(key, String(value))
|
|
}
|
|
})
|
|
|
|
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
|
}
|
|
|
|
function buildSearchActionUrl(baseUrl) {
|
|
if (typeof window === 'undefined' && !baseUrl) return '#'
|
|
|
|
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
|
url.searchParams.delete('q')
|
|
|
|
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
|
}
|
|
|
|
function reorderCollectionIds(collections, collectionId, direction) {
|
|
const currentIndex = collections.findIndex((item) => Number(item.id) === Number(collectionId))
|
|
if (currentIndex === -1) return null
|
|
|
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
|
if (targetIndex < 0 || targetIndex >= collections.length) return null
|
|
|
|
const nextCollections = [...collections]
|
|
const [movedCollection] = nextCollections.splice(currentIndex, 1)
|
|
nextCollections.splice(targetIndex, 0, movedCollection)
|
|
|
|
return nextCollections.map((item) => Number(item.id))
|
|
}
|
|
|
|
function EmptyState({ browseUrl }) {
|
|
return (
|
|
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
|
|
<i className="fa-solid fa-bookmark text-3xl" />
|
|
</div>
|
|
<h2 className="mt-5 text-2xl font-semibold text-white">No saved collections yet</h2>
|
|
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
|
|
Save collections to build a personal reference library for inspiration, campaigns, and creators you want to revisit.
|
|
</p>
|
|
<div className="mt-6 flex justify-center">
|
|
<a href={browseUrl} className="inline-flex items-center gap-2 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 collections
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FilterLink({ href, active, children, count }) {
|
|
return (
|
|
<a href={href} className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.07]'}`}>
|
|
<span>{children}</span>
|
|
{typeof count === 'number' ? <span className={`text-xs ${active ? 'text-sky-100/80' : 'text-slate-400'}`}>{count}</span> : null}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return null
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return null
|
|
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
export default function SavedCollections() {
|
|
const { props } = usePage()
|
|
const seo = props.seo || {}
|
|
const initialCollections = Array.isArray(props.collections) ? props.collections : []
|
|
const recentlyRevisited = Array.isArray(props.recentlyRevisited) ? props.recentlyRevisited : []
|
|
const recommendedCollections = Array.isArray(props.recommendedCollections) ? props.recommendedCollections : []
|
|
const browseUrl = props.browseUrl || '/collections/featured'
|
|
const libraryUrl = props.libraryUrl || '/me/saved/collections'
|
|
const activeFilters = props.activeFilters || { q: '', filter: 'all', sort: 'saved_desc', list: null }
|
|
const activeList = props.activeList || null
|
|
const filterOptions = Array.isArray(props.filterOptions) ? props.filterOptions : []
|
|
const sortOptions = Array.isArray(props.sortOptions) ? props.sortOptions : []
|
|
const searchBaseUrl = activeList?.url || libraryUrl
|
|
const [collections, setCollections] = React.useState(initialCollections)
|
|
const [savedLists, setSavedLists] = React.useState(Array.isArray(props.savedLists) ? props.savedLists : [])
|
|
const [newListTitle, setNewListTitle] = React.useState('')
|
|
const [selectedLists, setSelectedLists] = React.useState({})
|
|
const [notes, setNotes] = React.useState(() => Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
|
const [search, setSearch] = React.useState(activeFilters.q || '')
|
|
const [notice, setNotice] = React.useState('')
|
|
const [busy, setBusy] = React.useState('')
|
|
React.useEffect(() => {
|
|
setCollections(initialCollections)
|
|
setNotes(Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
|
}, [initialCollections])
|
|
|
|
React.useEffect(() => {
|
|
setSearch(activeFilters.q || '')
|
|
}, [activeFilters.q])
|
|
|
|
React.useEffect(() => {
|
|
setSavedLists(Array.isArray(props.savedLists) ? props.savedLists : [])
|
|
}, [props.savedLists])
|
|
|
|
const listSchema = seo?.canonical ? {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'CollectionPage',
|
|
name: 'Saved collections',
|
|
description: seo?.description || 'Your saved collections on Skinbase Nova.',
|
|
url: seo.canonical,
|
|
mainEntity: {
|
|
'@type': 'ItemList',
|
|
numberOfItems: collections.length,
|
|
itemListElement: collections.slice(0, 18).map((collection, index) => ({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
url: collection.url,
|
|
name: collection.title,
|
|
})),
|
|
},
|
|
} : null
|
|
|
|
async function handleCreateList(event) {
|
|
event.preventDefault()
|
|
if (!newListTitle.trim() || !props.endpoints?.createList) return
|
|
|
|
setBusy('create-list')
|
|
setNotice('')
|
|
|
|
try {
|
|
const payload = await requestJson(props.endpoints.createList, {
|
|
method: 'POST',
|
|
body: { title: newListTitle.trim() },
|
|
})
|
|
|
|
setSavedLists((current) => [...current, payload.list].sort((left, right) => String(left.title).localeCompare(String(right.title))))
|
|
setNewListTitle('')
|
|
setNotice('Saved list created.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to create list.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleAddToList(collectionId) {
|
|
const listId = selectedLists[collectionId] || savedLists[0]?.id
|
|
if (!listId || !props.endpoints?.addToListPattern) return
|
|
|
|
setBusy(`list-${collectionId}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const payload = await requestJson(props.endpoints.addToListPattern.replace('__COLLECTION__', String(collectionId)), {
|
|
method: 'POST',
|
|
body: { saved_list_id: Number(listId) },
|
|
})
|
|
|
|
setSavedLists((current) => current.map((list) => (
|
|
Number(list.id) === Number(listId)
|
|
? { ...list, items_count: Number(payload?.list?.items_count || list.items_count || 0) }
|
|
: list
|
|
)))
|
|
setNotice(payload?.added ? 'Collection added to saved list.' : 'Collection already exists in that saved list.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to add collection to list.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleUnsave(collection) {
|
|
if (!collection?.id || !props.endpoints?.unsavePattern) return
|
|
|
|
setBusy(`unsave-${collection.id}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
await requestJson(props.endpoints.unsavePattern.replace('__COLLECTION__', String(collection.id)), {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
|
setSavedLists((current) => current.map((list) => {
|
|
const isMember = Array.isArray(collection.saved_list_ids) && collection.saved_list_ids.includes(Number(list.id))
|
|
|
|
return isMember
|
|
? { ...list, items_count: Math.max(0, Number(list.items_count || 0) - 1) }
|
|
: list
|
|
}))
|
|
setNotice('Collection removed from your saved library.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to remove collection from your saved library.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleRemoveFromList(collection) {
|
|
if (!activeList?.id || !collection?.id || !props.endpoints?.removeFromListPattern) return
|
|
|
|
setBusy(`remove-${collection.id}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const payload = await requestJson(
|
|
props.endpoints.removeFromListPattern
|
|
.replace('__LIST__', String(activeList.id))
|
|
.replace('__COLLECTION__', String(collection.id)),
|
|
{ method: 'DELETE' },
|
|
)
|
|
|
|
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
|
setSavedLists((current) => current.map((list) => (
|
|
Number(list.id) === Number(payload?.list?.id)
|
|
? { ...list, items_count: Number(payload?.list?.items_count || 0) }
|
|
: list
|
|
)))
|
|
setNotice('Collection removed from this saved list.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to remove collection from this saved list.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleReorderCollection(collectionId, direction) {
|
|
if (!activeList?.id || !props.endpoints?.reorderItemsPattern) return
|
|
|
|
const reorderedCollectionIds = reorderCollectionIds(collections, collectionId, direction)
|
|
if (!reorderedCollectionIds) return
|
|
|
|
setBusy(`reorder-${collectionId}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
await requestJson(
|
|
props.endpoints.reorderItemsPattern.replace('__LIST__', String(activeList.id)),
|
|
{
|
|
method: 'POST',
|
|
body: { collection_ids: reorderedCollectionIds },
|
|
},
|
|
)
|
|
|
|
setCollections((current) => {
|
|
const order = new Map(reorderedCollectionIds.map((id, index) => [Number(id), index]))
|
|
|
|
return [...current].sort((left, right) => {
|
|
const leftOrder = order.get(Number(left.id)) ?? Number.MAX_SAFE_INTEGER
|
|
const rightOrder = order.get(Number(right.id)) ?? Number.MAX_SAFE_INTEGER
|
|
|
|
return leftOrder - rightOrder
|
|
})
|
|
})
|
|
setNotice('Saved list order updated.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to update saved list order.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleSaveNote(collectionId) {
|
|
if (!props.endpoints?.updateNotePattern) return
|
|
|
|
setBusy(`note-${collectionId}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const payload = await requestJson(
|
|
props.endpoints.updateNotePattern.replace('__COLLECTION__', String(collectionId)),
|
|
{
|
|
method: 'PATCH',
|
|
body: { note: notes[collectionId] || '' },
|
|
},
|
|
)
|
|
|
|
setCollections((current) => current.map((collection) => (
|
|
Number(collection.id) === Number(collectionId)
|
|
? { ...collection, saved_note: payload?.note?.note || null }
|
|
: collection
|
|
)))
|
|
setNotice(payload?.note ? 'Saved note updated.' : 'Saved note removed.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to update saved note.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{seo?.title || 'Saved Collections — Skinbase Nova'}</title>
|
|
<meta name="description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
|
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
|
<meta name="robots" content={seo?.robots || 'noindex,follow'} />
|
|
<meta property="og:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
|
|
<meta property="og:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
|
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
|
|
<meta property="og:type" content="website" />
|
|
<meta name="twitter:card" content="summary" />
|
|
<meta name="twitter:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
|
|
<meta name="twitter:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
|
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
|
|
</Head>
|
|
|
|
<div className="relative min-h-screen overflow-hidden pb-16">
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
|
|
|
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
|
<a href={browseUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
|
|
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
|
|
Browse collections
|
|
</a>
|
|
</div>
|
|
|
|
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Library</p>
|
|
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Saved collections</h1>
|
|
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
|
A personal shortlist of collections worth revisiting. Organize them into saved lists, pivot by editorial or campaign relevance, and keep a working shelf of what should influence your next publish.
|
|
</p>
|
|
{activeList ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100"><i className="fa-solid fa-folder-open fa-fw" />Viewing list: {activeList.title}</p> : null}
|
|
{activeFilters.q ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100"><i className="fa-solid fa-magnifying-glass fa-fw" />Search: {activeFilters.q}</p> : null}
|
|
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
|
</section>
|
|
|
|
<section className="mt-8 grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
|
<aside className="space-y-5">
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
|
|
<form method="GET" action={buildSearchActionUrl(searchBaseUrl)} className="mt-4 space-y-3">
|
|
<input name="q" value={search} onChange={(event) => setSearch(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Search titles, notes context, or curator" maxLength={120} />
|
|
{activeFilters.filter && activeFilters.filter !== 'all' ? <input type="hidden" name="filter" value={activeFilters.filter} /> : null}
|
|
{activeFilters.sort && activeFilters.sort !== 'saved_desc' ? <input type="hidden" name="sort" value={activeFilters.sort} /> : null}
|
|
<div className="flex gap-3">
|
|
<button type="submit" className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-sky-300/20 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-magnifying-glass fa-fw" />Apply</button>
|
|
{(activeFilters.q || search) ? <a href={buildFilterUrl({ q: null }, searchBaseUrl)} className="inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]">Clear</a> : null}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
|
|
<div className="mt-4 space-y-3">
|
|
{filterOptions.map((option) => (
|
|
<FilterLink
|
|
key={option.key}
|
|
href={buildFilterUrl({ q: activeFilters.q, filter: option.key, sort: activeFilters.sort }, searchBaseUrl)}
|
|
active={activeFilters.filter === option.key}
|
|
count={option.count}
|
|
>
|
|
{option.label}
|
|
</FilterLink>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sort</p>
|
|
<div className="mt-4 space-y-3">
|
|
{sortOptions.map((option) => (
|
|
<FilterLink
|
|
key={option.key}
|
|
href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: option.key }, searchBaseUrl)}
|
|
active={activeFilters.sort === option.key}
|
|
>
|
|
{option.label}
|
|
</FilterLink>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Saved Lists</p>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{savedLists.length}</span>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
<FilterLink href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, libraryUrl)} active={!activeFilters.list}>All saved collections</FilterLink>
|
|
{savedLists.map((list) => (
|
|
<FilterLink key={list.id} href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, list.url || libraryUrl)} active={Number(activeFilters.list) === Number(list.id)} count={list.items_count}>{list.title}</FilterLink>
|
|
))}
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateList} className="mt-5 space-y-3">
|
|
<input value={newListTitle} onChange={(event) => setNewListTitle(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Create a saved list" maxLength={120} />
|
|
<button type="submit" disabled={busy === 'create-list'} className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'create-list' ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Create list</button>
|
|
</form>
|
|
</section>
|
|
</aside>
|
|
|
|
<div className="space-y-8">
|
|
{recentlyRevisited.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recently Revisited</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Jump back into active references</h2>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recentlyRevisited.length}</span>
|
|
</div>
|
|
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
|
{recentlyRevisited.map((collection) => (
|
|
<CollectionCard key={`revisited-${collection.id}`} collection={collection} isOwner={false} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section>
|
|
{collections.length ? (
|
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
|
{collections.map((collection, index) => (
|
|
<div key={collection.id} className="space-y-3">
|
|
<CollectionCard collection={collection} isOwner={false} />
|
|
{(collection.saved_because || collection.last_viewed_at) ? (
|
|
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
|
{collection.saved_because ? <span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-100"><i className="fa-solid fa-lightbulb fa-fw" />{collection.saved_because}</span> : null}
|
|
{collection.last_viewed_at ? <span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold text-slate-200"><i className="fa-solid fa-clock-rotate-left fa-fw" />Last revisited {formatDateTime(collection.last_viewed_at)}</span> : null}
|
|
</div>
|
|
) : null}
|
|
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
|
<button type="button" onClick={() => handleUnsave(collection)} disabled={busy === `unsave-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `unsave-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-bookmark-slash'} fa-fw`} />Remove from saved</button>
|
|
{activeList ? <button type="button" onClick={() => handleRemoveFromList(collection)} disabled={busy === `remove-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `remove-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-minus'} fa-fw`} />Remove from list</button> : null}
|
|
{activeList && collections.length > 1 ? (
|
|
<div className="ml-auto inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-[#0d1726] p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReorderCollection(collection.id, 'up')}
|
|
disabled={index === 0 || busy === `reorder-${collection.id}`}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={`Move ${collection.title} up`}
|
|
>
|
|
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-up'} fa-fw`} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleReorderCollection(collection.id, 'down')}
|
|
disabled={index === collections.length - 1 || busy === `reorder-${collection.id}`}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
|
aria-label={`Move ${collection.title} down`}
|
|
>
|
|
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-down'} fa-fw`} />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{savedLists.length ? (
|
|
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
|
<select value={selectedLists[collection.id] || savedLists[0]?.id || ''} onChange={(event) => setSelectedLists((current) => ({ ...current, [collection.id]: event.target.value }))} className="min-w-[180px] rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-2.5 text-sm text-white outline-none">
|
|
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
|
|
</select>
|
|
<button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button>
|
|
</div>
|
|
) : null}
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Private Note</p>
|
|
<button type="button" onClick={() => handleSaveNote(collection.id)} disabled={busy === `note-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `note-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-note-sticky'} fa-fw`} />Save note</button>
|
|
</div>
|
|
<textarea
|
|
value={notes[collection.id] || ''}
|
|
onChange={(event) => setNotes((current) => ({ ...current, [collection.id]: event.target.value }))}
|
|
rows={3}
|
|
maxLength={1000}
|
|
placeholder="Why did you save this collection? Add campaign context, inspiration notes, or follow-up ideas."
|
|
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
activeFilters.q || activeFilters.filter !== 'all' || activeFilters.sort !== 'saved_desc' || activeFilters.list
|
|
? <div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center text-sm text-slate-300">No saved collections match the current search or filters.</div>
|
|
: <EmptyState browseUrl={browseUrl} />
|
|
)}
|
|
</section>
|
|
|
|
{recommendedCollections.length ? (
|
|
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Recommended Next</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Because of what you save</h2>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recommendedCollections.length}</span>
|
|
</div>
|
|
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
|
{recommendedCollections.map((collection) => (
|
|
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|