Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -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>