Replace native selects with NovaSelect

This commit is contained in:
2026-05-01 07:45:37 +02:00
parent 67be537c86
commit 35011001ba
55 changed files with 3136 additions and 1662 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -105,18 +106,14 @@ export default function StudioActivity() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search activity</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Message, actor, or module" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span>
<select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{typeOptions.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">
<NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={typeOptions} searchable={false} />
</div>
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Content type</span>
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={moduleOptions} searchable={false} />
</div>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', type: 'all', module: 'all' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>

View File

@@ -791,6 +791,7 @@ export default function StudioArtworkEdit() {
.map((world) => ({
world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '',
source_surface: 'navigation',
}))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
evolution_target_artwork_id: evolutionTarget?.id || null,
@@ -2166,18 +2167,14 @@ export default function StudioArtworkEdit() {
<div className="space-y-4">
<label className="block">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span>
<select
<NovaSelect
value={evolutionRelationType}
onChange={(event) => setEvolutionRelationType(event.target.value)}
onChange={(value) => setEvolutionRelationType(value)}
disabled={saving || !evolutionTarget}
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
>
{evolutionRelationTypes.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
className="mt-2"
options={evolutionRelationTypes.map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</label>
{errors.evolution_relation_type?.[0] ? <p className="text-sm text-red-400">{errors.evolution_relation_type[0]}</p> : null}
@@ -2474,6 +2471,7 @@ export default function StudioArtworkEdit() {
onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) ? { ...world, note } : world
)))}
analyticsContext={{ sourceSurface: 'navigation', sourceDetail: 'studio_artwork_edit' }}
/>
)}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
function formatDate(value) {
@@ -78,50 +79,35 @@ export default function StudioAssets() {
/>
</label>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Type</span>
<select
<NovaSelect
value={filters.type || 'all'}
onChange={(event) => updateFilters({ type: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(value) => updateFilters({ type: value })}
options={typeOptions.map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Source</span>
<select
<NovaSelect
value={filters.source || 'all'}
onChange={(event) => updateFilters({ source: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{sourceOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(value) => updateFilters({ source: value })}
options={sourceOptions.map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
<label className="space-y-2 text-sm text-slate-300">
<div 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
<NovaSelect
value={filters.sort || 'recent'}
onChange={(event) => updateFilters({ sort: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(value) => updateFilters({ sort: value })}
options={sortOptions.map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">

View File

@@ -3,6 +3,187 @@ import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
import NovaSelect from '../../components/ui/NovaSelect'
function parseFocusDate(value) {
if (!value) return new Date()
const parsed = new Date(`${value}T12:00:00`)
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
}
function toFocusDateValue(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function shiftFocusDate(value, view, direction) {
const next = new Date(parseFocusDate(value))
if (view === 'week') {
next.setDate(next.getDate() + (direction * 7))
return toFocusDateValue(next)
}
const originalDay = next.getDate()
next.setDate(1)
next.setMonth(next.getMonth() + direction)
const lastDayOfMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()
next.setDate(Math.min(originalDay, lastDayOfMonth))
return toFocusDateValue(next)
}
function itemHref(item) {
return item.edit_url || item.manage_url || item.preview_url || item.view_url || '#'
}
function CalendarThumb({ item, className = 'h-full w-full', showTime = false }) {
return (
<a href={itemHref(item)} className={`group relative block overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80 ${className}`}>
{item.image_url ? (
<img src={item.image_url} alt={item.title || ''} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
{item.module_label || 'Item'}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/70 to-transparent p-2">
<div className="truncate text-[11px] font-semibold text-white">{item.title}</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-slate-300">
<span className="truncate">{item.module_label}</span>
{showTime && item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
</div>
</div>
</a>
)
}
function CalendarInlineItem({ item, showTime = true }) {
return (
<a href={itemHref(item)} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-2.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/5">
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-slate-950/80">
{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 w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-[9px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
{String(item.module_label || 'Item').slice(0, 3)}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-400">
<span className="truncate">{item.module_label}</span>
{showTime && item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
</div>
</div>
</a>
)
}
function CalendarDayModal({ day, busyKey, endpoints, onAction, onClose, nowMs }) {
if (!day) return null
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 p-3 backdrop-blur-sm md:items-center md:p-6" role="dialog" aria-modal="true" aria-label={`Scheduled items for ${day.label || day.date}`}>
<button type="button" aria-label="Close day details" className="absolute inset-0 cursor-default" onClick={onClose} />
<div className="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_30%),linear-gradient(180deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.98))] shadow-2xl shadow-black/40">
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4 md:px-6">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Day queue</div>
<h3 className="mt-1 text-xl font-semibold text-white">{day.label || day.date}</h3>
<div className="mt-1 text-sm text-slate-400">{Number(day.count || 0).toLocaleString()} scheduled item{Number(day.count || 0) === 1 ? '' : 's'}</div>
</div>
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Close</button>
</div>
<div className="overflow-y-auto px-5 py-4 md:px-6 md:py-5">
<div className="grid gap-3 md:grid-cols-2">
{(day.detail_items || []).map((item) => (
<div key={item.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
<a href={itemHref(item)} className="group block">
<div className="relative h-36 overflow-hidden border-b border-white/10 bg-slate-950/80">
{item.image_url ? (
<img src={item.image_url} alt={item.title || ''} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-sm font-semibold uppercase tracking-[0.18em] text-sky-100/80">
{item.module_label || 'Item'}
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/75 to-transparent p-3">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-slate-300">
<span className="truncate">{item.module_label}</span>
{item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
</div>
</div>
</div>
</a>
<div className="p-4">
<div className="text-xs font-medium text-sky-200">{formatReleaseCountdown(item.scheduled_at, nowMs)}</div>
<div className="mt-1 text-xs text-slate-500">{item.subtitle || item.workflow?.readiness?.label || 'Scheduled item'}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => onAction(endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button>
<button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => onAction(endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button>
<a href={itemHref(item)} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/5">Open</a>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function CalendarMonthDay({ day, onOpenDetail }) {
const items = day.items || []
const overflowCount = Number(day.overflow_count || Math.max(0, Number(day.count || 0) - items.length))
const hasItems = items.length > 0
return (
<div
className={`min-h-[156px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}
>
<div className="flex items-center justify-between gap-2">
<span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span>
{hasItems ? (
<button type="button" onClick={() => onOpenDetail(day)} className="rounded-full border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">{day.count}</button>
) : (
<span className="rounded-full border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{day.count}</span>
)}
</div>
{hasItems ? (
<div className="mt-3 space-y-2">
{items.length === 1 ? (
<CalendarThumb item={items[0]} className="h-[92px]" />
) : (
<div className="grid grid-cols-2 gap-2">
{items.map((item) => (
<CalendarThumb key={item.id} item={item} className="h-[58px]" />
))}
</div>
)}
{overflowCount > 0 ? (
<button type="button" onClick={() => onOpenDetail(day)} className="w-full rounded-xl border border-dashed border-white/10 px-2.5 py-1.5 text-left text-[11px] font-medium text-slate-300 transition hover:border-sky-300/20 hover:bg-sky-300/5 hover:text-sky-100">
+{overflowCount} more scheduled
</button>
) : null}
</div>
) : (
<div className="mt-6 rounded-2xl border border-dashed border-white/10 px-3 py-6 text-center text-[11px] uppercase tracking-[0.18em] text-slate-600">
Quiet day
</div>
)}
</div>
)
}
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -26,10 +207,13 @@ export default function StudioCalendar() {
const calendar = props.calendar || {}
const filters = calendar.filters || {}
const summary = calendar.summary || {}
const currentView = filters.view || 'month'
const [busyKey, setBusyKey] = useState(null)
const [nowMs, setNowMs] = useState(() => Date.now())
const [selectedDay, setSelectedDay] = useState(null)
const updateFilters = (patch) => {
setSelectedDay(null)
const next = { ...filters, ...patch }
trackStudioEvent('studio_scheduled_opened', {
surface: studioSurface(),
@@ -43,6 +227,14 @@ export default function StudioCalendar() {
})
}
const shiftCalendar = (direction) => {
updateFilters({ focus_date: shiftFocusDate(filters.focus_date, currentView, direction) })
}
const resetCalendarFocus = () => {
updateFilters({ focus_date: toFocusDateValue(new Date()) })
}
const runAction = async (pattern, item, key) => {
const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id))
setBusyKey(`${key}:${item.id}`)
@@ -67,6 +259,26 @@ export default function StudioCalendar() {
return () => window.clearInterval(timer)
}, [calendar.scheduled_items, summary.next_publish_at])
useEffect(() => {
if (!selectedDay?.date) return
const nextSelectedDay = (calendar.month?.days || []).find((day) => day.date === selectedDay.date) || null
setSelectedDay(nextSelectedDay)
}, [calendar.month?.days, selectedDay?.date])
useEffect(() => {
if (!selectedDay) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setSelectedDay(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedDay])
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
@@ -83,9 +295,9 @@ export default function StudioCalendar() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search planning queue</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
</label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><select value={filters.view || 'month'} onChange={(event) => updateFilters({ view: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.view_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.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.module_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.18em] text-slate-500">Queue</span><select value={filters.status || 'scheduled'} onChange={(event) => updateFilters({ status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.status_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><NovaSelect value={filters.view || 'month'} onChange={(val) => updateFilters({ view: val })} options={calendar.view_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={calendar.module_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Queue</span><NovaSelect value={filters.status || 'scheduled'} onChange={(val) => updateFilters({ status: val })} options={calendar.status_options || []} searchable={false} /></div>
</div>
</section>
@@ -93,12 +305,22 @@ export default function StudioCalendar() {
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
{filters.view === 'week' ? (
<>
<h2 className="text-lg font-semibold text-white">{calendar.week?.label}</h2>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{calendar.week?.label}</h2>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">Week planning</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => shiftCalendar(-1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Prev week</button>
<button type="button" onClick={resetCalendarFocus} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Today</button>
<button type="button" onClick={() => shiftCalendar(1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Next week</button>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
{(calendar.week?.days || []).map((day) => (
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-3">
<div className="text-sm font-semibold text-white">{day.label}</div>
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 px-3 py-2 text-xs text-slate-200">{item.title}</a>) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <CalendarInlineItem key={item.id} item={item} />) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
</div>
))}
</div>
@@ -106,13 +328,23 @@ export default function StudioCalendar() {
) : filters.view === 'agenda' ? (
<>
<h2 className="text-lg font-semibold text-white">Agenda</h2>
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatScheduledDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <CalendarInlineItem key={item.id} item={item} />)}</div></div>)}</div>
</>
) : (
<>
<h2 className="text-lg font-semibold text-white">{calendar.month?.label}</h2>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{calendar.month?.label}</h2>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">Month planning</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => shiftCalendar(-1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Prev month</button>
<button type="button" onClick={resetCalendarFocus} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Today</button>
<button type="button" onClick={() => shiftCalendar(1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Next month</button>
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((label) => <div key={label} className="px-2 py-1">{label}</div>)}</div>
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <div key={day.date} className={`min-h-[120px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}><div className="flex items-center justify-between gap-2"><span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span><span className="text-[10px] uppercase tracking-[0.18em] text-slate-500">{day.count}</span></div><div className="mt-3 space-y-2">{day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-xl border border-white/10 px-2 py-1.5 text-[11px] text-slate-200">{item.title}</a>)}</div></div>)}</div>
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <CalendarMonthDay key={day.date} day={day} onOpenDetail={setSelectedDay} />)}</div>
</>
)}
</section>
@@ -134,6 +366,8 @@ export default function StudioCalendar() {
</section>
</aside>
</div>
<CalendarDayModal day={selectedDay} busyKey={busyKey} endpoints={props.endpoints} onAction={runAction} onClose={() => setSelectedDay(null)} nowMs={nowMs} />
</div>
</StudioLayout>
)

View File

@@ -7,6 +7,8 @@ import NovaCardGradientPicker from '../../components/nova-cards/NovaCardGradient
import NovaCardFontPicker from '../../components/nova-cards/NovaCardFontPicker'
import NovaCardAutosaveIndicator from '../../components/nova-cards/NovaCardAutosaveIndicator'
import NovaCardPresetPicker from '../../components/nova-cards/NovaCardPresetPicker'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
const defaultMobileSteps = [
{ key: 'format', label: 'Format', description: 'Choose the canvas shape and basic direction.' },
@@ -948,10 +950,9 @@ export default function StudioCardEditor() {
const currentProjectSummary = summarizeProjectSnapshot(card.project_json || {})
const editorTabs = [
{ key: 'background', label: 'Background' },
{ key: 'content', label: 'Content' },
{ key: 'typography', label: 'Typography' },
{ key: 'layout', label: 'Layout' },
{ key: 'background', label: 'Canvas' },
{ key: 'content', label: 'Text' },
{ key: 'style', label: 'Style' },
{ key: 'publish', label: 'Publish' },
]
@@ -1012,9 +1013,20 @@ export default function StudioCardEditor() {
{/* Tab panels */}
<div className="mt-2 rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
{/* BACKGROUND TAB */}
{/* CANVAS TAB */}
{activeTab === 'background' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Canvas format</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.formats || []).map((format) => (
<button key={format.key} type="button" onClick={() => updateCard({ format: format.key })} className={pillClasses((card.format || 'square') === format.key)}>
{format.label}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Template</div>
<NovaCardTemplatePicker templates={editorOptions.templates || []} selectedId={card.template_id} onSelect={handleTemplateSelect} />
@@ -1076,12 +1088,10 @@ export default function StudioCardEditor() {
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Overlay &amp; depth</div>
<div className="space-y-4">
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-2 block">Overlay style</span>
<select value={card.project_json?.background?.overlay_style || 'dark-soft'} onChange={(event) => updateCard({}, { background: { overlay_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{overlayOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={card.project_json?.background?.overlay_style || 'dark-soft'} onChange={(val) => updateCard({}, { background: { overlay_style: val } })} options={(overlayOptions || []).map((o) => ({ value: o.value, label: o.label }))} />
</div>
<label className="block text-sm text-slate-300">
<div className="mb-2 flex justify-between">
<span>Overlay opacity</span>
@@ -1098,12 +1108,10 @@ export default function StudioCardEditor() {
</div>
<input type="range" min="0" max="32" step="4" value={card.project_json?.background?.blur_level || 0} onChange={(event) => updateCard({}, { background: { blur_level: Number(event.target.value) } })} className="w-full" />
</label>
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-2 block">Focal position</span>
<select value={card.project_json?.background?.focal_position || 'center'} onChange={(event) => updateCard({}, { background: { focal_position: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.focal_positions || []).map((position) => <option key={position.key} value={position.key}>{position.label}</option>)}
</select>
</label>
<NovaSelect value={card.project_json?.background?.focal_position || 'center'} onChange={(val) => updateCard({}, { background: { focal_position: val } })} options={(editorOptions.focal_positions || []).map((p) => ({ value: p.key, label: p.label }))} />
</div>
</>
)}
</div>
@@ -1146,9 +1154,15 @@ export default function StudioCardEditor() {
</div>
)}
{/* CONTENT TAB */}
{/* TEXT TAB */}
{activeTab === 'content' && (
<div className="space-y-4">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Font family</div>
<NovaCardFontPicker fonts={editorOptions.font_presets || []} selectedKey={card.project_json?.typography?.font_preset} onSelect={handleFontSelect} />
</div>
<div className="border-t border-white/10 pt-4">
<label className="block text-sm text-slate-300">
<span className="mb-2 block">Title</span>
<input value={card.title || ''} onChange={(event) => updateTextField('title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
@@ -1183,19 +1197,9 @@ export default function StudioCardEditor() {
<button type="button" onClick={() => moveTextBlock(index, -1)} disabled={index === 0} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30"></button>
<button type="button" onClick={() => moveTextBlock(index, 1)} disabled={index === textBlocks.length - 1} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30"></button>
</div>
<select value={block.type || 'body'} onChange={(event) => updateTextBlock(index, { type: event.target.value })} className="rounded-xl border border-white/10 bg-[#0d1726] px-2 py-2 text-xs text-white outline-none">
<option value="title">Title</option>
<option value="quote">Quote</option>
<option value="author">Author</option>
<option value="source">Source</option>
<option value="body">Body</option>
<option value="caption">Caption</option>
</select>
<NovaSelect value={block.type || 'body'} onChange={(val) => updateTextBlock(index, { type: val })} searchable={false} options={[{ value: 'title', label: 'Title' }, { value: 'quote', label: 'Quote' }, { value: 'author', label: 'Author' }, { value: 'source', label: 'Source' }, { value: 'body', label: 'Body' }, { value: 'caption', label: 'Caption' }]} />
<input value={block.text || ''} onChange={(event) => updateTextBlock(index, { text: event.target.value, enabled: block.type === 'title' || block.type === 'quote' ? true : Boolean(event.target.value.trim()) })} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-sm text-white outline-none" />
<label className="flex items-center gap-1 text-xs text-slate-400">
<input type="checkbox" checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
On
</label>
<Checkbox checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} label="On" />
<button type="button" onClick={() => removeTextBlock(index)} disabled={block.type === 'title' || block.type === 'quote'} className="text-rose-300 transition hover:text-rose-200 disabled:opacity-30">×</button>
</div>
</div>
@@ -1203,17 +1207,12 @@ export default function StudioCardEditor() {
</div>
</div>
)}
</div>
</div>
)}
{/* TYPOGRAPHY TAB */}
{activeTab === 'typography' && (
{/* STYLE TAB */}
{activeTab === 'style' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Font family</div>
<NovaCardFontPicker fonts={editorOptions.font_presets || []} selectedKey={card.project_json?.typography?.font_preset} onSelect={handleFontSelect} />
</div>
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote size</div>
<div className="flex items-center gap-3">
@@ -1318,39 +1317,20 @@ export default function StudioCardEditor() {
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote mark &amp; panel</div>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Quote mark style</span>
<select value={card.project_json?.typography?.quote_mark_preset || 'none'} onChange={(event) => updateCard({}, { typography: { quote_mark_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.quote_mark_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<label className="block text-sm text-slate-300">
<NovaSelect value={card.project_json?.typography?.quote_mark_preset || 'none'} onChange={(val) => updateCard({}, { typography: { quote_mark_preset: val } })} options={(editorOptions.quote_mark_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
</div>
<div className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Text panel style</span>
<select value={card.project_json?.typography?.text_panel_style || 'none'} onChange={(event) => updateCard({}, { typography: { text_panel_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.text_panel_styles || []).map((style) => <option key={style.key} value={style.key}>{style.label}</option>)}
</select>
</label>
<NovaSelect value={card.project_json?.typography?.text_panel_style || 'none'} onChange={(val) => updateCard({}, { typography: { text_panel_style: val } })} options={(editorOptions.text_panel_styles || []).map((s) => ({ value: s.key, label: s.label }))} />
</div>
</div>
</div>
</>
)}
</div>
)}
{/* LAYOUT TAB */}
{activeTab === 'layout' && (
<div className="space-y-6">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Format</div>
<div className="flex flex-wrap gap-2">
{(editorOptions.formats || []).map((format) => (
<button key={format.key} type="button" onClick={() => updateCard({ format: format.key })} className={pillClasses((card.format || 'square') === format.key)}>
{format.label}
</button>
))}
</div>
</div>
<div className="border-t border-white/10 pt-2">
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Layout preset</div>
<div className="flex flex-wrap gap-2">
@@ -1412,25 +1392,19 @@ export default function StudioCardEditor() {
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Frame &amp; effects</div>
<div className="space-y-3">
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-1.5 block">Frame</span>
<select value={card.project_json?.frame?.preset || 'none'} onChange={(event) => updateCard({}, { frame: { preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.frame_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<NovaSelect value={card.project_json?.frame?.preset || 'none'} onChange={(val) => updateCard({}, { frame: { preset: val } })} options={(editorOptions.frame_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Color grade</span>
<select value={card.project_json?.effects?.color_grade || 'none'} onChange={(event) => updateCard({}, { effects: { color_grade: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.color_grade_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<label className="block text-sm text-slate-300">
<NovaSelect value={card.project_json?.effects?.color_grade || 'none'} onChange={(val) => updateCard({}, { effects: { color_grade: val } })} options={(editorOptions.color_grade_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
</div>
<div className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Effect</span>
<select value={card.project_json?.effects?.effect_preset || 'none'} onChange={(event) => updateCard({}, { effects: { effect_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
{(editorOptions.effect_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
</select>
</label>
<NovaSelect value={card.project_json?.effects?.effect_preset || 'none'} onChange={(val) => updateCard({}, { effects: { effect_preset: val } })} options={(editorOptions.effect_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
</div>
</div>
</div>
</div>
@@ -1489,19 +1463,17 @@ export default function StudioCardEditor() {
)}
</div>
)}
</div>
</div>
)}
{/* PUBLISH TAB */}
{activeTab === 'publish' && (
<div className="space-y-5">
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-2 block">Category</span>
<select value={card.category_id || ''} onChange={(event) => updateCard({ category_id: event.target.value ? Number(event.target.value) : null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">Select category</option>
{(editorOptions.categories || []).map((cat) => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
</select>
</label>
<NovaSelect value={String(card.category_id || '')} onChange={(val) => updateCard({ category_id: val ? Number(val) : null })} placeholder="Select category" options={(editorOptions.categories || []).map((c) => ({ value: String(c.id), label: c.name }))} />
</div>
<div>
<div className="mb-2 text-sm text-slate-300">Visibility</div>
@@ -1528,10 +1500,10 @@ export default function StudioCardEditor() {
{ key: 'allow_export', label: 'Allow export' },
{ key: 'allow_background_reuse', label: 'Allow background reuse' },
].map(({ key, label }) => (
<label key={key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<div key={key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>{label}</span>
<input type="checkbox" checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
</label>
<Checkbox checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} />
</div>
))}
</div>
@@ -1541,22 +1513,16 @@ export default function StudioCardEditor() {
</label>
{advancedMode && (
<label className="block text-sm text-slate-300">
<div className="block text-sm text-slate-300">
<span className="mb-2 block">Style family</span>
<select value={card.style_family || ''} onChange={(event) => updateCard({ style_family: event.target.value || null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">None</option>
{(editorOptions.style_families || []).map((sf) => <option key={sf.key} value={sf.key}>{sf.label}</option>)}
</select>
</label>
<NovaSelect value={card.style_family || ''} onChange={(val) => updateCard({ style_family: val || null })} placeholder="None" options={(editorOptions.style_families || []).map((sf) => ({ value: sf.key, label: sf.label }))} />
</div>
)}
<div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Save to collection</div>
<div className="flex gap-2">
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
<option value="">Default saved cards</option>
{collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}
</select>
<NovaSelect value={String(selectedCollectionId || '')} onChange={(val) => setSelectedCollectionId(val)} placeholder="Default saved cards" options={collections.map((c) => ({ value: String(c.id), label: c.name }))} />
<button type="button" onClick={saveToCollection} disabled={!cardId || busy} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">Save</button>
</div>
<button type="button" onClick={createCollection} className="mt-2 text-sm text-slate-400 transition hover:text-white">+ Create collection</button>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const reportReasons = [
@@ -197,20 +198,15 @@ export default function StudioComments() {
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
/>
</label>
<label className="space-y-2 text-sm text-slate-300">
<div 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
<NovaSelect
value={filters.module || 'all'}
onChange={(event) => updateFilters({ module: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
>
{moduleOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(value) => updateFilters({ module: value })}
options={moduleOptions.map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
</div>
</section>
@@ -352,20 +348,15 @@ export default function StudioComments() {
{reportFor === comment.id && (
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</span>
<select
<NovaSelect
value={reportReason}
onChange={(event) => setReportReason(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white"
>
{reportReasons.map((reason) => (
<option key={reason.value} value={reason.value} className="bg-slate-900">
{reason.label}
</option>
))}
</select>
</label>
onChange={(value) => setReportReason(value)}
options={reportReasons.map((reason) => ({ value: reason.value, label: reason.label }))}
searchable={false}
/>
</div>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Details</span>

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
function SummaryCard({ label, value, icon }) {
return (
@@ -49,18 +50,14 @@ export default function StudioFollowers() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<input value={filters.q || ''} onChange={(event) => updateQuery({ q: event.target.value, page: 1 })} placeholder="Search followers" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<div 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 || 'recent'} onChange={(event) => updateQuery({ sort: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{(listing.sort_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">
<NovaSelect value={filters.sort || 'recent'} onChange={(val) => updateQuery({ sort: val, page: 1 })} options={listing.sort_options || []} searchable={false} />
</div>
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Relationship</span>
<select value={filters.relationship || 'all'} onChange={(event) => updateQuery({ relationship: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{(listing.relationship_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<NovaSelect value={filters.relationship || 'all'} onChange={(val) => updateQuery({ relationship: val, page: 1 })} options={listing.relationship_options || []} searchable={false} />
</div>
</div>
<div className="mt-6 space-y-3">

View File

@@ -1,6 +1,8 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupAssets() {
const { props } = usePage()
@@ -46,18 +48,15 @@ export default function StudioGroupAssets() {
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<div className="grid gap-4 lg:grid-cols-6">
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Asset title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" />
<select value={form.data.category} onChange={(event) => form.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={form.data.category} onChange={(val) => form.setData('category', val)} options={props.categoryOptions || []} searchable={false} />
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<input type="file" onChange={(event) => form.setData('file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="What is this asset for?" rows={3} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-4 grid gap-4 md:grid-cols-2">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured asset</label>
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Featured asset" /></div>
</div>
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Upload asset</button>
</form>
@@ -73,14 +72,8 @@ export default function StudioGroupAssets() {
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<input value={filters.data.q} onChange={(event) => filters.setData('q', event.target.value)} placeholder="Search title, description, or filename" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={filters.data.category} onChange={(event) => filters.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All categories</option>
{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select value={filters.data.bucket} onChange={(event) => filters.setData('bucket', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="all">All visibility levels</option>
{(props.listing?.bucket_options || []).filter((option) => option.value !== 'all').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<NovaSelect value={filters.data.category} onChange={(val) => filters.setData('category', val)} options={[{ value: 'all', label: 'All categories' }, ...(props.categoryOptions || [])]} searchable={false} />
<NovaSelect value={filters.data.bucket} onChange={(val) => filters.setData('bucket', val)} options={[{ value: 'all', label: 'All visibility levels' }, ...(props.listing?.bucket_options || []).filter((option) => option.value !== 'all')]} searchable={false} />
</div>
</form>

View File

@@ -1,10 +1,12 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupChallengeEditor() {
const { props } = usePage()
const challenge = props.challenge || null
const outcomeArtworkOptions = Array.isArray(challenge?.artworks) ? challenge.artworks : []
const form = useForm({
title: challenge?.title || '',
summary: challenge?.summary || '',
@@ -20,6 +22,14 @@ export default function StudioGroupChallengeEditor() {
linked_collection_id: challenge?.linked_collection?.id || '',
linked_project_id: challenge?.linked_project?.id || '',
featured_artwork_id: challenge?.featured_artwork?.id || '',
outcomes: Array.isArray(challenge?.outcomes) ? challenge.outcomes.map((outcome) => ({
artwork_id: outcome.artwork_id || '',
outcome_type: outcome.outcome_type || props.outcomeTypeOptions?.[0]?.value || 'winner',
position: outcome.position || '',
sort_order: outcome.sort_order ?? 0,
title_override: outcome.title_override || '',
note: outcome.note || '',
})) : [],
cover_file: null,
})
const attachForm = useForm({ artwork_id: '' })
@@ -34,6 +44,30 @@ export default function StudioGroupChallengeEditor() {
form.post(props.storeUrl, options)
}
const updateOutcome = (index, key, value) => {
const next = [...(form.data.outcomes || [])]
next[index] = { ...next[index], [key]: value }
form.setData('outcomes', next)
}
const addOutcome = () => {
form.setData('outcomes', [
...(form.data.outcomes || []),
{
artwork_id: outcomeArtworkOptions[0]?.id || '',
outcome_type: props.outcomeTypeOptions?.[0]?.value || 'winner',
position: '',
sort_order: form.data.outcomes?.length || 0,
title_override: '',
note: '',
},
])
}
const removeOutcome = (index) => {
form.setData('outcomes', (form.data.outcomes || []).filter((_, currentIndex) => currentIndex !== index))
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
@@ -43,9 +77,9 @@ export default function StudioGroupChallengeEditor() {
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Challenge description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.participation_scope} onChange={(event) => form.setData('participation_scope', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.participationScopeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<NovaSelect value={form.data.participation_scope} onChange={(val) => form.setData('participation_scope', val)} options={props.participationScopeOptions || []} searchable={false} />
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
@@ -53,25 +87,49 @@ export default function StudioGroupChallengeEditor() {
</div>
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={form.data.judging_mode} onChange={(event) => form.setData('judging_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No judging mode</option>
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<NovaSelect value={form.data.judging_mode || ''} onChange={(val) => form.setData('judging_mode', val)} placeholder="No judging mode" options={(props.judgingModeOptions || []).map((o) => ({ value: o.value, label: o.label }))} searchable={false} />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-6 text-slate-300">
Featured result rendering now comes from structured outcomes. Attach entries first, then assign winners and finalists below.
</div>
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
{props.updateUrl ? (
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">Challenge outcomes</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">Choose from attached challenge entries only. These outcomes now drive public winners, finalists, and linked-world reward automation.</p>
</div>
<button type="button" onClick={addOutcome} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add outcome</button>
</div>
{outcomeArtworkOptions.length === 0 ? (
<div className="mt-4 rounded-2xl border border-dashed border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-400">Attach challenge entries first. Outcomes are intentionally limited to artworks already entered into this challenge.</div>
) : null}
<div className="mt-4 space-y-4">
{(form.data.outcomes || []).map((outcome, index) => (
<div key={`${outcome.artwork_id || 'new'}-${index}`} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div className="grid gap-3 md:grid-cols-3">
<NovaSelect value={String(outcome.artwork_id || '')} onChange={(val) => updateOutcome(index, 'artwork_id', val)} placeholder="Choose challenge entry" options={outcomeArtworkOptions.map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={outcome.outcome_type} onChange={(val) => updateOutcome(index, 'outcome_type', val)} options={props.outcomeTypeOptions || []} searchable={false} />
<button type="button" onClick={() => removeOutcome(index)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-slate-300">Remove</button>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<input value={outcome.position} onChange={(event) => updateOutcome(index, 'position', event.target.value)} placeholder="Position" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={outcome.sort_order} onChange={(event) => updateOutcome(index, 'sort_order', event.target.value)} placeholder="Sort order" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={outcome.title_override} onChange={(event) => updateOutcome(index, 'title_override', event.target.value)} placeholder="Optional label override" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={outcome.note} onChange={(event) => updateOutcome(index, 'note', event.target.value)} placeholder="Optional editorial note" rows={3} className="mt-3 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
))}
</div>
</div>
) : null}
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save challenge</button>
</form>
@@ -81,10 +139,7 @@ export default function StudioGroupChallengeEditor() {
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={attachForm.data.artwork_id} onChange={(event) => attachForm.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(attachForm.data.artwork_id || '')} onChange={(val) => attachForm.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach</button>
</form>
) : null}

View File

@@ -2,6 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
import NovaSelect from '../../components/ui/NovaSelect'
function slugifyGroupValue(value) {
return String(value || '')
@@ -199,18 +200,14 @@ export default function StudioGroupCreate() {
</label>
</div>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<div className="grid gap-2 text-sm text-slate-200">
<span>Visibility</span>
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-200">
<NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} options={props.visibilityOptions || []} searchable={false} />
</div>
<div className="grid gap-2 text-sm text-slate-200">
<span>Membership policy</span>
<select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={form.membership_policy} onChange={(val) => setForm((current) => ({ ...current, membership_policy: val }))} options={props.membershipPolicyOptions || []} searchable={false} />
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>

View File

@@ -1,6 +1,8 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupEventEditor() {
const { props } = usePage()
@@ -43,9 +45,9 @@ export default function StudioGroupEventEditor() {
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Event description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.event_type} onChange={(event) => form.setData('event_type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={form.data.event_type} onChange={(val) => form.setData('event_type', val)} options={props.typeOptions || []} searchable={false} />
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
@@ -57,20 +59,11 @@ export default function StudioGroupEventEditor() {
</div>
<input value={form.data.external_url} onChange={(event) => form.setData('external_url', event.target.value)} placeholder="External link" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.linked_challenge_id} onChange={(event) => form.setData('linked_challenge_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked challenge</option>
{(props.challengeOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={String(form.data.linked_challenge_id || '')} onChange={(val) => form.setData('linked_challenge_id', val)} placeholder="No linked challenge" options={(props.challengeOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured event</label>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Featured event" /></div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save event</button>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function formatInviteTimestamp(value) {
if (!value) return null
@@ -45,11 +46,7 @@ export default function StudioGroupInvitations() {
<div className="mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<NovaSelect value={invite.role} onChange={(val) => setInvite((current) => ({ ...current, role: val }))} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={invite.expires_in_days} onChange={(event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value }))} type="number" min="1" max="30" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 })} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Send invite</button>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function overrideMap(member) {
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
@@ -86,11 +87,7 @@ export default function StudioGroupMembers() {
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<NovaSelect value={invite.role} onChange={(val) => setInvite((current) => ({ ...current, role: val }))} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
</div>
@@ -128,11 +125,7 @@ export default function StudioGroupMembers() {
</div>
<div>
{canManageMembers && member.role !== 'owner' ? (
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<NovaSelect value={member.role} onChange={(val) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: val })} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupPostEditor() {
const { props } = usePage()
@@ -28,12 +29,10 @@ export default function StudioGroupPostEditor() {
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={form.data.type} onChange={(val) => form.setData('type', val)} options={Array.isArray(props.typeOptions) ? props.typeOptions : []} searchable={false} />
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function normalizeIds(values) {
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0)
@@ -48,35 +49,21 @@ export default function StudioGroupProjectEditor() {
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Longer project description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
</div>
<select multiple value={form.data.member_user_ids.map(String)} onChange={(event) => form.setData('member_user_ids', normalizeIds(event.target.selectedOptions))} className="min-h-40 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<NovaSelect multi value={form.data.member_user_ids} onChange={(vals) => form.setData('member_user_ids', (vals || []).map(Number).filter(Boolean))} placeholder="Select team members" options={(props.memberOptions || []).map((o) => ({ value: o.id, label: o.name || o.username }))} />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_featured_artwork_id} onChange={(event) => form.setData('linked_featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.pinned_post_id} onChange={(event) => form.setData('pinned_post_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No pinned post</option>
{(props.postOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.linked_featured_artwork_id || '')} onChange={(val) => form.setData('linked_featured_artwork_id', val)} placeholder="No featured artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={String(form.data.pinned_post_id || '')} onChange={(val) => form.setData('pinned_post_id', val)} placeholder="No pinned post" options={(props.postOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
</div>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
@@ -87,7 +74,7 @@ export default function StudioGroupProjectEditor() {
{props.statusUrl ? (
<form onSubmit={(event) => { event.preventDefault(); statusForm.post(props.statusUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Status</h2>
<select value={statusForm.data.status} onChange={(event) => statusForm.setData('status', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={statusForm.data.status} onChange={(val) => statusForm.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update status</button>
</form>
) : null}
@@ -95,10 +82,7 @@ export default function StudioGroupProjectEditor() {
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(artworkAttach.data.artwork_id || '')} onChange={(val) => artworkAttach.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
@@ -106,10 +90,7 @@ export default function StudioGroupProjectEditor() {
{props.attachAssetUrl ? (
<form onSubmit={(event) => { event.preventDefault(); assetAttach.post(props.attachAssetUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach asset</h2>
<select value={assetAttach.data.asset_id} onChange={(event) => assetAttach.setData('asset_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose asset</option>
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(assetAttach.data.asset_id || '')} onChange={(val) => assetAttach.setData('asset_id', val)} placeholder="Choose asset" options={(props.assetOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach asset</button>
</form>
) : null}
@@ -121,15 +102,10 @@ export default function StudioGroupProjectEditor() {
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>

View File

@@ -1,6 +1,8 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function toggleItem(list, value) {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
@@ -33,10 +35,9 @@ export default function StudioGroupRecruitment() {
<h2 className="text-xl font-semibold text-white">Recruitment profile</h2>
<p className="mt-1 text-sm text-slate-400">Describe what the group is looking for and how applicants should reach you.</p>
</div>
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
<input type="checkbox" checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} />
Recruiting now
</label>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
<Checkbox checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} label="Recruiting now" />
</div>
</div>
<div className="mt-5 grid gap-4">
@@ -74,18 +75,14 @@ export default function StudioGroupRecruitment() {
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Application settings</h2>
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contact mode</span>
<select value={form.data.contact_mode} onChange={(event) => form.setData('contact_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.contactModes) ? props.contactModes : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<NovaSelect value={form.data.contact_mode} onChange={(val) => form.setData('contact_mode', val)} options={Array.isArray(props.contactModes) ? props.contactModes : []} searchable={false} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</span>
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []} searchable={false} />
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Public preview</p>
<p className="mt-2">{form.data.headline || 'No headline yet.'}</p>

View File

@@ -1,6 +1,8 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function toDateTimeInput(value) {
return value ? String(value).slice(0, 16) : ''
@@ -50,35 +52,22 @@ export default function StudioGroupReleaseEditor() {
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Release overview" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.release_notes} onChange={(event) => form.setData('release_notes', event.target.value)} placeholder="Release notes" rows={7} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-3">
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<select value={form.data.current_stage} onChange={(event) => form.setData('current_stage', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<NovaSelect value={form.data.current_stage} onChange={(val) => form.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
</div>
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No release lead</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked project</option>
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No release lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No linked collection</option>
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<NovaSelect value={String(form.data.featured_artwork_id || '')} onChange={(val) => form.setData('featured_artwork_id', val)} placeholder="No featured artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
</div>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature this release on the public group page" />
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature this release on the public group page
</label>
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<div className="mt-6 flex flex-wrap gap-3">
@@ -91,7 +80,7 @@ export default function StudioGroupReleaseEditor() {
{props.stageUrl ? (
<form onSubmit={(event) => { event.preventDefault(); stageForm.post(props.stageUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Stage</h2>
<select value={stageForm.data.current_stage} onChange={(event) => stageForm.setData('current_stage', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
<NovaSelect value={stageForm.data.current_stage} onChange={(val) => stageForm.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
<div className="mt-4 flex flex-wrap gap-2">
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update stage</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button> : null}
@@ -102,10 +91,7 @@ export default function StudioGroupReleaseEditor() {
{props.attachArtworkUrl ? (
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<NovaSelect value={String(artworkAttach.data.artwork_id || '')} onChange={(val) => artworkAttach.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
</form>
) : null}
@@ -114,10 +100,7 @@ export default function StudioGroupReleaseEditor() {
<form onSubmit={(event) => { event.preventDefault(); contributorForm.post(props.attachContributorUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Contributor credit</h2>
<div className="mt-4 space-y-3">
<select value={contributorForm.data.user_id} onChange={(event) => contributorForm.setData('user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Choose contributor</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<NovaSelect value={String(contributorForm.data.user_id || '')} onChange={(val) => contributorForm.setData('user_id', val)} placeholder="Choose contributor" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<input value={contributorForm.data.role_label} onChange={(event) => contributorForm.setData('role_label', event.target.value)} placeholder="Role label" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach contributor</button>
</div>
@@ -132,15 +115,10 @@ export default function StudioGroupReleaseEditor() {
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupReleases() {
const { props } = usePage()
@@ -14,9 +15,7 @@ export default function StudioGroupReleases() {
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-slate-400">Track the release pipeline from draft through public launch, with milestones and contributor credits.</div>
<div className="flex items-center gap-3">
<select value={currentBucket} onChange={(event) => router.get(window.location.pathname, { bucket: event.target.value }, { preserveScroll: true, preserveState: true })} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white outline-none">
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<NovaSelect value={currentBucket} onChange={(val) => router.get(window.location.pathname, { bucket: val }, { preserveScroll: true, preserveState: true })} options={bucketOptions} searchable={false} />
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create release</a> : null}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim()
@@ -145,13 +146,10 @@ export default function StudioGroupSettings() {
</div>
</div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
<label className="grid gap-2 text-sm text-slate-200">
<div className="grid gap-2 text-sm text-slate-200">
<span>Featured artwork</span>
<select value={form.featured_artwork_id} onChange={(event) => setForm((current) => ({ ...current, featured_artwork_id: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">Use latest published artwork</option>
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
</select>
</label>
<NovaSelect value={String(form.featured_artwork_id || '')} onChange={(val) => setForm((current) => ({ ...current, featured_artwork_id: val }))} placeholder="Use latest published artwork" options={featuredArtworkOptions.map((item) => ({ value: String(item.id), label: item.title }))} />
</div>
{selectedFeaturedArtwork ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
{selectedFeaturedArtwork.thumb ? <img src={selectedFeaturedArtwork.thumb} alt={selectedFeaturedArtwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
@@ -164,8 +162,8 @@ export default function StudioGroupSettings() {
<p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
)}
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<div className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} options={props.visibilityOptions || []} searchable={false} /></div>
<div className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><NovaSelect value={form.membership_policy} onChange={(val) => setForm((current) => ({ ...current, membership_policy: val }))} options={props.membershipPolicyOptions || []} searchable={false} /></div>
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -78,10 +79,10 @@ export default function StudioInbox() {
<h2 className="text-lg font-semibold text-white">Filters</h2>
<div className="mt-4 space-y-3">
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Actor, title, or module" /></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.type_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.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.module_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.18em] text-slate-500">Read state</span><select value={filters.read_state || 'all'} onChange={(event) => updateFilters({ read_state: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.read_state_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.18em] text-slate-500">Priority</span><select value={filters.priority || 'all'} onChange={(event) => updateFilters({ priority: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.priority_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span><NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={inbox.type_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={inbox.module_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read state</span><NovaSelect value={filters.read_state || 'all'} onChange={(val) => updateFilters({ read_state: val })} options={inbox.read_state_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Priority</span><NovaSelect value={filters.priority || 'all'} onChange={(val) => updateFilters({ priority: val })} options={inbox.priority_options || []} searchable={false} /></div>
</div>
</section>

View File

@@ -1,6 +1,60 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
import NovaSelect from '../../components/ui/NovaSelect'
import { Checkbox } from '../../components/ui'
// ── Minimal toast system ────────────────────────────────────────────────────
let _toastId = 0
function useToast() {
const [toasts, setToasts] = useState([])
const push = useCallback((message, type = 'info') => {
const id = ++_toastId
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000)
}, [])
const dismiss = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), [])
return { toasts, push, dismiss }
}
function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null
return (
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2" aria-live="polite">
{toasts.map((t) => (
<div
key={t.id}
className={[
'flex items-start gap-3 rounded-2xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-sm',
t.type === 'success'
? 'border-emerald-400/30 bg-emerald-950/90 text-emerald-100'
: t.type === 'error'
? 'border-rose-400/30 bg-rose-950/90 text-rose-100'
: 'border-white/15 bg-slate-900/90 text-slate-100',
].join(' ')}
>
<span className="mt-0.5 text-base leading-none">
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : ''}
</span>
<span className="flex-1 leading-5">{t.message}</span>
<button
type="button"
onClick={() => onDismiss(t.id)}
className="ml-2 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>×</button>
</div>
))}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) {
@@ -20,7 +74,7 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-1 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
{item.description ? <div className="mt-1 line-clamp-2 text-xs text-slate-400">{item.description}</div> : null}
</div>
</button>
))}
@@ -28,16 +82,178 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
)
}
function FieldError({ message }) {
if (!message) return null
return <p className="text-xs text-rose-300">{message}</p>
}
function normalizeNewTagName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80)
}
function SectionCard({ eyebrow, title, description, actions, children, tone = 'default' }) {
const toneClass = tone === 'feature'
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
: 'bg-white/[0.03]'
return (
<section className={`rounded-[28px] border border-white/10 p-5 ${toneClass}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
<div className="mt-5">{children}</div>
</section>
)
}
function TagPicker({ options, selectedIds, newTagNames, tagQuery, onTagQueryChange, onToggle, onCreateTag, onRemoveNewTag, manageUrl }) {
const selectedTags = useMemo(() => options.filter((tag) => selectedIds.includes(tag.id)), [options, selectedIds])
const normalizedQuery = useMemo(() => normalizeNewTagName(tagQuery), [tagQuery])
const matchingExistingTag = useMemo(() => {
if (!normalizedQuery) return null
const lowerQuery = normalizedQuery.toLowerCase()
return options.find((tag) => String(tag.name || '').toLowerCase() === lowerQuery) || null
}, [options, normalizedQuery])
const queryMatchesPending = useMemo(() => {
if (!normalizedQuery) return false
const lowerQuery = normalizedQuery.toLowerCase()
return newTagNames.some((tagName) => tagName.toLowerCase() === lowerQuery)
}, [newTagNames, normalizedQuery])
const availableTags = useMemo(() => {
const query = String(tagQuery || '').trim().toLowerCase()
return options
.filter((tag) => !selectedIds.includes(tag.id))
.filter((tag) => (query === '' ? true : String(tag.name || '').toLowerCase().includes(query)))
.slice(0, 12)
}, [options, selectedIds, tagQuery])
return (
<div className="grid gap-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
<div className="mt-1 text-sm text-slate-400">Attach article topics without forcing the editor to scan a wall of checkboxes.</div>
</div>
{manageUrl ? <a href={manageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">Manage tags</a> : null}
</div>
<div className="mt-4 flex min-h-[3.5rem] flex-wrap gap-2">
{selectedTags.length > 0 ? selectedTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => onToggle(tag.id)}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-sm text-sky-50 transition hover:bg-sky-400/15"
>
<span>{tag.name}</span>
<span className="text-xs text-sky-100/70">Remove</span>
</button>
)) : null}
{newTagNames.map((tagName) => (
<button
key={tagName}
type="button"
onClick={() => onRemoveNewTag(tagName)}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm text-emerald-50 transition hover:bg-emerald-400/15"
>
<span>{tagName}</span>
<span className="rounded-full border border-emerald-200/30 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100/80">New</span>
</button>
))}
{selectedTags.length === 0 && newTagNames.length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No tags selected yet.</div> : null}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Find tags</span>
<input
value={tagQuery}
onChange={(event) => onTagQueryChange(event.target.value)}
onKeyDown={(event) => {
if (!['Enter', ','].includes(event.key)) {
return
}
event.preventDefault()
const nextQuery = normalizeNewTagName(event.currentTarget.value)
if (!nextQuery) {
return
}
if (matchingExistingTag && !selectedIds.includes(matchingExistingTag.id)) {
onToggle(matchingExistingTag.id)
onTagQueryChange('')
return
}
if (!queryMatchesPending) {
onCreateTag(nextQuery)
}
onTagQueryChange('')
}}
placeholder="Search existing tags or type a new one"
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
/>
</label>
{normalizedQuery && !matchingExistingTag && !queryMatchesPending ? (
<button
type="button"
onClick={() => {
onCreateTag(normalizedQuery)
onTagQueryChange('')
}}
className="mt-3 inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-50"
>
<span>Create tag</span>
<span className="rounded-full border border-emerald-200/30 px-2 py-0.5 text-xs text-emerald-100">{normalizedQuery}</span>
</button>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{availableTags.length > 0 ? availableTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => onToggle(tag.id)}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white transition hover:border-white/20 hover:bg-white/[0.08]"
>
+ {tag.name}
</button>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No additional tags match the current search.</div>}
</div>
<div className="mt-3 text-xs leading-5 text-slate-500">
Press Enter or comma to queue a new tag. Pending tags are written into the news tag list when the article is saved.
</div>
</div>
</div>
)
}
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2">
@@ -67,28 +283,70 @@ function RelationCard({ relation, index, onChange, onRemove, onSearch, results,
)
}
export default function StudioNewsEditor() {
const { props } = usePage()
const article = props.article || {}
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
const form = useForm({
function selectOptionsFromValues(options, emptyLabel = null) {
const base = Array.isArray(options)
? options.map((option) => ({
value: option.value ?? option.id,
label: option.label ?? option.name,
}))
: []
return emptyLabel ? [{ value: '', label: emptyLabel }, ...base] : base
}
function buildSubmitPayload(data) {
return {
title: String(data.title || '').trim(),
slug: String(data.slug || '').trim(),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
cover_image: String(data.cover_image || '').trim(),
type: String(data.type || ''),
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
author_id: data.author_id === '' || data.author_id == null ? null : Number(data.author_id),
editorial_status: String(data.editorial_status || ''),
published_at: data.published_at ? String(data.published_at) : null,
is_featured: Boolean(data.is_featured),
is_pinned: Boolean(data.is_pinned),
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
meta_title: String(data.meta_title || ''),
meta_description: String(data.meta_description || ''),
meta_keywords: String(data.meta_keywords || ''),
canonical_url: String(data.canonical_url || '').trim(),
og_title: String(data.og_title || ''),
og_description: String(data.og_description || ''),
og_image: String(data.og_image || '').trim(),
relations: Array.isArray(data.relations)
? data.relations.map((relation) => ({
entity_type: String(relation.entity_type || '').trim(),
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
context_label: String(relation.context_label || '').trim(),
}))
: [],
}
}
function buildInitialFormData(article, defaultAuthor, typeOptions) {
return {
title: article.title || '',
slug: article.slug || '',
excerpt: article.excerpt || '',
content: article.content || '',
cover_image: article.cover_image || '',
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
category_id: article.category_id || '',
author_id: article.author_id || props.defaultAuthor?.id || '',
author_id: article.author_id || defaultAuthor?.id || '',
editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
new_tag_names: [],
meta_title: article.meta_title || '',
meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '',
@@ -103,18 +361,50 @@ export default function StudioNewsEditor() {
preview: relation.preview || null,
query: relation.preview?.title || '',
})) : [],
})
}
}
const submit = (event) => {
event.preventDefault()
export default function StudioNewsEditor() {
const { props } = usePage()
const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
const article = props.article || {}
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions), [article, props.defaultAuthor, props.typeOptions])
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
const [relationResults, setRelationResults] = useState({})
const [tagQuery, setTagQuery] = useState('')
const [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
if (props.updateUrl) {
form.patch(props.updateUrl)
const form = useForm(initialFormData)
useEffect(() => {
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
return
}
form.post(props.storeUrl)
}
lastSyncedArticleKeyRef.current = articleSyncKey
form.setData(initialFormData)
form.clearErrors()
setSelectedAuthor(article.author || props.defaultAuthor || null)
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
setRelationResults({})
setTagQuery('')
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
setStagedCoverPath('')
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
const excerptLength = String(form.data.excerpt || '').trim().length
const bodyWordCount = useMemo(() => {
const plain = stripHtml(form.data.content)
return plain === '' ? 0 : plain.split(/\s+/).length
}, [form.data.content])
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'No category'), [props.categoryOptions])
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
@@ -174,95 +464,225 @@ export default function StudioNewsEditor() {
setRelationResults((current) => ({ ...current, [index]: items }))
}
const toggleTag = (tagId) => {
const numericId = Number(tagId)
const next = form.data.tag_ids.includes(numericId)
? form.data.tag_ids.filter((currentId) => currentId !== numericId)
: [...form.data.tag_ids, numericId]
form.setData('tag_ids', next)
if (!form.data.tag_ids.includes(numericId)) {
const matchedTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => tag.id === numericId)
if (matchedTag) {
const lowerName = String(matchedTag.name || '').toLowerCase()
form.setData('new_tag_names', form.data.new_tag_names.filter((tagName) => tagName.toLowerCase() !== lowerName))
}
}
}
const addNewTagName = (rawValue) => {
const nextTagName = normalizeNewTagName(rawValue)
if (!nextTagName) return
const lowerName = nextTagName.toLowerCase()
const matchingExistingTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => String(tag.name || '').toLowerCase() === lowerName)
if (matchingExistingTag) {
if (!form.data.tag_ids.includes(matchingExistingTag.id)) {
form.setData('tag_ids', [...form.data.tag_ids, matchingExistingTag.id])
}
return
}
if (form.data.new_tag_names.some((tagName) => tagName.toLowerCase() === lowerName)) {
return
}
form.setData('new_tag_names', [...form.data.new_tag_names, nextTagName])
}
const removeNewTagName = (tagName) => {
form.setData('new_tag_names', form.data.new_tag_names.filter((currentTagName) => currentTagName !== tagName))
}
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
if (stagedCoverPath && nextValue !== stagedCoverPath) {
setStagedCoverPath('')
}
if (!nextValue) {
setCoverPreviewUrl('')
return
}
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
setCoverPreviewUrl(nextValue)
return
}
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
}
const submit = (event) => {
event.preventDefault()
const options = {
preserveScroll: true,
preserveState: false,
onSuccess: () => {
setStagedCoverPath('')
pushToast('Article saved successfully.', 'success')
},
onError: (errors) => {
const errorMessages = Object.values(errors)
const first = errorMessages[0] || 'The article could not be saved.'
const extra = errorMessages.length > 1 ? ` (${errorMessages.length - 1} more field${errorMessages.length > 2 ? 's' : ''})` : ''
pushToast(first + extra, 'error')
},
}
form.transform((data) => buildSubmitPayload(data))
if (props.updateUrl) {
form.patch(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
const deleteArticle = () => {
if (!props.destroyUrl) return
if (!window.confirm('Move this article to trash? This uses soft delete so the record stays in the database.')) return
router.delete(props.destroyUrl, {
preserveScroll: true,
})
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<ToastStack toasts={toasts} onDismiss={dismissToast} />
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,0.85fr)]">
<div className="space-y-6">
<SectionCard
eyebrow="Story workspace"
title={article.id ? 'Shape the full newsroom story before it goes live.' : 'Create a newsroom story that reads like an editorial feature, not a raw database form.'}
description="The cover, excerpt, body, tags, and related entities are all tuned for homepage spotlight, archive browsing, and article detail pages."
tone="feature"
>
<div className="grid gap-5">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Headline that can carry the article alone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Aim for a clear editorial headline that still makes sense in cards, notifications, and social previews.</span>
<FieldError message={form.errors.title} />
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Leave blank to generate from the title, or set a durable URL manually when the story needs a stable public address.</span>
<FieldError message={form.errors.slug} />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover image URL or path</span>
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Excerpt</span>
<span className="text-slate-500">{excerptLength}/800</span>
</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={6} placeholder="Write the concise summary used in listing cards, metadata, and archive previews." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Lead with the update, why it matters, and the audience hook. Two to four punchy sentences usually land better than one dense paragraph.</span>
<FieldError message={form.errors.excerpt} />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="grid gap-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_image}
previewUrl={coverPreviewUrl}
emptyLabel="Drop a cover image"
helperText="Upload the hero image directly to object storage. A wide landscape image works best for cards, preview surfaces, and social sharing."
uploadUrl={props.coverUploadUrl}
deleteUrl={props.coverDeleteUrl}
onChange={({ path, url }) => {
setStagedCoverPath(path || '')
form.setData('cover_image', path || '')
setCoverPreviewUrl(url || '')
}}
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
/>
<FieldError message={form.errors.cover_image} />
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</span>
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
</label>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Related entities</h2>
<p className="mt-1 text-sm text-slate-400">Attach Groups, artworks, collections, releases, projects, challenges, events, and profiles.</p>
</div>
<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>
</div>
<div className="mt-4 grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<RelationCard
key={`${relation.entity_type}-${index}`}
relation={relation}
index={index}
onChange={updateRelation}
onRemove={removeRelation}
onSearch={runRelationSearch}
results={relationResults[index] || []}
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
/>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No related entities attached yet.</div>}
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported legacy stories, or when you already have the exact asset URL you want to use.</span>
</label>
</div>
</div>
</div>
</section>
</SectionCard>
<section className="space-y-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">Publishing</h2>
<div className="mt-5 grid gap-4">
<SectionCard eyebrow="Full message" title="Body editor" description="Write the main article in a richer editing surface so the content reads like a polished story, not pasted plain text." actions={<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>}>
<div className="grid gap-3 text-sm text-slate-300">
<RichTextEditor
content={form.data.content}
onChange={(nextValue) => form.setData('content', nextValue)}
placeholder="Open with the update, add context, use links, pull quotes, headings, and imagery where the story needs structure."
error={form.errors.content}
minHeight={24}
autofocus={false}
/>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
</div>
</div>
</SectionCard>
<SectionCard eyebrow="Context links" title="Related entities" description="Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page." actions={<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}>
<div className="grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<RelationCard
key={`${relation.entity_type}-${index}`}
relation={relation}
index={index}
onChange={updateRelation}
onRemove={removeRelation}
onSearch={runRelationSearch}
results={relationResults[index] || []}
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
/>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No related entities attached yet.</div>}
</div>
</SectionCard>
</div>
<div className="space-y-6">
<SectionCard eyebrow="Editorial controls" title="Publishing" description="Set ownership, placement, timing, and surface behavior before the article leaves draft.">
<div className="grid gap-4">
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">No category</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</label>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" error={form.errors.type} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => form.setData('category_id', String(nextValue || ''))} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Workflow status</span>
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<FieldError message={form.errors.published_at} />
</label>
</div>
@@ -283,51 +703,39 @@ export default function StudioNewsEditor() {
setAuthorQuery(item.title)
form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
<div className="grid gap-2 sm:grid-cols-2">
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
const checked = form.data.tag_ids.includes(tag.id)
return (
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
return
}
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
}}
/>
<span>{tag.name}</span>
</label>
)
})}
</div>
<FieldError message={form.errors.author_id} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
Feature on newsroom surfaces
</label>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
Pin to the top of the newsroom
</label>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on newsroom surfaces" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
</div>
</div>
</div>
</div>
</SectionCard>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<h2 className="text-xl font-semibold text-white">SEO &amp; social</h2>
<div className="mt-5 grid gap-4">
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
<TagPicker
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
selectedIds={form.data.tag_ids}
newTagNames={form.data.new_tag_names}
tagQuery={tagQuery}
onTagQueryChange={setTagQuery}
onToggle={toggleTag}
onCreateTag={addNewTagName}
onRemoveNewTag={removeNewTagName}
manageUrl={props.tagsUrl}
/>
<div className="mt-3">
<FieldError message={form.errors.tag_ids || form.errors.new_tag_names} />
</div>
</SectionCard>
<SectionCard eyebrow="Metadata" title="SEO and social" description="Keep search and sharing fields aligned with the main editorial package.">
<div className="grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
@@ -359,18 +767,20 @@ export default function StudioNewsEditor() {
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
</SectionCard>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<SectionCard eyebrow="Actions" title="Save and publish" description="Use the primary action for create or update, then promote, archive, or trash the article from the same control rail.">
<div className="grid gap-3">
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save article</button>
{Object.keys(form.errors || {}).length > 0 ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">The article was not saved. Fix the highlighted fields and try again.</div> : null}
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">{form.processing ? 'Saving article…' : 'Save article'}</button>
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Archive article</button> : null}
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive article</button> : null}
{props.destroyUrl ? <button type="button" onClick={deleteArticle} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Move to trash</button> : null}
</div>
</div>
</section>
</SectionCard>
</div>
</form>
</StudioLayout>
)

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function formatDate(value) {
if (!value) return 'Draft'
@@ -36,6 +37,15 @@ export default function StudioNewsIndex() {
const filters = props.listing?.filters || {}
const meta = props.listing?.meta || {}
const deleteItem = (item) => {
if (!item?.delete_url) return
if (!window.confirm(`Move "${item.title}" to trash?`)) return
router.delete(item.delete_url, {
preserveScroll: true,
})
}
const updateFilter = (next) => {
router.get('/studio/news', {
...filters,
@@ -89,45 +99,36 @@ export default function StudioNewsIndex() {
}}
/>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select
<NovaSelect
value={filters.status || ''}
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All statuses</option>
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
onChange={(value) => updateFilter({ status: value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
placeholder="All statuses"
options={(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select
<NovaSelect
value={filters.type || ''}
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All types</option>
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
onChange={(value) => updateFilter({ type: value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
placeholder="All types"
options={(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select
<NovaSelect
value={filters.category_id || ''}
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
>
<option value="">All categories</option>
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
</label>
onChange={(value) => updateFilter({ category_id: value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
placeholder="All categories"
options={(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ({ value: String(option.id), label: option.name }))}
searchable={false}
/>
</div>
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
</div>
</section>
@@ -154,6 +155,7 @@ export default function StudioNewsIndex() {
<div className="mt-5 flex flex-wrap gap-2">
<a href={item.edit_url} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Edit</a>
<a href={item.editorial_status === 'published' ? item.public_url : item.preview_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{item.editorial_status === 'published' ? 'View' : 'Preview'}</a>
<button type="button" onClick={() => deleteItem(item)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Trash</button>
</div>
</div>
</article>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
const shortcutOptions = [
{ value: '/dashboard/profile', label: 'Dashboard profile' },
@@ -172,48 +173,30 @@ export default function StudioPreferences() {
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default content view</span>
<select value={form.default_content_view} onChange={(event) => setForm((current) => ({ ...current, default_content_view: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="grid" className="bg-slate-900">Grid</option>
<option value="list" className="bg-slate-900">List</option>
</select>
</label>
<NovaSelect value={form.default_content_view} onChange={(val) => setForm((current) => ({ ...current, default_content_view: val }))} searchable={false} options={[{ value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Analytics date range</span>
<select value={form.analytics_range_days} onChange={(event) => setForm((current) => ({ ...current, analytics_range_days: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{[7, 14, 30, 60, 90].map((days) => (
<option key={days} value={days} className="bg-slate-900">Last {days} days</option>
))}
</select>
</label>
<NovaSelect value={form.analytics_range_days} onChange={(val) => setForm((current) => ({ ...current, analytics_range_days: Number(val) }))} searchable={false} options={[7, 14, 30, 60, 90].map((days) => ({ value: days, label: `Last ${days} days` }))} />
</div>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<div className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Draft behavior</span>
<select value={form.draft_behavior} onChange={(event) => setForm((current) => ({ ...current, draft_behavior: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="resume-last" className="bg-slate-900">Resume the last draft I edited</option>
<option value="open-drafts" className="bg-slate-900">Open the drafts library first</option>
<option value="focus-published" className="bg-slate-900">Open published content first</option>
</select>
</label>
<NovaSelect value={form.draft_behavior} onChange={(val) => setForm((current) => ({ ...current, draft_behavior: val }))} searchable={false} options={[{ value: 'resume-last', label: 'Resume the last draft I edited' }, { value: 'open-drafts', label: 'Open the drafts library first' }, { value: 'focus-published', label: 'Open published content first' }]} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default landing page</span>
<select value={form.default_landing_page} onChange={(event) => setForm((current) => ({ ...current, default_landing_page: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
{landingOptions.map(([value, label]) => (
<option key={value} value={value} className="bg-slate-900">{label}</option>
))}
</select>
</label>
<NovaSelect value={form.default_landing_page} onChange={(val) => setForm((current) => ({ ...current, default_landing_page: val }))} searchable={false} options={landingOptions.map(([value, label]) => ({ value, label }))} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Card density</span>
<select value={form.card_density} onChange={(event) => setForm((current) => ({ ...current, card_density: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<option value="comfortable" className="bg-slate-900">Comfortable</option>
<option value="compact" className="bg-slate-900">Compact</option>
</select>
</label>
<NovaSelect value={form.card_density} onChange={(val) => setForm((current) => ({ ...current, card_density: val }))} searchable={false} options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]} />
</div>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduling timezone</span>

View File

@@ -3,6 +3,7 @@ import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') {
const response = await fetch(url, {
@@ -142,18 +143,14 @@ export default function StudioScheduled() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search scheduled work</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span>
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{(listing.module_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">
<NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={listing.module_options || []} searchable={false} />
</div>
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Date range</span>
<select value={filters.range || 'upcoming'} onChange={(event) => updateFilters({ range: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{rangeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<NovaSelect value={filters.range || 'upcoming'} onChange={(val) => updateFilters({ range: val })} options={rangeOptions} searchable={false} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Start date</span>
<input type="date" value={filters.start_date || ''} onChange={(event) => updateFilters({ range: 'custom', start_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioSearch() {
const { props } = usePage()
@@ -22,8 +23,8 @@ export default function StudioSearch() {
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="space-y-2 text-sm text-slate-300 xl:col-span-3"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search Studio</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Search content, comments, inbox, or assets" /></label>
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Surface</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.type_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.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Surface</span><NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={search.type_options || []} searchable={false} /></div>
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={search.module_options || []} searchable={false} /></div>
</div>
</section>

View File

@@ -0,0 +1,921 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
const MIN_CHUNK_SIZE_BYTES = 256 * 1024
function formatDate(value) {
if (!value) return 'Just now'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Just now'
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function formatPercent(value) {
const normalized = Number(value || 0)
if (!Number.isFinite(normalized)) return '0%'
return `${Math.max(0, Math.min(100, Math.round(normalized)))}%`
}
function parseTags(raw) {
return String(raw || '')
.split(/[\n,]+/)
.map((tag) => tag.trim())
.filter(Boolean)
}
function statusClasses(status) {
switch (status) {
case 'ready':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'published':
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
case 'failed':
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
case 'needs_review':
case 'needs_metadata':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
default:
return 'border-white/15 bg-white/5 text-slate-300'
}
}
function batchStatusClasses(status) {
switch (status) {
case 'completed':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'completed_with_errors':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
case 'processing':
return 'border-sky-400/30 bg-sky-400/10 text-sky-100'
default:
return 'border-white/15 bg-white/5 text-slate-300'
}
}
function noticeClasses(type) {
switch (type) {
case 'success':
return 'border-emerald-400/30 bg-emerald-400/10 text-emerald-100'
case 'warning':
return 'border-amber-400/30 bg-amber-400/10 text-amber-100'
default:
return 'border-rose-400/30 bg-rose-400/10 text-rose-100'
}
}
function humanStage(stage) {
return String(stage || 'queued').replace(/_/g, ' ')
}
function flattenCategories(contentTypes) {
return (Array.isArray(contentTypes) ? contentTypes : []).flatMap((type) => {
const parents = Array.isArray(type?.categories) ? type.categories : []
return parents.flatMap((category) => {
const children = Array.isArray(category?.children) ? category.children : []
if (children.length === 0) {
return [{
id: category.id,
label: `${type.name} / ${category.name}`,
}]
}
return children.map((child) => ({
id: child.id,
label: `${type.name} / ${category.name} / ${child.name}`,
}))
})
})
}
function SummaryCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
<p className="mt-2 text-sm text-slate-400">{hint}</p>
</div>
)
}
export default function StudioUploadQueue() {
const { props } = usePage()
const queueProp = props.queue || {}
const chunkSize = Math.max(MIN_CHUNK_SIZE_BYTES, Number(props.chunkSize || 0) || (5 * 1024 * 1024))
const chunkRequestTimeoutMs = Math.max(15000, Number(props.chunkRequestTimeoutMs || 0) || 45000)
const categoryOptions = useMemo(() => flattenCategories(props.contentTypes || []), [props.contentTypes])
const [queue, setQueue] = useState(queueProp)
const [selectedBatchId, setSelectedBatchId] = useState(queueProp?.filters?.batch_id ?? queueProp?.current_batch?.id ?? '')
const [statusFilter, setStatusFilter] = useState(queueProp?.filters?.status || 'all')
const [sort, setSort] = useState(queueProp?.filters?.sort || 'newest')
const [selectedIds, setSelectedIds] = useState([])
const [files, setFiles] = useState([])
const [uploading, setUploading] = useState(false)
const [uploadState, setUploadState] = useState({})
const [notice, setNotice] = useState(null)
const [busyAction, setBusyAction] = useState('')
const [defaults, setDefaults] = useState({
name: '',
categoryId: '',
tags: '',
visibility: 'public',
isMature: false,
})
const [bulkForm, setBulkForm] = useState({
categoryId: '',
tags: '',
visibility: 'public',
})
const fileInputRef = useRef(null)
const noticeTimeoutRef = useRef(null)
const items = Array.isArray(queue?.items) ? queue.items : []
const currentBatch = queue?.current_batch || null
const batches = Array.isArray(queue?.batches) ? queue.batches : []
const selectableIds = items
.filter((item) => item?.actions?.can_delete || item?.actions?.can_publish || item?.actions?.can_generate_ai)
.map((item) => Number(item.id))
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id))
const activeProcessing = uploading || ['uploading', 'processing'].includes(String(currentBatch?.status || ''))
const pushNotice = (type, message) => {
setNotice({ type, message })
window.clearTimeout(noticeTimeoutRef.current)
noticeTimeoutRef.current = window.setTimeout(() => setNotice(null), 4500)
}
useEffect(() => () => window.clearTimeout(noticeTimeoutRef.current), [])
const syncSelectedIds = (queueItems) => {
const validIds = new Set((queueItems || []).map((item) => Number(item.id)))
setSelectedIds((current) => current.filter((id) => validIds.has(id)))
}
const loadQueue = async (overrides = {}) => {
const params = {
batch_id: overrides.batch_id ?? (selectedBatchId || undefined),
status: overrides.status ?? statusFilter,
sort: overrides.sort ?? sort,
}
try {
const response = await window.axios.get('/api/studio/upload-queue', { params })
const nextQueue = response.data || {}
setQueue(nextQueue)
setSelectedBatchId(nextQueue?.filters?.batch_id ?? '')
syncSelectedIds(nextQueue?.items || [])
return nextQueue
} catch (error) {
pushNotice('error', error?.response?.data?.message || 'Failed to refresh the upload queue.')
return null
}
}
useEffect(() => {
if (!activeProcessing || !selectedBatchId) return undefined
const timer = window.setInterval(() => {
loadQueue({ batch_id: selectedBatchId })
}, 3000)
return () => window.clearInterval(timer)
}, [activeProcessing, selectedBatchId, statusFilter, sort])
const uploadChunk = async (sessionId, uploadToken, blob, offset, totalSize) => {
const payload = new FormData()
payload.append('session_id', sessionId)
payload.append('offset', String(offset))
payload.append('chunk_size', String(blob.size))
payload.append('total_size', String(totalSize))
payload.append('chunk', blob)
payload.append('upload_token', uploadToken)
const response = await window.axios.post('/api/uploads/chunk', payload, {
timeout: chunkRequestTimeoutMs,
headers: { 'X-Upload-Token': uploadToken },
})
return response.data || {}
}
const uploadSingleFile = async (item, file) => {
const init = await window.axios.post('/api/uploads/init', { client: 'web' })
const sessionId = init?.data?.session_id
const uploadToken = init?.data?.upload_token
if (!sessionId || !uploadToken) {
throw new Error('Upload session initialization failed.')
}
let offset = 0
while (offset < file.size) {
const nextOffset = Math.min(offset + chunkSize, file.size)
const chunk = file.slice(offset, nextOffset)
const data = await uploadChunk(sessionId, uploadToken, chunk, offset, file.size)
offset = Number(data?.received_bytes ?? nextOffset)
const progress = Math.max(1, Math.min(100, Math.round((offset / file.size) * 100)))
setUploadState((current) => ({
...current,
[item.id]: {
...current[item.id],
status: 'uploading',
progress,
},
}))
}
await window.axios.post('/api/uploads/finish', {
session_id: sessionId,
artwork_id: item.artwork_id,
batch_item_id: item.id,
file_name: file.name,
upload_token: uploadToken,
}, {
headers: { 'X-Upload-Token': uploadToken },
})
setUploadState((current) => ({
...current,
[item.id]: {
status: 'processing',
progress: 100,
},
}))
}
const markItemFailed = async (itemId, error) => {
try {
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/fail`, {
error_code: error?.response?.data?.reason || 'upload_failed',
error_message: error?.response?.data?.message || error?.message || 'Upload failed.',
})
} catch (markError) {
// Keep the original upload error as the visible one.
}
}
const startUpload = async () => {
if (files.length === 0) {
pushNotice('error', 'Choose at least one image file to start a batch.')
return
}
setUploading(true)
setBusyAction('create-batch')
try {
const response = await window.axios.post('/api/studio/upload-queue/batches', {
name: defaults.name || null,
files: files.map((file) => ({ name: file.name })),
defaults: {
category_id: defaults.categoryId ? Number(defaults.categoryId) : null,
tags: parseTags(defaults.tags),
visibility: defaults.visibility,
is_mature: Boolean(defaults.isMature),
},
})
const createdItems = Array.isArray(response?.data?.items) ? response.data.items : []
const batchId = response?.data?.batch?.id
if (!batchId || createdItems.length !== files.length) {
throw new Error('Batch registration did not return a usable file map.')
}
setQueue(response.data.queue || queue)
setSelectedBatchId(batchId)
setSelectedIds([])
for (let index = 0; index < createdItems.length; index += 1) {
const item = createdItems[index]
const file = files[index]
setUploadState((current) => ({
...current,
[item.id]: { status: 'queued', progress: 0 },
}))
try {
await uploadSingleFile(item, file)
} catch (error) {
await markItemFailed(item.id, error)
setUploadState((current) => ({
...current,
[item.id]: {
status: 'failed',
progress: current[item.id]?.progress || 0,
},
}))
}
await loadQueue({ batch_id: batchId })
}
setFiles([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
pushNotice('success', 'Upload batch created. Processing continues in the queue.')
} catch (error) {
pushNotice('error', error?.response?.data?.message || error?.message || 'Failed to create the upload batch.')
} finally {
setUploading(false)
setBusyAction('')
}
}
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds([])
return
}
setSelectedIds(selectableIds)
}
const handleToggleSelected = (itemId) => {
setSelectedIds((current) => current.includes(itemId)
? current.filter((id) => id !== itemId)
: [...current, itemId])
}
const summarizePublishSelection = (ids) => {
const selectedItems = items.filter((item) => ids.includes(Number(item.id)))
const readyItems = selectedItems.filter((item) => item?.is_ready_to_publish)
const blockedItems = selectedItems.filter((item) => !item?.is_ready_to_publish)
const reviewBlockedCount = blockedItems.filter((item) => item?.status === 'needs_review').length
const metadataBlockedCount = blockedItems.filter((item) => item?.status === 'needs_metadata').length
const processingBlockedCount = blockedItems.filter((item) => item?.status === 'processing').length
const failedBlockedCount = blockedItems.filter((item) => item?.status === 'failed').length
return {
totalCount: selectedItems.length,
readyCount: readyItems.length,
blockedCount: blockedItems.length,
reviewBlockedCount,
metadataBlockedCount,
processingBlockedCount,
failedBlockedCount,
}
}
const confirmPublishSelection = (ids) => {
const summary = summarizePublishSelection(ids)
if (summary.totalCount === 0) {
pushNotice('warning', 'Select at least one queue item first.')
return false
}
if (summary.readyCount === 0) {
pushNotice('warning', 'None of the selected drafts are ready to publish yet.')
return false
}
const message = [
`Publish ${summary.readyCount} ready draft(s)?`,
`Selected: ${summary.totalCount}`,
`Ready now: ${summary.readyCount}`,
`Blocked and skipped: ${summary.blockedCount}`,
]
if (summary.reviewBlockedCount > 0) {
message.push(`Needs review: ${summary.reviewBlockedCount}`)
}
if (summary.metadataBlockedCount > 0) {
message.push(`Missing metadata: ${summary.metadataBlockedCount}`)
}
if (summary.processingBlockedCount > 0) {
message.push(`Still processing: ${summary.processingBlockedCount}`)
}
if (summary.failedBlockedCount > 0) {
message.push(`Failed items: ${summary.failedBlockedCount}`)
}
message.push('Blocked drafts will not be published.')
return window.confirm(message.join('\n'))
}
const runBulkAction = async (action, params = {}, ids = selectedIds) => {
if (!Array.isArray(ids) || ids.length === 0) {
pushNotice('warning', 'Select at least one queue item first.')
return
}
let confirmValue = undefined
if (action === 'publish') {
if (!confirmPublishSelection(ids)) {
return
}
}
if (action === 'delete') {
const value = window.prompt('Type DELETE to remove the selected drafts from the queue.')
if (value !== 'DELETE') {
return
}
confirmValue = value
}
setBusyAction(action)
try {
const response = await window.axios.post('/api/studio/upload-queue/bulk', {
action,
item_ids: ids,
params,
confirm: confirmValue,
})
const success = Number(response?.data?.success || 0)
const failed = Number(response?.data?.failed || 0)
if (failed > 0 && success === 0) {
pushNotice('error', response?.data?.errors?.[0] || 'The queue action failed.')
} else if (failed > 0) {
pushNotice('warning', `${success} item(s) updated. ${failed} item(s) could not be changed.`)
} else {
pushNotice('success', `${success} item(s) updated.`)
}
await loadQueue({ batch_id: selectedBatchId })
setSelectedIds([])
} catch (error) {
const message = error?.response?.data?.errors?.[0]
|| error?.response?.data?.message
|| 'The queue action failed.'
pushNotice('error', message)
} finally {
setBusyAction('')
}
}
const retryItem = async (itemId) => {
setBusyAction(`retry-${itemId}`)
try {
await window.axios.post(`/api/studio/upload-queue/items/${itemId}/retry`)
pushNotice('success', 'Background processing has been queued again for this draft.')
await loadQueue({ batch_id: selectedBatchId })
} catch (error) {
pushNotice('error', error?.response?.data?.message || 'Retry failed for this queue item.')
} finally {
setBusyAction('')
}
}
const handleBatchChange = async (nextBatchId) => {
setSelectedBatchId(nextBatchId)
await loadQueue({ batch_id: nextBatchId || undefined })
}
const onDropFiles = (event) => {
event.preventDefault()
const dropped = Array.from(event.dataTransfer.files || [])
setFiles(dropped)
}
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6">
{notice && (
<div className={`rounded-[24px] border px-4 py-3 text-sm ${noticeClasses(notice.type)}`}>
{notice.message}
</div>
)}
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.96))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.3)]">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Bulk upload drafts</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Start a batch, then let Studio handle the review queue.</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Each file becomes a normal draft artwork. Upload transport happens now, thumbnail and maturity work continue in the background, and publishing stays blocked until the draft is actually ready.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:w-[380px]">
<SummaryCard label="Selected files" value={files.length} hint="Add multiple images to build a batch." />
<SummaryCard label="Current batch" value={currentBatch?.total_items || 0} hint={currentBatch ? `Status: ${String(currentBatch.status || 'uploading').replace(/_/g, ' ')}` : 'No active batch selected.'} />
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr,0.8fr]">
<div
className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.03] p-5 transition hover:border-sky-300/35"
onDragOver={(event) => event.preventDefault()}
onDrop={onDropFiles}
>
<div className="flex h-full flex-col justify-between gap-4">
<div>
<p className="text-sm font-semibold text-white">Drag multiple image files here</p>
<p className="mt-2 text-sm text-slate-400">PNG, JPG, and WebP files are supported through the normal upload pipeline. Each file becomes one draft artwork.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15"
>
<i className="fa-solid fa-cloud-arrow-up" />
Choose files
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => setFiles(Array.from(event.target.files || []))}
/>
<span className="text-sm text-slate-500">{files.length > 0 ? `${files.length} file(s) ready` : 'Nothing selected yet'}</span>
</div>
{files.length > 0 && (
<div className="rounded-2xl border border-white/10 bg-slate-950/30 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch contents</p>
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1 text-sm text-slate-300">
{files.map((file) => (
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-3 rounded-2xl border border-white/5 bg-white/[0.02] px-3 py-2">
<span className="truncate">{file.name}</span>
<span className="text-xs text-slate-500">{Math.max(1, Math.round(file.size / 1024))} KB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<p className="text-sm font-semibold text-white">Shared defaults</p>
<div className="mt-4 space-y-4">
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch name</span>
<input
value={defaults.name}
onChange={(event) => setDefaults((current) => ({ ...current, name: event.target.value }))}
className="mt-2 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/35"
placeholder="Optional batch label"
/>
</label>
<div className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Category</span>
<NovaSelect
value={defaults.categoryId}
onChange={(value) => setDefaults((current) => ({ ...current, categoryId: value }))}
className="mt-2"
options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))}
placeholder="No shared category"
/>
</div>
<div className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility when published</span>
<NovaSelect
value={defaults.visibility}
onChange={(value) => setDefaults((current) => ({ ...current, visibility: value }))}
className="mt-2"
options={[
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted' },
{ value: 'private', label: 'Private' },
]}
searchable={false}
/>
</div>
<label className="block text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shared tags</span>
<textarea
value={defaults.tags}
onChange={(event) => setDefaults((current) => ({ ...current, tags: event.target.value }))}
className="mt-2 min-h-[92px] 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/35"
placeholder="fantasy, portrait, wallpaper"
/>
</label>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
<Checkbox
checked={defaults.isMature}
onChange={(event) => setDefaults((current) => ({ ...current, isMature: event.target.checked }))}
label="Mark all files as creator-declared mature"
/>
</div>
<button
type="button"
onClick={startUpload}
disabled={uploading || files.length === 0}
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/45 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-play" />
{busyAction === 'create-batch' ? 'Creating batch...' : 'Start upload batch'}
</button>
</div>
</div>
</div>
</section>
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Queue view</p>
<h2 className="mt-2 text-xl font-semibold text-white">Review a batch, then work the drafts that actually need attention.</h2>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Batch</span>
<NovaSelect
value={selectedBatchId}
onChange={handleBatchChange}
className="mt-2"
options={batches.map((batch) => ({ value: String(batch.id), label: batch.name || `Batch #${batch.id}` }))}
placeholder="Latest batch"
/>
</div>
<div className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Filter</span>
<NovaSelect
value={statusFilter}
onChange={async (nextStatus) => {
setStatusFilter(nextStatus)
await loadQueue({ status: nextStatus })
}}
className="mt-2"
options={(queue?.status_options || []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
<div className="text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sort</span>
<NovaSelect
value={sort}
onChange={async (nextSort) => {
setSort(nextSort)
await loadQueue({ sort: nextSort })
}}
className="mt-2"
options={(queue?.sort_options || []).map((option) => ({ value: option.value, label: option.label }))}
searchable={false}
/>
</div>
</div>
</div>
{currentBatch && (
<div className="mt-6 grid gap-4 md:grid-cols-5">
<SummaryCard label="Batch status" value={String(currentBatch.status || 'uploading').replace(/_/g, ' ')} hint={`Updated ${formatDate(currentBatch.updated_at)}`} />
<SummaryCard label="Ready" value={currentBatch.ready_items || 0} hint="Can be published right now." />
<SummaryCard label="Processing" value={currentBatch.processing_items || 0} hint="Still moving through the pipeline." />
<SummaryCard label="Needs review" value={currentBatch.needs_review_items || 0} hint="Blocked on maturity or review." />
<SummaryCard label="Failed" value={currentBatch.failed_items || 0} hint="Needs retry or a fresh upload." />
</div>
)}
<div className="mt-6 rounded-[28px] border border-white/10 bg-slate-950/35 p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSelectAll}
disabled={selectableIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-check-double" />
{allSelected ? 'Clear selection' : 'Select visible'}
</button>
<span className="text-sm text-slate-500">{selectedIds.length} selected</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => runBulkAction('publish')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Publish selected
</button>
<button
type="button"
onClick={() => runBulkAction('generate_ai')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Generate AI
</button>
<button
type="button"
onClick={() => runBulkAction('delete')}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete selected
</button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr,1fr,auto,auto]">
<NovaSelect
value={bulkForm.categoryId}
onChange={(value) => setBulkForm((current) => ({ ...current, categoryId: value }))}
options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))}
placeholder="Apply category..."
/>
<input
value={bulkForm.tags}
onChange={(event) => setBulkForm((current) => ({ ...current, tags: event.target.value }))}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
placeholder="Add shared tags to selection"
/>
<NovaSelect
value={bulkForm.visibility}
onChange={(value) => setBulkForm((current) => ({ ...current, visibility: value }))}
options={[
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted' },
{ value: 'private', label: 'Private' },
]}
searchable={false}
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => runBulkAction('apply_category', { category_id: Number(bulkForm.categoryId) })}
disabled={busyAction !== '' || selectedIds.length === 0 || !bulkForm.categoryId}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply category
</button>
<button
type="button"
onClick={() => runBulkAction('apply_tags', { tags: parseTags(bulkForm.tags) })}
disabled={busyAction !== '' || selectedIds.length === 0 || parseTags(bulkForm.tags).length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Apply tags
</button>
<button
type="button"
onClick={() => runBulkAction('set_visibility', { visibility: bulkForm.visibility })}
disabled={busyAction !== '' || selectedIds.length === 0}
className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50"
>
Set visibility
</button>
</div>
</div>
</div>
{items.length === 0 ? (
<div className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.02] p-8 text-center text-sm text-slate-400">
No items match this view yet. Start a batch above or switch to another recent batch.
</div>
) : (
<div className="mt-6 grid gap-4 xl:grid-cols-2">
{items.map((item) => {
const localUpload = uploadState[item.id] || null
const progress = localUpload?.progress ?? null
const actionState = item.actions || {}
return (
<article key={item.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
<div className="flex items-start gap-4">
<Checkbox
checked={selectedIds.includes(Number(item.id))}
onChange={() => handleToggleSelected(Number(item.id))}
/>
<div className="h-24 w-24 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
{item.thumbnail_url ? (
<img src={item.thumbnail_url} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-slate-500">
<i className="fa-solid fa-image text-2xl" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<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-semibold uppercase tracking-[0.16em] ${statusClasses(item.status)}`}>
{String(item.status || 'processing').replace(/_/g, ' ')}
</span>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${batchStatusClasses(item.processing_stage === 'finalized' ? 'completed' : 'processing')}`}>
{humanStage(item.processing_stage)}
</span>
{item.is_ready_to_publish && (
<span className="inline-flex items-center rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">
Ready to publish
</span>
)}
</div>
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-1 truncate text-sm text-slate-400">{item.original_filename}</p>
<div className="mt-4 grid gap-2 text-sm text-slate-300 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</span>
<p className="mt-2 text-white">{item.metadata_label}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Updated</span>
<p className="mt-2 text-white">{formatDate(item.updated_at)}</p>
</div>
</div>
{typeof progress === 'number' && progress < 100 && (
<div className="mt-4 rounded-2xl border border-sky-300/20 bg-sky-300/10 px-3 py-3 text-sm text-sky-100">
Uploading now: {formatPercent(progress)}
</div>
)}
{Array.isArray(item.missing) && item.missing.length > 0 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/30 px-3 py-3 text-sm text-slate-300">
{item.missing.join(' • ')}
</div>
)}
{item.error_message && (
<div className="mt-4 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-3 py-3 text-sm text-rose-100">
{item.error_message}
</div>
)}
<div className="mt-5 flex flex-wrap gap-2">
{actionState.can_edit && item.edit_url && (
<a href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200 transition hover:border-white/20 hover:bg-white/[0.06]">
<i className="fa-solid fa-pen-to-square" />
Edit in Studio
</a>
)}
{actionState.can_publish && (
<button
type="button"
onClick={() => runBulkAction('publish', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-400/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-rocket" />
Publish
</button>
)}
{actionState.can_generate_ai && (
<button
type="button"
onClick={() => runBulkAction('generate_ai', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-wand-magic-sparkles" />
Generate AI
</button>
)}
{actionState.can_retry_processing && (
<button
type="button"
onClick={() => retryItem(item.id)}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-sm font-semibold text-amber-100 transition hover:border-amber-400/40 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-rotate-right" />
Retry
</button>
)}
{actionState.can_delete && (
<button
type="button"
onClick={() => runBulkAction('delete', {}, [item.id])}
disabled={busyAction !== ''}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100 transition hover:border-rose-400/40 hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-50"
>
<i className="fa-solid fa-trash" />
Delete draft
</button>
)}
</div>
</div>
</div>
</article>
)
})}
</div>
)}
</section>
</div>
</StudioLayout>
)
}

View File

@@ -1,6 +1,9 @@
import React from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import WorldStatusBadge from '../../components/worlds/WorldStatusBadge'
import WorldAnalyticsPortfolioPanel from '../../components/worlds/editor/analytics/WorldAnalyticsPortfolioPanel'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioWorldsIndex() {
const { props } = usePage()
@@ -15,26 +18,22 @@ export default function StudioWorldsIndex() {
return (
<StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6">
<WorldAnalyticsPortfolioPanel analytics={props.analytics} />
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_auto] lg:items-end">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
<input value={filters.q || ''} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Search title, slug, or summary" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select value={filters.status || ''} onChange={(event) => updateFilter('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">All statuses</option>
{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<NovaSelect value={filters.status || ''} onChange={(val) => updateFilter('status', val)} options={[{ value: '', label: 'All statuses' }, ...(props.statusOptions || [])]} searchable={false} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select value={filters.type || ''} onChange={(event) => updateFilter('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
<option value="">All types</option>
{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<NovaSelect value={filters.type || ''} onChange={(val) => updateFilter('type', val)} options={[{ value: '', label: 'All types' }, ...(props.typeOptions || [])]} searchable={false} />
</div>
<a href={props.createUrl} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus" />New world</a>
</div>
</section>
@@ -43,16 +42,18 @@ export default function StudioWorldsIndex() {
{items.length > 0 ? items.map((world) => (
<a key={world.id} href={world.edit_url} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 transition hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.status}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.type}</span>
{world.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-amber-100">Featured</span> : null}
<WorldStatusBadge badge={{ label: world.status, tone: 'slate' }} />
<WorldStatusBadge badge={{ label: world.type, tone: 'slate' }} />
{(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => <WorldStatusBadge key={`${world.id}-${badge.label}`} badge={badge} />)}
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">{world.title}</h2>
<div className="mt-2 text-sm text-slate-500">/{world.slug}</div>
{world.summary ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.summary}</p> : null}
<div className="mt-5 flex flex-wrap gap-4 text-sm text-slate-400">
{world.timeframe_label ? <span>{world.timeframe_label}</span> : null}
{world.promotion_window_label ? <span>{world.promotion_window_label}</span> : null}
<span>{world.relation_count} relations</span>
{world.live_submission_count > 0 ? <span>{world.live_submission_count} live submissions</span> : null}
{world.theme_key ? <span>{world.theme_key}</span> : null}
</div>
<div className="mt-5 flex flex-wrap gap-3 text-sm font-semibold">