Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import ConfirmDangerModal from './ConfirmDangerModal'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
import Checkbox from '../ui/Checkbox'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unscheduled'
|
||||
@@ -79,6 +81,11 @@ function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
|
||||
|| fallback
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
|
||||
function ActionLink({ href, icon, label, onClick }) {
|
||||
if (!href) return null
|
||||
|
||||
@@ -176,7 +183,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
||||
)}
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
||||
{item.description || 'No description yet.'}
|
||||
{stripHtml(item.description) || 'No description yet.'}
|
||||
</p>
|
||||
|
||||
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
|
||||
@@ -266,7 +273,7 @@ function ListRow({ item, onExecuteAction, busyKey }) {
|
||||
</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>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{stripHtml(item.description) || 'No description yet.'}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{readiness && (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
|
||||
@@ -324,31 +331,48 @@ function materializeFilter(filter, pendingFilters) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectOptions(options = []) {
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
group: option.group,
|
||||
disabled: option.disabled,
|
||||
icon: option.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
function FilterField({ label, children }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedFilterControl({ filter, onChange, value }) {
|
||||
const controlValue = value ?? filter.value
|
||||
|
||||
if (filter.type === 'select') {
|
||||
const options = selectOptions(filter.options || [])
|
||||
const searchable = filter.searchable ?? options.length > 8
|
||||
|
||||
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
|
||||
<FilterField label={filter.label}>
|
||||
<NovaSelect
|
||||
id={`studio-filter-${filter.key}`}
|
||||
options={options}
|
||||
value={controlValue || '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>
|
||||
onChange={(nextValue) => onChange(filter.key, nextValue ?? 'all')}
|
||||
placeholder={filter.label}
|
||||
searchable={searchable}
|
||||
/>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<FilterField label={filter.label}>
|
||||
<input
|
||||
type="search"
|
||||
value={controlValue || ''}
|
||||
@@ -356,7 +380,7 @@ function AdvancedFilterControl({ filter, onChange, 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>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -817,8 +841,7 @@ export default function StudioContentBrowser({
|
||||
<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 ${filterGridClass}`}>
|
||||
<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>
|
||||
<FilterField label="Search">
|
||||
<input
|
||||
type="search"
|
||||
value={pendingFilters.q}
|
||||
@@ -826,56 +849,44 @@ export default function StudioContentBrowser({
|
||||
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>
|
||||
</FilterField>
|
||||
|
||||
{!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
|
||||
<FilterField label="Module">
|
||||
<NovaSelect
|
||||
id="studio-filter-module"
|
||||
options={selectOptions(listing?.module_options || [])}
|
||||
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>
|
||||
onChange={(nextValue) => updateQuery({ module: nextValue ?? 'all' })}
|
||||
placeholder="All content"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
{!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
|
||||
<FilterField label="Status">
|
||||
<NovaSelect
|
||||
id="studio-filter-status"
|
||||
options={selectOptions(listing?.bucket_options || [])}
|
||||
value={pendingFilters.bucket}
|
||||
onChange={(event) => setPendingFilter('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>
|
||||
onChange={(nextValue) => setPendingFilter('bucket', nextValue ?? 'all')}
|
||||
placeholder="All"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
<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
|
||||
<FilterField label="Sort">
|
||||
<NovaSelect
|
||||
id="studio-filter-sort"
|
||||
options={selectOptions(listing?.sort_options || [])}
|
||||
value={pendingFilters.sort}
|
||||
onChange={(event) => setPendingFilter('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>
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
|
||||
placeholder="Recently updated"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
{advancedFilters.map((filter) => {
|
||||
const resolvedFilter = materializeFilter(filter, pendingFilters)
|
||||
@@ -960,15 +971,13 @@ export default function StudioContentBrowser({
|
||||
{viewMode === 'table' && supportsArtworkBulk && (
|
||||
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
label="Select page"
|
||||
/>
|
||||
<span>Select page</span>
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
|
||||
</span>
|
||||
@@ -1014,11 +1023,9 @@ export default function StudioContentBrowser({
|
||||
<tr>
|
||||
{supportsArtworkBulk && (
|
||||
<th scope="col" className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
aria-label="Select all artworks on this page"
|
||||
/>
|
||||
</th>
|
||||
@@ -1039,11 +1046,9 @@ export default function StudioContentBrowser({
|
||||
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||
{supportsArtworkBulk && (
|
||||
<td className="px-4 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => 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}`}
|
||||
/>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user