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.title} ) : (
)}
{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.title} ) : (
)}
{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 && ( )} {visibleItems.map((item) => { const isSelected = selectedOnPage.includes(Number(item.numeric_id)) return ( {supportsArtworkBulk && ( )} ) })}
Item Status Category Updated Stats Actions
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.title} ) : (
)}
{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) => ( ))}
) ) : (

{emptyTitle}

{emptyBody}

)}
Creator Studio
) }