import React, { useEffect, useState } from 'react'
import { router } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import ConfirmDangerModal from './ConfirmDangerModal'
function formatDate(value) {
if (!value) return 'Unscheduled'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unscheduled'
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function metricValue(item, key) {
return Number(item?.metrics?.[key] ?? 0).toLocaleString()
}
function readinessClasses(readiness) {
if (!readiness) return 'border-white/15 bg-white/5 text-slate-300'
if (readiness.can_publish && readiness.score >= readiness.max) return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
if (readiness.can_publish) return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
}
function statusClasses(status) {
switch (status) {
case 'published':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
case 'draft':
case 'pending_review':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
case 'scheduled':
case 'processing':
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
case 'archived':
case 'hidden':
case 'rejected':
return 'border-white/15 bg-white/5 text-slate-300'
default:
return 'border-white/15 bg-white/5 text-slate-200'
}
}
function itemReadiness(item) {
if (item?.status === 'published') return null
return item?.workflow?.readiness ?? null
}
function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
return payload.errors[0]
}
return payload?.message
|| payload?.error
|| payload?.errors?.confirm?.[0]
|| payload?.errors?.action?.[0]
|| fallback
}
function ActionLink({ href, icon, label, onClick }) {
if (!href) return null
return (
{label}
)
}
function RequestActionButton({ action, onExecute, busyKey }) {
if (!action || action.type !== 'request') return null
const isBusy = busyKey === `${action.key}:${action.url}`
return (
)
}
function PreviewLink({ item }) {
if (!item?.preview_url) return null
return
}
function GridCard({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
module: item.module,
item_module: item.module,
item_id: item.numeric_id,
meta: {
action: 'edit',
},
})
}
return (
{item.image_url ? (

) : (
)}
{item.module_label}
{item.title}
{item.subtitle || item.visibility || 'Untitled metadata'}
{String(item.status || 'unknown').replace('_', ' ')}
{readiness && (
{readiness.label}
{item.workflow.is_stale_draft && (
Stale draft
)}
{readiness.score}/{readiness.max} ready
)}
{item.description || 'No description yet.'}
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
{readiness.missing.slice(0, 2).join(' • ')}
)}
Views
{metricValue(item, 'views')}
Reactions
{metricValue(item, 'appreciation')}
Comments
{metricValue(item, 'comments')}
Updated {formatDate(item.updated_at)}
{item.published_at && Published {formatDate(item.published_at)}}
{(item.actions || []).map((action) => (
))}
{Array.isArray(item.workflow?.cross_module_actions) && item.workflow.cross_module_actions.length > 0 && (
{item.workflow.cross_module_actions.slice(0, 2).map((action) => (
))}
)}
)
}
function ListRow({ item, onExecuteAction, busyKey }) {
const readiness = itemReadiness(item)
const handleEditClick = () => {
trackStudioEvent('studio_item_edited', {
surface: studioSurface(),
module: item.module,
item_module: item.module,
item_id: item.numeric_id,
meta: {
action: 'edit',
},
})
}
return (
{item.image_url ? (

) : (
)}
{item.module_label}
{String(item.status || 'unknown').replace('_', ' ')}
{item.title}
{item.subtitle || item.visibility || 'Untitled metadata'}
{item.description || 'No description yet.'}
{readiness && (
{readiness.label}
)}
{item.workflow?.is_stale_draft && (
Stale draft
)}
{metricValue(item, 'views')} views
{metricValue(item, 'appreciation')} reactions
{metricValue(item, 'comments')} comments
Updated {formatDate(item.updated_at)}
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
{readiness.missing.slice(0, 2).join(' • ')}
)}
{(item.actions || []).map((action) => (
))}
{(item.workflow?.cross_module_actions || []).slice(0, 2).map((action) => (
))}
)
}
function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value
if (filter.type === 'select') {
return (
)
}
return (
)
}
export default function StudioContentBrowser({
listing,
quickCreate = [],
hideModuleFilter = false,
hideBucketFilter = false,
emptyTitle = 'Nothing here yet',
emptyBody = 'Try adjusting filters or create something new.',
}) {
const [viewMode, setViewMode] = useState('grid')
const [busyKey, setBusyKey] = useState(null)
const [selectedIds, setSelectedIds] = useState([])
const [bulkBusy, setBulkBusy] = useState(false)
const [optimisticRemovedIds, setOptimisticRemovedIds] = useState([])
const [pendingFilters, setPendingFilters] = useState({
q: '',
bucket: 'all',
sort: 'updated_desc',
category: 'all',
tag: '',
})
const [deleteDialog, setDeleteDialog] = useState({
open: false,
title: '',
message: '',
target: null,
})
const filters = listing?.filters || {}
const items = listing?.items || []
const meta = listing?.meta || {}
const advancedFilters = listing?.advanced_filters || []
const visibleItems = items.filter((item) => !optimisticRemovedIds.includes(Number(item.numeric_id)))
const currentModule = filters.module || listing?.module || items[0]?.module || null
const visibleQuickCreate = hideModuleFilter && currentModule && currentModule !== 'all'
? quickCreate.filter((action) => action.key === currentModule)
: quickCreate
const supportsArtworkBulk = currentModule === 'artworks' && items.every((item) => item.module === 'artworks')
const selectableIds = supportsArtworkBulk
? visibleItems.map((item) => Number(item.numeric_id)).filter((value) => Number.isInteger(value) && value > 0)
: []
const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const selectedOnPage = selectedIds.filter((id) => selectableIds.includes(id))
const visibleTotal = Math.max(0, Number(meta.total || 0) - optimisticRemovedIds.length)
const filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1
const filterGridClass = filterControlCount <= 4
? 'xl:grid-cols-4'
: filterControlCount === 5
? 'xl:grid-cols-5'
: filterControlCount === 6
? 'xl:grid-cols-6'
: 'xl:grid-cols-6 2xl:grid-cols-7'
useEffect(() => {
const stored = window.localStorage.getItem('studio-content-view')
if (stored === 'grid' || stored === 'list' || stored === 'table') {
setViewMode(stored)
return
}
if (listing?.default_view === 'grid' || listing?.default_view === 'list' || listing?.default_view === 'table') {
setViewMode(listing.default_view)
}
}, [listing?.default_view])
useEffect(() => {
setSelectedIds((current) => current.filter((id) => selectableIds.includes(id)))
}, [visibleItems, supportsArtworkBulk])
useEffect(() => {
setOptimisticRemovedIds([])
}, [items])
useEffect(() => {
setPendingFilters({
q: filters.q || '',
bucket: filters.bucket || 'all',
sort: filters.sort || 'updated_desc',
category: filters.category || 'all',
tag: filters.tag || '',
})
}, [filters.q, filters.bucket, filters.sort, filters.category, filters.tag])
const updateQuery = (patch) => {
const next = {
...filters,
...patch,
}
if (patch.page == null) {
next.page = 1
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: filters.module || listing?.module || null,
meta: {
patch,
},
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const updateView = (nextMode) => {
setViewMode(nextMode)
window.localStorage.setItem('studio-content-view', nextMode)
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: filters.module || listing?.module || null,
meta: {
view_mode: nextMode,
},
})
}
const setPendingFilter = (key, value) => {
setPendingFilters((current) => ({
...current,
[key]: value,
}))
}
const submitSearch = () => {
updateQuery({
q: pendingFilters.q,
bucket: pendingFilters.bucket,
sort: pendingFilters.sort,
category: pendingFilters.category,
tag: pendingFilters.tag,
})
}
const addOptimisticallyRemovedIds = (ids) => {
setOptimisticRemovedIds((current) => Array.from(new Set([
...current,
...ids.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0),
])))
}
const toggleSelected = (numericId) => {
setSelectedIds((current) => current.includes(numericId)
? current.filter((id) => id !== numericId)
: [...current, numericId])
}
const toggleSelectAllVisible = () => {
if (!supportsArtworkBulk || selectableIds.length === 0) {
return
}
setSelectedIds((current) => {
if (allVisibleSelected) {
return current.filter((id) => !selectableIds.includes(id))
}
return Array.from(new Set([...current, ...selectableIds]))
})
}
const executeBulkAction = async (actionKey) => {
if (!supportsArtworkBulk || selectedIds.length === 0 || bulkBusy) {
return
}
const labels = {
publish: 'publish',
unpublish: 'move to draft',
archive: 'archive',
unarchive: 'restore',
delete: 'delete permanently',
}
if (actionKey === 'delete') {
setDeleteDialog({
open: true,
title: `Delete ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`,
message: 'This permanently removes the selected artworks. This cannot be undone.',
target: {
kind: 'bulk',
actionKey,
ids: [...selectedIds],
},
})
return
}
if (!window.confirm(`Are you sure you want to ${labels[actionKey] || actionKey} ${selectedIds.length} artwork${selectedIds.length === 1 ? '' : 's'}?`)) {
return
}
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
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: JSON.stringify({
action: actionKey,
artwork_ids: selectedIds,
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: actionKey,
count: selectedIds.length,
},
})
setSelectedIds([])
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
}
const closeDeleteDialog = () => {
setDeleteDialog({
open: false,
title: '',
message: '',
target: null,
})
}
const confirmDeleteDialog = async () => {
const target = deleteDialog.target
if (!target) {
closeDeleteDialog()
return
}
if (target.kind === 'bulk') {
setBulkBusy(true)
try {
const response = await fetch('/api/studio/artworks/bulk', {
method: 'POST',
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: JSON.stringify({
action: 'delete',
artwork_ids: target.ids,
confirm: 'DELETE',
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(bulkErrorMessage(payload))
}
trackStudioEvent('studio_filter_used', {
surface: studioSurface(),
module: 'artworks',
meta: {
bulk_action: 'delete',
count: target.ids.length,
},
})
addOptimisticallyRemovedIds(target.ids)
setSelectedIds([])
closeDeleteDialog()
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Bulk action failed.')
} finally {
setBulkBusy(false)
}
return
}
if (target.kind === 'single') {
const action = target.action
const requestKey = `${action.key}:${action.url}`
setBusyKey(requestKey)
try {
const response = await fetch(action.url, {
method: String(action.method || 'post').toUpperCase(),
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: action.payload ? JSON.stringify(action.payload) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Request failed')
}
addOptimisticallyRemovedIds(action.payload?.artwork_ids || [])
closeDeleteDialog()
if (action.redirect_pattern && payload?.data?.id) {
window.location.assign(action.redirect_pattern.replace('__ID__', String(payload.data.id)))
return
}
if (payload?.redirect) {
window.location.assign(payload.redirect)
return
}
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Action failed.')
} finally {
setBusyKey(null)
}
}
}
const executeAction = async (action) => {
if (!action?.url || action.type !== 'request') {
return
}
if (action.key === 'delete') {
setDeleteDialog({
open: true,
title: 'Delete artwork permanently?',
message: action.confirm || 'This artwork will be permanently removed and cannot be restored.',
target: {
kind: 'single',
action,
},
})
return
}
if (action.confirm && !window.confirm(action.confirm)) {
return
}
const requestKey = `${action.key}:${action.url}`
setBusyKey(requestKey)
try {
const response = await fetch(action.url, {
method: String(action.method || 'post').toUpperCase(),
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: action.payload ? JSON.stringify(action.payload) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Request failed')
}
if (action.key === 'archive') {
trackStudioEvent('studio_item_archived', {
surface: studioSurface(),
module: filters.module || null,
item_module: action.item_module || null,
item_id: action.item_id || null,
meta: {
action: action.key,
url: action.url,
},
})
}
if (action.key === 'restore') {
trackStudioEvent('studio_item_restored', {
surface: studioSurface(),
module: filters.module || null,
item_module: action.item_module || null,
item_id: action.item_id || null,
meta: {
action: action.key,
url: action.url,
},
})
}
if (action.redirect_pattern && payload?.data?.id) {
window.location.assign(action.redirect_pattern.replace('__ID__', String(payload.data.id)))
return
}
if (payload?.redirect) {
window.location.assign(payload.redirect)
return
}
router.reload({ preserveScroll: true, preserveState: true })
} catch (error) {
window.alert(error?.message || 'Action failed.')
} finally {
setBusyKey(null)
}
}
return (
{!hideModuleFilter && (
)}
{!hideBucketFilter && (
)}
{advancedFilters.map((filter) => (
{
if (key === 'category' || key === 'tag') {
setPendingFilter(key, value)
return
}
updateQuery({ [key]: value })
}}
/>
))}
{[
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
{ value: 'table', icon: 'fa-solid fa-table-list', label: 'Table view' },
].map((option) => (
))}
{visibleQuickCreate.map((action) => (
trackStudioEvent('studio_quick_create_used', {
surface: studioSurface(),
module: action.key,
meta: {
href: action.url,
label: action.label,
},
})}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
New {action.label}
))}
Showing {visibleItems.length} of {visibleTotal.toLocaleString()} items
Page {meta.current_page || 1} of {meta.last_page || 1}
{viewMode === 'table' && supportsArtworkBulk && (
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
{[
{ key: 'publish', label: 'Publish', icon: 'fa-solid fa-rocket' },
{ key: 'unpublish', label: 'Draft', icon: 'fa-solid fa-file-pen' },
{ key: 'archive', label: 'Archive', icon: 'fa-solid fa-box-archive' },
{ key: 'unarchive', label: 'Restore', icon: 'fa-solid fa-rotate-left' },
{ key: 'delete', label: 'Delete', icon: 'fa-solid fa-trash' },
].map((action) => (
))}
)}
{visibleItems.length > 0 ? (
viewMode === 'grid' ? (
{visibleItems.map((item) => )}
) : viewMode === 'list' ? (
{visibleItems.map((item) => )}
) : (
{supportsArtworkBulk && (
|
|
)}
Item |
Status |
Category |
Updated |
Stats |
Actions |
{visibleItems.map((item) => {
const isSelected = selectedOnPage.includes(Number(item.numeric_id))
return (
{supportsArtworkBulk && (
|
toggleSelected(Number(item.numeric_id))}
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label={`Select ${item.title}`}
/>
|
)}
{item.image_url ? (

) : (
)}
{item.module_label}
#{item.numeric_id}
{item.title}
{item.subtitle || item.visibility || 'Untitled metadata'}
{Array.isArray(item.taxonomies?.tags) && item.taxonomies.tags.length > 0 && (
{item.taxonomies.tags.slice(0, 3).map((tag) => (
{tag.name}
))}
)}
|
{String(item.status || 'unknown').replace('_', ' ')}
{itemReadiness(item) && (
{itemReadiness(item).label}
)}
|
{item.subtitle || item.taxonomies?.categories?.[0]?.name || 'Uncategorized'}
{item.visibility || 'private'}
|
Updated {formatDate(item.updated_at)}
Created {formatDate(item.created_at)}
{item.published_at && Published {formatDate(item.published_at)} }
|
{metricValue(item, 'views')} views
{metricValue(item, 'appreciation')} reactions
{metricValue(item, 'comments')} comments
|
{(item.actions || []).slice(0, 2).map((action) => (
))}
|
)
})}
)
) : (
)}
Creator Studio
)
}