597 lines
25 KiB
JavaScript
597 lines
25 KiB
JavaScript
import React, { useEffect, useState } from 'react'
|
|
import { router } from '@inertiajs/react'
|
|
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
|
|
|
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 ActionLink({ href, icon, label, onClick }) {
|
|
if (!href) return null
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={onClick}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]"
|
|
>
|
|
<i className={`${icon} text-[11px]`} />
|
|
<span>{label}</span>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function RequestActionButton({ action, onExecute, busyKey }) {
|
|
if (!action || action.type !== 'request') return null
|
|
|
|
const isBusy = busyKey === `${action.key}:${action.url}`
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onExecute(action)}
|
|
disabled={isBusy}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<i className={`${action.icon} text-[11px]`} />
|
|
<span>{isBusy ? 'Working...' : action.label}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function PreviewLink({ item }) {
|
|
if (!item?.preview_url) return null
|
|
|
|
return <ActionLink href={item.preview_url} icon="fa-solid fa-eye" label="Preview" />
|
|
}
|
|
|
|
function GridCard({ item, onExecuteAction, busyKey }) {
|
|
const handleEditClick = () => {
|
|
trackStudioEvent('studio_item_edited', {
|
|
surface: studioSurface(),
|
|
module: item.module,
|
|
item_module: item.module,
|
|
item_id: item.numeric_id,
|
|
meta: {
|
|
action: 'edit',
|
|
},
|
|
})
|
|
}
|
|
|
|
return (
|
|
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.22)] transition hover:-translate-y-0.5 hover:border-white/20">
|
|
<div className="relative aspect-[1.15/1] overflow-hidden bg-slate-950/70">
|
|
{item.image_url ? (
|
|
<img src={item.image_url} alt={item.title} className="h-full w-full object-cover" loading="lazy" />
|
|
) : (
|
|
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_55%),linear-gradient(135deg,_rgba(15,23,42,0.9),_rgba(2,6,23,0.95))] text-slate-400">
|
|
<i className={`${item.module_icon} text-3xl`} />
|
|
</div>
|
|
)}
|
|
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-black/10 bg-black/45 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
|
|
<i className={`${item.module_icon} text-[10px]`} />
|
|
<span>{item.module_label}</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4 p-5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-base font-semibold text-white">{item.title}</h3>
|
|
<p className="mt-1 truncate text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
|
</div>
|
|
<span className={`inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
|
|
{String(item.status || 'unknown').replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
|
|
{item.workflow?.readiness && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
|
|
{item.workflow.readiness.label}
|
|
</span>
|
|
{item.workflow.is_stale_draft && (
|
|
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
|
|
Stale draft
|
|
</span>
|
|
)}
|
|
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
|
{item.workflow.readiness.score}/{item.workflow.readiness.max} ready
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
|
{item.description || 'No description yet.'}
|
|
</p>
|
|
|
|
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
|
|
<div className="rounded-2xl border border-white/5 bg-slate-950/35 p-3 text-xs text-slate-400">
|
|
{item.workflow.readiness.missing.slice(0, 2).join(' • ')}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-3 gap-2 rounded-2xl border border-white/5 bg-slate-950/40 p-3 text-xs text-slate-400">
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-[0.16em]">Views</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'views')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-[0.16em]">Reactions</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'appreciation')}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-[0.16em]">Comments</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">{metricValue(item, 'comments')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
<span>Updated {formatDate(item.updated_at)}</span>
|
|
{item.published_at && <span>Published {formatDate(item.published_at)}</span>}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
|
<PreviewLink item={item} />
|
|
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
|
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
|
{(item.actions || []).map((action) => (
|
|
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
|
))}
|
|
</div>
|
|
|
|
{Array.isArray(item.workflow?.cross_module_actions) && item.workflow.cross_module_actions.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 border-t border-white/5 pt-3">
|
|
{item.workflow.cross_module_actions.slice(0, 2).map((action) => (
|
|
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function ListRow({ item, onExecuteAction, busyKey }) {
|
|
const handleEditClick = () => {
|
|
trackStudioEvent('studio_item_edited', {
|
|
surface: studioSurface(),
|
|
module: item.module,
|
|
item_module: item.module,
|
|
item_id: item.numeric_id,
|
|
meta: {
|
|
action: 'edit',
|
|
},
|
|
})
|
|
}
|
|
|
|
return (
|
|
<article className="grid gap-4 rounded-[24px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 md:grid-cols-[120px_minmax(0,1fr)_auto] md:items-center">
|
|
<div className="h-24 overflow-hidden rounded-2xl bg-slate-950/60">
|
|
{item.image_url ? (
|
|
<img src={item.image_url} alt={item.title} className="h-full w-full object-cover" loading="lazy" />
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-slate-400">
|
|
<i className={`${item.module_icon} text-2xl`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
|
<i className={`${item.module_icon} text-[10px]`} />
|
|
{item.module_label}
|
|
</span>
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium capitalize ${statusClasses(item.status)}`}>
|
|
{String(item.status || 'unknown').replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
|
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
|
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{item.workflow?.readiness && (
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(item.workflow.readiness)}`}>
|
|
{item.workflow.readiness.label}
|
|
</span>
|
|
)}
|
|
{item.workflow?.is_stale_draft && (
|
|
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-[11px] font-medium text-amber-100">
|
|
Stale draft
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-4 text-xs text-slate-500">
|
|
<span>{metricValue(item, 'views')} views</span>
|
|
<span>{metricValue(item, 'appreciation')} reactions</span>
|
|
<span>{metricValue(item, 'comments')} comments</span>
|
|
<span>Updated {formatDate(item.updated_at)}</span>
|
|
</div>
|
|
{Array.isArray(item.workflow?.readiness?.missing) && item.workflow.readiness.missing.length > 0 && (
|
|
<div className="mt-3 text-xs text-slate-500">{item.workflow.readiness.missing.slice(0, 2).join(' • ')}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 md:justify-end">
|
|
<ActionLink href={item.edit_url || item.manage_url} icon="fa-solid fa-pen-to-square" label="Edit" onClick={handleEditClick} />
|
|
<PreviewLink item={item} />
|
|
<ActionLink href={item.analytics_url} icon="fa-solid fa-chart-line" label="Analytics" />
|
|
<ActionLink href={item.view_url} icon="fa-solid fa-arrow-up-right-from-square" label="Open" />
|
|
{(item.actions || []).map((action) => (
|
|
<RequestActionButton key={`${item.id}-${action.key}`} action={{ ...action, item_id: item.numeric_id, item_module: item.module }} onExecute={onExecuteAction} busyKey={busyKey} />
|
|
))}
|
|
{(item.workflow?.cross_module_actions || []).slice(0, 2).map((action) => (
|
|
<ActionLink key={`${item.id}-${action.label}`} href={action.href} icon={action.icon} label={action.label} />
|
|
))}
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function AdvancedFilterControl({ filter, onChange }) {
|
|
if (filter.type === 'select') {
|
|
return (
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
|
<select
|
|
value={filter.value || 'all'}
|
|
onChange={(event) => onChange(filter.key, event.target.value)}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
|
>
|
|
{(filter.options || []).map((option) => (
|
|
<option key={option.value} value={option.value} className="bg-slate-900">
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
|
<input
|
|
type="search"
|
|
value={filter.value || ''}
|
|
onChange={(event) => onChange(filter.key, event.target.value)}
|
|
placeholder={filter.placeholder || filter.label}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
|
/>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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 filters = listing?.filters || {}
|
|
const items = listing?.items || []
|
|
const meta = listing?.meta || {}
|
|
const advancedFilters = listing?.advanced_filters || []
|
|
|
|
useEffect(() => {
|
|
const stored = window.localStorage.getItem('studio-content-view')
|
|
if (stored === 'grid' || stored === 'list') {
|
|
setViewMode(stored)
|
|
return
|
|
}
|
|
|
|
if (listing?.default_view === 'grid' || listing?.default_view === 'list') {
|
|
setViewMode(listing.default_view)
|
|
}
|
|
}, [listing?.default_view])
|
|
|
|
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 executeAction = async (action) => {
|
|
if (!action?.url || action.type !== 'request') {
|
|
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 (
|
|
<div className="space-y-6">
|
|
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
|
<div className={`grid gap-3 md:grid-cols-2 ${advancedFilters.length > 0 ? 'xl:grid-cols-5' : 'xl:grid-cols-4'}`}>
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
|
<input
|
|
type="search"
|
|
value={filters.q || ''}
|
|
onChange={(event) => updateQuery({ q: event.target.value })}
|
|
placeholder="Title, description, module"
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
|
/>
|
|
</label>
|
|
|
|
{!hideModuleFilter && (
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
|
<select
|
|
value={filters.module || 'all'}
|
|
onChange={(event) => updateQuery({ module: event.target.value })}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
|
>
|
|
{(listing?.module_options || []).map((option) => (
|
|
<option key={option.value} value={option.value} className="bg-slate-900">
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{!hideBucketFilter && (
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
|
|
<select
|
|
value={filters.bucket || 'all'}
|
|
onChange={(event) => updateQuery({ bucket: event.target.value })}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
|
>
|
|
{(listing?.bucket_options || []).map((option) => (
|
|
<option key={option.value} value={option.value} className="bg-slate-900">
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
|
<select
|
|
value={filters.sort || 'updated_desc'}
|
|
onChange={(event) => updateQuery({ sort: event.target.value })}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
|
>
|
|
{(listing?.sort_options || []).map((option) => (
|
|
<option key={option.value} value={option.value} className="bg-slate-900">
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
{advancedFilters.map((filter) => (
|
|
<AdvancedFilterControl key={filter.key} filter={filter} onChange={(key, value) => updateQuery({ [key]: value })} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
|
<div className="inline-flex rounded-full border border-white/10 bg-black/20 p-1">
|
|
{[
|
|
{ value: 'grid', icon: 'fa-solid fa-table-cells-large', label: 'Grid view' },
|
|
{ value: 'list', icon: 'fa-solid fa-list', label: 'List view' },
|
|
].map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => updateView(option.value)}
|
|
className={`inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-semibold transition ${viewMode === option.value ? 'bg-white text-slate-950' : 'text-slate-300 hover:text-white'}`}
|
|
>
|
|
<i className={option.icon} />
|
|
<span className="hidden sm:inline">{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{quickCreate.map((action) => (
|
|
<a
|
|
key={action.key}
|
|
href={action.url}
|
|
onClick={() => 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"
|
|
>
|
|
<i className={action.icon} />
|
|
<span>New {action.label}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
|
|
<p>
|
|
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> items
|
|
</p>
|
|
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
|
</div>
|
|
|
|
{items.length > 0 ? (
|
|
viewMode === 'grid' ? (
|
|
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
|
{items.map((item) => <GridCard key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{items.map((item) => <ListRow key={item.id} item={item} onExecuteAction={executeAction} busyKey={busyKey} />)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<section className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
|
|
<h3 className="text-xl font-semibold text-white">{emptyTitle}</h3>
|
|
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">{emptyBody}</p>
|
|
</section>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
|
<button
|
|
type="button"
|
|
disabled={(meta.current_page || 1) <= 1}
|
|
onClick={() => updateQuery({ page: Math.max(1, (meta.current_page || 1) - 1) })}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
<i className="fa-solid fa-arrow-left" />
|
|
Previous
|
|
</button>
|
|
|
|
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Creator Studio</span>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
|
|
onClick={() => updateQuery({ page: (meta.current_page || 1) + 1 })}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
Next
|
|
<i className="fa-solid fa-arrow-right" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |