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 (
No saved collections yet
Save collections to build a personal reference library for inspiration, campaigns, and creators you want to revisit.
)
}
function FilterLink({ href, active, children, count }) {
return (
{children}
{typeof count === 'number' ? {count} : null}
)
}
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 (
<>
{seo?.title || 'Saved Collections — Skinbase Nova'}
{seo?.canonical ? : null}
{seo?.canonical ? : null}
{listSchema ? : null}
Library
Saved collections
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.
{activeList ? Viewing list: {activeList.title}
: null}
{activeFilters.q ? Search: {activeFilters.q}
: null}
{notice ? {notice}
: null}
Filters
{filterOptions.map((option) => (
{option.label}
))}
Sort
{sortOptions.map((option) => (
{option.label}
))}
Saved Lists
{savedLists.length}
All saved collections
{savedLists.map((list) => (
{list.title}
))}
{recentlyRevisited.length ? (
Recently Revisited
Jump back into active references
{recentlyRevisited.length}
{recentlyRevisited.map((collection) => (
))}
) : null}
{collections.length ? (
{collections.map((collection, index) => (
{(collection.saved_because || collection.last_viewed_at) ? (
{collection.saved_because ? {collection.saved_because} : null}
{collection.last_viewed_at ? Last revisited {formatDateTime(collection.last_viewed_at)} : null}
) : null}
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"> Remove from saved
{activeList ?
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"> Remove from list : null}
{activeList && collections.length > 1 ? (
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`}
>
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`}
>
) : null}
{savedLists.length ? (
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) => {list.title} )}
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"> Add to list
) : null}
Private Note
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"> Save note
))}
) : (
activeFilters.q || activeFilters.filter !== 'all' || activeFilters.sort !== 'saved_desc' || activeFilters.list
? No saved collections match the current search or filters.
:
)}
{recommendedCollections.length ? (
Recommended Next
Because of what you save
{recommendedCollections.length}
{recommendedCollections.map((collection) => (
))}
) : null}
>
)
}