Compare commits

...

3 Commits

Author SHA1 Message Date
35011001ba Replace native selects with NovaSelect 2026-05-01 07:45:37 +02:00
67be537c86 new test files 2026-04-25 08:36:03 +02:00
19d5a9ed3e removed files 2026-04-25 08:35:52 +02:00
75 changed files with 7211 additions and 18364 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link, usePage } from '@inertiajs/react' import { Link, usePage } from '@inertiajs/react'
import NovaSelect from '../components/ui/NovaSelect'
const navItems = [ const navItems = [
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' }, { label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
@@ -110,20 +111,16 @@ export default function SettingsLayout({ children, title, sections = null, activ
<div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30"> <div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
{hasSectionMode ? ( {hasSectionMode ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="block flex-1"> <div className="block flex-1">
<span className="sr-only">Settings section</span> <span className="sr-only">Settings section</span>
<select <NovaSelect
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none" className="w-full"
value={activeSection || ''} value={activeSection || ''}
onChange={(e) => onSectionChange(e.target.value)} onChange={(value) => onSectionChange(value)}
> options={sections.map((section) => ({ value: section.key, label: `${section.label}${dirtyMap[section.key] ? ' •' : ''}` }))}
{sections.map((section) => ( searchable={false}
<option key={section.key} value={section.key} className="bg-nova-900 text-white"> />
{section.label}{dirtyMap[section.key] ? ' •' : ''} </div>
</option>
))}
</select>
</label>
{dirtyMap[activeSection] ? ( {dirtyMap[activeSection] ? (
<span className="inline-flex items-center rounded-full bg-amber-400/15 px-2 py-1 text-[10px] font-semibold text-amber-300 border border-amber-400/20"> <span className="inline-flex items-center rounded-full bg-amber-400/15 px-2 py-1 text-[10px] font-semibold text-amber-300 border border-amber-400/20">
Unsaved Unsaved

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link, usePage } from '@inertiajs/react' import { Link, usePage } from '@inertiajs/react'
import NovaSelect from '../components/ui/NovaSelect'
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents' import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
const baseNavGroups = [ const baseNavGroups = [
@@ -15,8 +16,9 @@ const baseNavGroups = [
label: 'Create', label: 'Create',
items: [ items: [
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-layer-group' },
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' }, { label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' }, { label: 'New Story', href: '/studio/stories/create', icon: 'fa-solid fa-feather-pointed', fullLoad: true },
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' }, { label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
], ],
}, },
@@ -34,8 +36,9 @@ const baseNavGroups = [
label: 'Library', label: 'Library',
items: [ items: [
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' }, { label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
{ label: 'Upload Queue', href: '/studio/upload-queue', icon: 'fa-solid fa-list-check' },
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' }, { label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' }, { label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-days' },
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' }, { label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
{ label: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' }, { label: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' },
], ],
@@ -71,7 +74,7 @@ const baseNavGroups = [
const baseQuickCreateItems = [ const baseQuickCreateItems = [
{ label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' }, { label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
{ label: 'Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' }, { label: 'Story', href: '/studio/stories/create', icon: 'fa-solid fa-feather-pointed', fullLoad: true },
{ label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' }, { label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
] ]
@@ -145,14 +148,25 @@ function navigateToStudioUrl(targetUrl) {
} }
function NavLink({ item, active }) { function NavLink({ item, active }) {
const className = `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`
if (item.fullLoad) {
return (
<a href={item.href} className={className}>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</a>
)
}
return ( return (
<Link <Link
href={item.href} href={item.href}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${ className={className}
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
> >
<i className={`${item.icon} w-5 text-center text-base`} /> <i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span> <span>{item.label}</span>
@@ -169,6 +183,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
const currentGroup = props.studioGroup || null const currentGroup = props.studioGroup || null
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator) const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
const canManageWorlds = canManageNews const canManageWorlds = canManageNews
const isStaff = Boolean(props.auth?.user?.is_staff)
const navGroups = baseNavGroups.map((group) => { const navGroups = baseNavGroups.map((group) => {
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') { if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
@@ -312,6 +327,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
navGroups={navGroups} navGroups={navGroups}
quickCreateItems={quickCreateItems} quickCreateItems={quickCreateItems}
isActive={isActive} isActive={isActive}
isStaff={isStaff}
onNavigate={() => setMobileOpen(false)} onNavigate={() => setMobileOpen(false)}
onQuickCreate={handleQuickCreateClick} onQuickCreate={handleQuickCreateClick}
onContextChange={handleContextChange} onContextChange={handleContextChange}
@@ -328,6 +344,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
navGroups={navGroups} navGroups={navGroups}
quickCreateItems={quickCreateItems} quickCreateItems={quickCreateItems}
isActive={isActive} isActive={isActive}
isStaff={isStaff}
onQuickCreate={handleQuickCreateClick} onQuickCreate={handleQuickCreateClick}
onContextChange={handleContextChange} onContextChange={handleContextChange}
/> />
@@ -384,25 +401,22 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) { function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
return ( return (
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200"> <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200">
<i className="fa-solid fa-people-group text-sky-200" /> <i className="fa-solid fa-people-group text-sky-200" />
<select <NovaSelect
value={currentGroup?.slug || ''} value={currentGroup?.slug || ''}
onChange={(event) => onContextChange?.(event.target.value)} onChange={(value) => onContextChange?.(value)}
className="bg-transparent text-sm text-white outline-none" options={[
> { value: '', label: 'Personal studio' },
<option value="" className="bg-slate-950 text-white">Personal studio</option> ...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
{studioGroups.map((group) => ( ]}
<option key={group.slug} value={group.slug} className="bg-slate-950 text-white"> searchable={false}
{group.name} />
</option> </div>
))}
</select>
</label>
) )
} }
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, onNavigate, onQuickCreate, onContextChange }) { function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
return ( return (
<> <>
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4"> <div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
@@ -412,16 +426,16 @@ function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCrea
{studioGroups.length > 0 ? ( {studioGroups.length > 0 ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3"> <div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p> <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p>
<select <NovaSelect
value={currentGroup?.slug || ''} value={currentGroup?.slug || ''}
onChange={(event) => onContextChange?.(event.target.value)} onChange={(value) => onContextChange?.(value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2 text-sm text-white outline-none" className="mt-2"
> options={[
<option value="">Personal studio</option> { value: '', label: 'Personal studio' },
{studioGroups.map((group) => ( ...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
<option key={group.slug} value={group.slug}>{group.name}</option> ]}
))} searchable={false}
</select> />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -437,6 +451,20 @@ function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCrea
</div> </div>
</div> </div>
))} ))}
{isStaff && (
<div>
<h3 className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-500/70">Administration</h3>
<div className="space-y-1">
<a
href="/moderation"
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium text-rose-300/70 transition hover:bg-rose-500/10 hover:text-rose-300"
>
<i className="fa-solid fa-shield-halved w-5 text-center text-base" />
<span>Admin Panel</span>
</a>
</div>
</div>
)}
</nav> </nav>
<div className="mt-6 rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(15,23,42,0.95),_rgba(12,74,110,0.4))] p-4"> <div className="mt-6 rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(15,23,42,0.95),_rgba(12,74,110,0.4))] p-4">

View File

@@ -2,6 +2,7 @@ import React, { startTransition, useDeferredValue, useEffect, useRef, useState }
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import CategoryCard from '../components/category/CategoryCard' import CategoryCard from '../components/category/CategoryCard'
import Pagination from '../components/forum/Pagination' import Pagination from '../components/forum/Pagination'
import NovaSelect from '../components/ui/NovaSelect'
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{ value: 'popular', label: 'Popular' }, { value: 'popular', label: 'Popular' },
@@ -13,6 +14,26 @@ const PAGE_SIZE = 24
const numberFormatter = new Intl.NumberFormat() const numberFormatter = new Intl.NumberFormat()
function normalizeInitialData(initialData) {
if (!initialData || typeof initialData !== 'object') {
return {
data: [],
meta: { current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 },
summary: { total_categories: 0, total_artworks: 0 },
popular_categories: [],
request: { query: '', sort: 'popular', page: 1 },
}
}
return {
data: Array.isArray(initialData.data) ? initialData.data : [],
meta: initialData.meta || { current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 },
summary: initialData.summary || { total_categories: 0, total_artworks: 0 },
popular_categories: Array.isArray(initialData.popular_categories) ? initialData.popular_categories : [],
request: initialData.request || { query: '', sort: 'popular', page: 1 },
}
}
function LoadingGrid() { function LoadingGrid() {
return ( return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
@@ -113,17 +134,21 @@ function syncQueryState({ page, sort, query }) {
window.history.replaceState({}, '', url.toString()) window.history.replaceState({}, '', url.toString())
} }
function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '' }) { function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '', initialData = null }) {
const [categories, setCategories] = useState([]) const bootstrap = normalizeInitialData(initialData)
const [popularCategories, setPopularCategories] = useState([]) const hasInitialData = initialData !== null
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 }) const initialRequestRef = useRef(hasInitialData ? bootstrap.request : null)
const [summary, setSummary] = useState({ total_categories: 0, total_artworks: 0 })
const [loading, setLoading] = useState(true) const [categories, setCategories] = useState(() => (hasInitialData ? bootstrap.data : []))
const [popularCategories, setPopularCategories] = useState(() => (hasInitialData ? bootstrap.popular_categories : []))
const [meta, setMeta] = useState(() => (hasInitialData ? bootstrap.meta : { current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 }))
const [summary, setSummary] = useState(() => (hasInitialData ? bootstrap.summary : { total_categories: 0, total_artworks: 0 }))
const [loading, setLoading] = useState(() => !hasInitialData)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [searchQuery, setSearchQuery] = useState(() => getInitialSearchQuery()) const [searchQuery, setSearchQuery] = useState(() => (hasInitialData ? (bootstrap.request.query || '') : getInitialSearchQuery()))
const [sort, setSort] = useState(() => getInitialSort()) const [sort, setSort] = useState(() => (hasInitialData ? (bootstrap.request.sort || 'popular') : getInitialSort()))
const [currentPage, setCurrentPage] = useState(() => getInitialPage()) const [currentPage, setCurrentPage] = useState(() => (hasInitialData ? (bootstrap.request.page || 1) : getInitialPage()))
const deferredQuery = useDeferredValue(searchQuery) const deferredQuery = useDeferredValue(searchQuery)
const sentinelRef = useRef(null) const sentinelRef = useRef(null)
@@ -199,6 +224,20 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
} }
useEffect(() => { useEffect(() => {
const initialRequest = initialRequestRef.current
if (initialRequest) {
const sameQuery = (initialRequest.query || '') === deferredQuery
const sameSort = (initialRequest.sort || 'popular') === sort
const samePage = Number(initialRequest.page || 1) === currentPage
initialRequestRef.current = null
if (sameQuery && sameSort && samePage) {
return undefined
}
}
const controller = new AbortController() const controller = new AbortController()
void loadCategories({ void loadCategories({
@@ -334,24 +373,19 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
/> />
</label> </label>
<label className="block"> <div className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-white/38">Sort by</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-white/38">Sort by</span>
<select <NovaSelect
value={sort} value={sort}
onChange={(event) => { onChange={(value) => {
setSort(event.target.value) setSort(value)
setCurrentPage(1) setCurrentPage(1)
}} }}
aria-label="Sort categories" id="categories-sort"
className="h-14 w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 text-sm text-white focus:border-orange-300/45 focus:outline-none focus:ring-2 focus:ring-orange-300/12" options={SORT_OPTIONS.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{SORT_OPTIONS.map((option) => ( />
<option key={option.value} value={option.value} className="bg-slate-950 text-white"> </div>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -438,6 +472,7 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
) )
} }
if (typeof document !== 'undefined') {
const mountElement = document.getElementById('categories-page-root') const mountElement = document.getElementById('categories-page-root')
if (mountElement) { if (mountElement) {
@@ -452,5 +487,6 @@ if (mountElement) {
createRoot(mountElement).render(<CategoriesPage {...props} />) createRoot(mountElement).render(<CategoriesPage {...props} />)
} }
}
export default CategoriesPage export default CategoriesPage

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
const DEFAULT_SEARCH_FILTERS = { const DEFAULT_SEARCH_FILTERS = {
q: '', q: '',
@@ -262,13 +264,7 @@ function BulkActionsPanel({
<div className="mt-4 grid gap-4 lg:grid-cols-4"> <div className="mt-4 grid gap-4 lg:grid-cols-4">
<SearchField label="Action"> <SearchField label="Action">
<select value={form.action} onChange={(event) => onFormChange('action', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={form.action} onChange={(val) => onFormChange('action', val)} searchable={false} options={[{ value: 'archive', label: 'Archive' }, { value: 'assign_campaign', label: 'Assign campaign' }, { value: 'update_lifecycle', label: 'Update lifecycle' }, { value: 'request_ai_review', label: 'Request AI review' }, { value: 'mark_editorial_review', label: 'Mark editorial review' }]} />
<option value="archive">Archive</option>
<option value="assign_campaign">Assign campaign</option>
<option value="update_lifecycle">Update lifecycle</option>
<option value="request_ai_review">Request AI review</option>
<option value="mark_editorial_review">Mark editorial review</option>
</select>
</SearchField> </SearchField>
{form.action === 'assign_campaign' ? ( {form.action === 'assign_campaign' ? (
@@ -284,11 +280,7 @@ function BulkActionsPanel({
{form.action === 'update_lifecycle' ? ( {form.action === 'update_lifecycle' ? (
<SearchField label="Lifecycle state"> <SearchField label="Lifecycle state">
<select value={form.lifecycle_state} onChange={(event) => onFormChange('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={form.lifecycle_state} onChange={(val) => onFormChange('lifecycle_state', val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'published', label: 'Published' }, { value: 'archived', label: 'Archived' }]} />
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</SearchField> </SearchField>
) : null} ) : null}
@@ -344,15 +336,13 @@ function SearchResults({ state, endpoints, selectedIds, onToggleSelected }) {
{state.collections.map((collection) => ( {state.collections.map((collection) => (
<div key={collection.id} className="space-y-3 rounded-[28px] border border-white/10 bg-[#0d1726] p-5"> <div key={collection.id} className="space-y-3 rounded-[28px] border border-white/10 bg-[#0d1726] p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400"> <div className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
<input <Checkbox
type="checkbox"
checked={selectedIds.includes(collection.id)} checked={selectedIds.includes(collection.id)}
onChange={() => onToggleSelected(collection.id)} onChange={() => onToggleSelected(collection.id)}
className="h-4 w-4 rounded border-white/20 bg-[#09111d] text-sky-400 focus:ring-sky-300/30" label="Select"
/> />
Select </div>
</label>
</div> </div>
<CollectionCard collection={collection} isOwner /> <CollectionCard collection={collection} isOwner />
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400"> <div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400">
@@ -584,56 +574,27 @@ export default function CollectionDashboard() {
</SearchField> </SearchField>
<SearchField label="Type"> <SearchField label="Type">
<select value={searchFilters.type} onChange={(event) => updateFilter('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.type} onChange={(val) => updateFilter('type', val)} placeholder="Any type" options={(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((o) => ({ value: o, label: titleize(o) }))} />
<option value="">Any type</option>
{(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField> </SearchField>
<SearchField label="Visibility"> <SearchField label="Visibility">
<select value={searchFilters.visibility} onChange={(event) => updateFilter('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.visibility} onChange={(val) => updateFilter('visibility', val)} placeholder="Any visibility" options={(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((o) => ({ value: o, label: titleize(o) }))} />
<option value="">Any visibility</option>
{(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField> </SearchField>
<SearchField label="Lifecycle"> <SearchField label="Lifecycle">
<select value={searchFilters.lifecycle_state} onChange={(event) => updateFilter('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.lifecycle_state} onChange={(val) => updateFilter('lifecycle_state', val)} placeholder="Any lifecycle" options={(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((o) => ({ value: o, label: titleize(o) }))} />
<option value="">Any lifecycle</option>
{(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField> </SearchField>
<SearchField label="Workflow"> <SearchField label="Workflow">
<select value={searchFilters.workflow_state} onChange={(event) => updateFilter('workflow_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.workflow_state} onChange={(val) => updateFilter('workflow_state', val)} placeholder="Any workflow" options={(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((o) => ({ value: o, label: titleize(o) }))} />
<option value="">Any workflow</option>
{(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField> </SearchField>
<SearchField label="Health"> <SearchField label="Health">
<select value={searchFilters.health_state} onChange={(event) => updateFilter('health_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.health_state} onChange={(val) => updateFilter('health_state', val)} placeholder="Any health state" options={(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((o) => ({ value: o, label: titleize(o) }))} />
<option value="">Any health state</option>
{(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField> </SearchField>
<SearchField label="Placement"> <SearchField label="Placement">
<select value={searchFilters.placement_eligibility} onChange={(event) => updateFilter('placement_eligibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={searchFilters.placement_eligibility} onChange={(val) => updateFilter('placement_eligibility', val)} placeholder="Any placement state" searchable={false} options={[{ value: '1', label: 'Eligible' }, { value: '0', label: 'Blocked' }]} />
<option value="">Any placement state</option>
<option value="1">Eligible</option>
<option value="0">Blocked</option>
</select>
</SearchField> </SearchField>
<div className="flex items-end gap-3 xl:col-span-1"> <div className="flex items-end gap-3 xl:col-span-1">

View File

@@ -1,7 +1,8 @@
import React from 'react' import React from 'react'
import { usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import SeoHead from '../../components/seo/SeoHead' import SeoHead from '../../components/seo/SeoHead'
import NovaSelect from '../../components/ui/NovaSelect'
const SEARCH_SELECT_OPTIONS = { const SEARCH_SELECT_OPTIONS = {
type: [ type: [
@@ -174,6 +175,33 @@ function SearchPanel({ search }) {
const options = search.options || {} const options = search.options || {}
const chips = activeSearchChips(filters) const chips = activeSearchChips(filters)
const [localFilters, setLocalFilters] = React.useState({
q: filters.q || '',
type: filters.type || '',
sort: filters.sort || 'trending',
category: filters.category || '',
mode: filters.mode || '',
style: filters.style || '',
lifecycle_state: filters.lifecycle_state || '',
theme: filters.theme || '',
health_state: filters.health_state || '',
color: filters.color || '',
campaign_key: filters.campaign_key || '',
program_key: filters.program_key || '',
quality_tier: filters.quality_tier || '',
})
function updateFilter(key, val) {
setLocalFilters((curr) => ({ ...curr, [key]: val }))
}
function handleSubmit(event) {
event.preventDefault()
const params = {}
Object.entries(localFilters).forEach(([k, v]) => { if (v) params[k] = v })
router.get('/collections/search', params)
}
return ( return (
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7"> <section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -183,75 +211,20 @@ function SearchPanel({ search }) {
</div> </div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{search?.meta?.total ?? 0} results</span> <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{search?.meta?.total ?? 0} results</span>
</div> </div>
<form method="GET" action="/collections/search" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <form onSubmit={handleSubmit} className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<input name="q" defaultValue={filters.q || ''} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" /> <input value={localFilters.q} onChange={(e) => updateFilter('q', e.target.value)} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" />
<select name="type" defaultValue={filters.type || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"> <NovaSelect value={localFilters.type} onChange={(val) => updateFilter('type', val)} placeholder="All types" searchable={false} options={[{ value: 'personal', label: 'Personal' }, { value: 'community', label: 'Community' }, { value: 'editorial', label: 'Editorial' }]} />
<option value="">All types</option> <NovaSelect value={localFilters.sort} onChange={(val) => updateFilter('sort', val)} searchable={false} options={[{ value: 'trending', label: 'Trending' }, { value: 'recent', label: 'Recent' }, { value: 'quality', label: 'Quality' }, { value: 'evergreen', label: 'Evergreen' }]} />
<option value="personal">Personal</option> <NovaSelect value={localFilters.category} onChange={(val) => updateFilter('category', val)} placeholder="Any category" options={(options.category || []).map((item) => ({ value: item.value, label: item.label }))} />
<option value="community">Community</option> <NovaSelect value={localFilters.mode} onChange={(val) => updateFilter('mode', val)} placeholder="Any curation mode" searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'smart', label: 'Smart' }]} />
<option value="editorial">Editorial</option> <NovaSelect value={localFilters.style} onChange={(val) => updateFilter('style', val)} placeholder="Any style signal" options={(options.style || []).map((item) => ({ value: item.value, label: item.label }))} />
</select> <NovaSelect value={localFilters.lifecycle_state} onChange={(val) => updateFilter('lifecycle_state', val)} placeholder="Any lifecycle" searchable={false} options={[{ value: 'published', label: 'Published' }, { value: 'featured', label: 'Featured' }, { value: 'archived', label: 'Archived' }]} />
<select name="sort" defaultValue={filters.sort || 'trending'} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"> <NovaSelect value={localFilters.theme} onChange={(val) => updateFilter('theme', val)} placeholder="Any theme" options={(options.theme || []).map((item) => ({ value: item.value, label: item.label }))} />
<option value="trending">Trending</option> <NovaSelect value={localFilters.health_state} onChange={(val) => updateFilter('health_state', val)} placeholder="Any quality state" searchable={false} options={[{ value: 'healthy', label: 'Healthy' }, { value: 'needs_metadata', label: 'Needs metadata' }, { value: 'stale', label: 'Stale' }, { value: 'low_content', label: 'Low content' }, { value: 'broken_items', label: 'Broken items' }, { value: 'weak_cover', label: 'Weak cover' }, { value: 'low_engagement', label: 'Low engagement' }, { value: 'duplicate_risk', label: 'Duplicate risk' }, { value: 'merge_candidate', label: 'Merge candidate' }]} />
<option value="recent">Recent</option> <NovaSelect value={localFilters.color} onChange={(val) => updateFilter('color', val)} placeholder="Any color palette" options={(options.color || []).map((item) => ({ value: item.value, label: item.label }))} />
<option value="quality">Quality</option> <input value={localFilters.campaign_key} onChange={(e) => updateFilter('campaign_key', e.target.value)} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<option value="evergreen">Evergreen</option> <input value={localFilters.program_key} onChange={(e) => updateFilter('program_key', e.target.value)} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
</select> <NovaSelect value={localFilters.quality_tier} onChange={(val) => updateFilter('quality_tier', val)} placeholder="Any quality tier" options={(options.quality_tier || []).map((item) => ({ value: item.value, label: item.label }))} />
<select name="category" defaultValue={filters.category || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any category</option>
{(options.category || []).map((item) => (
<option key={`category-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="mode" defaultValue={filters.mode || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any curation mode</option>
<option value="manual">Manual</option>
<option value="smart">Smart</option>
</select>
<select name="style" defaultValue={filters.style || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any style signal</option>
{(options.style || []).map((item) => (
<option key={`style-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="lifecycle_state" defaultValue={filters.lifecycle_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any lifecycle</option>
<option value="published">Published</option>
<option value="featured">Featured</option>
<option value="archived">Archived</option>
</select>
<select name="theme" defaultValue={filters.theme || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any theme</option>
{(options.theme || []).map((item) => (
<option key={`theme-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="health_state" defaultValue={filters.health_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality state</option>
<option value="healthy">Healthy</option>
<option value="needs_metadata">Needs metadata</option>
<option value="stale">Stale</option>
<option value="low_content">Low content</option>
<option value="broken_items">Broken items</option>
<option value="weak_cover">Weak cover</option>
<option value="low_engagement">Low engagement</option>
<option value="duplicate_risk">Duplicate risk</option>
<option value="merge_candidate">Merge candidate</option>
</select>
<select name="color" defaultValue={filters.color || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any color palette</option>
{(options.color || []).map((item) => (
<option key={`color-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<input name="campaign_key" defaultValue={filters.campaign_key || ''} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<input name="program_key" defaultValue={filters.program_key || ''} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<select name="quality_tier" defaultValue={filters.quality_tier || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality tier</option>
{(options.quality_tier || []).map((item) => (
<option key={`quality-tier-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3"> <div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3">
<button type="submit" className="inline-flex items-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-magnifying-glass fa-fw" />Apply filters</button> <button type="submit" className="inline-flex items-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-magnifying-glass fa-fw" />Apply filters</button>
<a href="/collections/search" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Reset</a> <a href="/collections/search" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Reset</a>

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge' import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -466,15 +468,19 @@ function MemberCard({ member, onRoleChange, onRemove, onAccept, onDecline, onTra
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{member?.can_revoke ? ( {member?.can_revoke ? (
<select <div className="min-w-[150px]">
value={member?.role} <NovaSelect
onChange={(event) => onRoleChange?.(member, event.target.value)} value={member?.role}
className="rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white" onChange={(value) => onRoleChange?.(member, value)}
> options={[
<option value="editor">Editor</option> { value: 'editor', label: 'Editor' },
<option value="contributor">Contributor</option> { value: 'contributor', label: 'Contributor' },
<option value="viewer">Viewer</option> { value: 'viewer', label: 'Viewer' },
</select> ]}
searchable={false}
className="text-xs font-semibold uppercase tracking-[0.14em]"
/>
</div>
) : null} ) : null}
{member?.can_accept ? <button type="button" onClick={() => onAccept?.(member)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Accept</button> : null} {member?.can_accept ? <button type="button" onClick={() => onAccept?.(member)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Accept</button> : null}
{member?.can_decline ? <button type="button" onClick={() => onDecline?.(member)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Decline</button> : null} {member?.can_decline ? <button type="button" onClick={() => onDecline?.(member)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Decline</button> : null}
@@ -528,28 +534,27 @@ function LayoutModuleCard({ module, index, total, onToggle, onSlotChange, onMove
<div className="text-sm font-semibold text-white">{module.label}</div> <div className="text-sm font-semibold text-white">{module.label}</div>
<p className="mt-1 text-sm leading-relaxed text-slate-400">{module.description}</p> <p className="mt-1 text-sm leading-relaxed text-slate-400">{module.description}</p>
</div> </div>
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"> <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">
<input <Checkbox
type="checkbox"
checked={module.enabled} checked={module.enabled}
disabled={module.locked} disabled={module.locked}
onChange={(event) => onToggle(module.key, event.target.checked)} onChange={(event) => onToggle(module.key, event.target.checked)}
label={module.locked ? 'Required' : (module.enabled ? 'Enabled' : 'Disabled')}
/> />
{module.locked ? 'Required' : (module.enabled ? 'Enabled' : 'Disabled')} </div>
</label>
</div> </div>
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]"> <div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Placement"> <Field label="Placement">
<select <NovaSelect
value={module.slot} value={module.slot}
onChange={(event) => onSlotChange(module.key, event.target.value)} onChange={(value) => onSlotChange(module.key, 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" options={module.slots.map((slot) => ({
> value: slot,
{module.slots.map((slot) => ( label: slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar',
<option key={slot} value={slot}>{slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar'}</option> }))}
))} searchable={false}
</select> />
</Field> </Field>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -646,27 +651,21 @@ function SmartRuleRow({
<div className="mt-4 grid gap-4 md:grid-cols-[1fr_180px_minmax(0,1.15fr)]"> <div className="mt-4 grid gap-4 md:grid-cols-[1fr_180px_minmax(0,1.15fr)]">
<Field label="Field"> <Field label="Field">
<select <NovaSelect
value={rule.field} value={rule.field}
onChange={(event) => onFieldChange(event.target.value)} onChange={(value) => onFieldChange(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" options={fieldOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{fieldOptions.map((option) => ( />
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field> </Field>
<Field label="Operator"> <Field label="Operator">
<select <NovaSelect
value={rule.operator} value={rule.operator}
onChange={(event) => onOperatorChange(event.target.value)} onChange={(value) => onOperatorChange(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" options={operatorOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{operatorOptions.map((option) => ( />
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field> </Field>
{rule.field === 'created_at' ? ( {rule.field === 'created_at' ? (
@@ -688,23 +687,20 @@ function SmartRuleRow({
</Field> </Field>
) : rule.field === 'is_featured' || rule.field === 'is_mature' ? ( ) : rule.field === 'is_featured' || rule.field === 'is_mature' ? (
<Field label="Value"> <Field label="Value">
<select <NovaSelect
value={rule.value ? 'true' : 'false'} value={rule.value ? 'true' : 'false'}
onChange={(event) => onValueChange(event.target.value === 'true')} onChange={(value) => onValueChange(value === 'true')}
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" options={rule.field === 'is_featured'
> ? [
{rule.field === 'is_featured' ? ( { value: 'true', label: 'Featured only' },
<> { value: 'false', label: 'Not featured' },
<option value="true">Featured only</option> ]
<option value="false">Not featured</option> : [
</> { value: 'true', label: 'Mature only' },
) : ( { value: 'false', label: 'Not mature' },
<> ]}
<option value="true">Mature only</option> searchable={false}
<option value="false">Not mature</option> />
</>
)}
</select>
</Field> </Field>
) : rule.field === 'tags' ? ( ) : rule.field === 'tags' ? (
<Field label="Value" help="Type a tag name exactly as it appears on your artworks."> <Field label="Value" help="Type a tag name exactly as it appears on your artworks.">
@@ -718,16 +714,13 @@ function SmartRuleRow({
</Field> </Field>
) : valueOptions.length ? ( ) : valueOptions.length ? (
<Field label="Value"> <Field label="Value">
<select <NovaSelect
value={rule.value} value={rule.value}
onChange={(event) => onValueChange(event.target.value)} onChange={(value) => onValueChange(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" options={valueOptions.map((option) => ({ value: option.value, label: option.label }))}
> placeholder="Select one"
<option value="">Select one</option> searchable={false}
{valueOptions.map((option) => ( />
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field> </Field>
) : ( ) : (
<Field label="Value"> <Field label="Value">
@@ -1917,11 +1910,7 @@ export default function CollectionManage() {
/> />
</Field> </Field>
<Field label="Visibility"> <Field label="Visibility">
<select value={form.visibility} onChange={(event) => updateForm('visibility', 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"> <NovaSelect value={form.visibility} onChange={(val) => updateForm('visibility', val)} searchable={false} options={[{ value: 'public', label: 'Public — visible to everyone' }, { value: 'unlisted', label: 'Unlisted — accessible by link only' }, { value: 'private', label: 'Private — only you can see it' }]} />
<option value="public">Public visible to everyone</option>
<option value="unlisted">Unlisted accessible by link only</option>
<option value="private">Private only you can see it</option>
</select>
</Field> </Field>
</div> </div>
@@ -1965,12 +1954,7 @@ export default function CollectionManage() {
/> />
</Field> </Field>
<Field label="Presentation Style"> <Field label="Presentation Style">
<select value={form.presentation_style} onChange={(event) => updateForm('presentation_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"> <NovaSelect value={form.presentation_style} onChange={(val) => updateForm('presentation_style', val)} searchable={false} options={[{ value: 'standard', label: 'Standard' }, { value: 'editorial_grid', label: 'Editorial Grid' }, { value: 'hero_grid', label: 'Hero Grid' }, { value: 'masonry', label: 'Masonry' }]} />
<option value="standard">Standard</option>
<option value="editorial_grid">Editorial Grid</option>
<option value="hero_grid">Hero Grid</option>
<option value="masonry">Masonry</option>
</select>
</Field> </Field>
</div> </div>
@@ -1986,73 +1970,49 @@ export default function CollectionManage() {
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<Field label="Emphasis Mode"> <Field label="Emphasis Mode">
<select value={form.emphasis_mode} onChange={(event) => updateForm('emphasis_mode', 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"> <NovaSelect value={form.emphasis_mode} onChange={(val) => updateForm('emphasis_mode', val)} searchable={false} options={[{ value: 'cover_heavy', label: 'Cover Heavy' }, { value: 'balanced', label: 'Balanced' }, { value: 'artwork_first', label: 'Artwork First' }]} />
<option value="cover_heavy">Cover Heavy</option>
<option value="balanced">Balanced</option>
<option value="artwork_first">Artwork First</option>
</select>
</Field> </Field>
<Field label="Theme"> <Field label="Theme">
<select value={form.theme_token} onChange={(event) => updateForm('theme_token', 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"> <NovaSelect value={form.theme_token} onChange={(val) => updateForm('theme_token', val)} searchable={false} options={[{ value: 'default', label: 'Default' }, { value: 'subtle-blue', label: 'Subtle Blue' }, { value: 'violet', label: 'Violet' }, { value: 'amber', label: 'Amber' }]} />
<option value="default">Default</option>
<option value="subtle-blue">Subtle Blue</option>
<option value="violet">Violet</option>
<option value="amber">Amber</option>
</select>
</Field> </Field>
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{!isSmartMode ? ( {!isSmartMode ? (
<Field label="Sort Order" help="Manual keeps the display order under your direct control."> <Field label="Sort Order" help="Manual keeps the display order under your direct control.">
<select value={form.sort_mode} onChange={(event) => updateForm('sort_mode', 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"> <NovaSelect value={form.sort_mode} onChange={(val) => updateForm('sort_mode', val)} searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'newest', label: 'Newest first' }, { value: 'oldest', label: 'Oldest first' }, { value: 'popular', label: 'Most popular' }]} />
<option value="manual">Manual</option>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="popular">Most popular</option>
</select>
</Field> </Field>
) : ( ) : (
<Field label="Match Mode" help="All rules must match, or any one rule is enough."> <Field label="Match Mode" help="All rules must match, or any one rule is enough.">
<select <NovaSelect
value={smartRules.match} value={smartRules.match}
onChange={(event) => setSmartRules((current) => ({ ...current, match: event.target.value }))} onChange={(val) => setSmartRules((current) => ({ ...current, match: val }))}
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" searchable={false}
> options={[{ value: 'all', label: 'All rules' }, { value: 'any', label: 'Any rule' }]}
<option value="all">All rules</option> />
<option value="any">Any rule</option>
</select>
</Field> </Field>
)} )}
{!isSmartMode ? ( {!isSmartMode ? (
<Field label="Cover Artwork" help={attachedCoverOptions.length ? 'Choose a cover from artworks already attached to this collection.' : 'Attach artworks first to pick a manual cover.'}> <Field label="Cover Artwork" help={attachedCoverOptions.length ? 'Choose a cover from artworks already attached to this collection.' : 'Attach artworks first to pick a manual cover.'}>
<select <NovaSelect
value={form.cover_artwork_id} value={String(form.cover_artwork_id || '')}
onChange={(event) => updateForm('cover_artwork_id', event.target.value)} onChange={(val) => updateForm('cover_artwork_id', val)}
disabled={!attachedCoverOptions.length} disabled={!attachedCoverOptions.length}
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 disabled:cursor-not-allowed disabled:text-slate-500" placeholder="Automatic cover"
> options={attachedCoverOptions.map((a) => ({ value: String(a.id), label: a.title }))}
<option value="">Automatic cover</option> />
{attachedCoverOptions.map((artwork) => (
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
))}
</select>
</Field> </Field>
) : ( ) : (
<Field label="Smart Sort" help="How matching artworks should be ordered in this collection."> <Field label="Smart Sort" help="How matching artworks should be ordered in this collection.">
<select <NovaSelect
value={smartRules.sort} value={smartRules.sort}
onChange={(event) => { onChange={(val) => {
setSmartRules((current) => ({ ...current, sort: event.target.value })) setSmartRules((current) => ({ ...current, sort: val }))
updateForm('sort_mode', event.target.value) updateForm('sort_mode', val)
}} }}
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" options={(smartRuleOptions?.sort_options || [])}
> />
{(smartRuleOptions?.sort_options || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field> </Field>
)} )}
</div> </div>
@@ -2061,48 +2021,32 @@ export default function CollectionManage() {
<AdvancedSection title="Collaboration & Access" icon="fa-user-group" defaultOpen={mode === 'edit'}> <AdvancedSection title="Collaboration & Access" icon="fa-user-group" defaultOpen={mode === 'edit'}>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<Field label="Collection Type"> <Field label="Collection Type">
<select value={form.type} onChange={(event) => updateForm('type', 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"> <NovaSelect value={form.type} onChange={(val) => updateForm('type', val)} searchable={false} options={[{ value: 'personal', label: 'Personal' }, { value: 'community', label: 'Community' }, { value: 'editorial', label: 'Editorial' }]} />
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
</Field> </Field>
<Field label="Collaboration Mode"> <Field label="Collaboration Mode">
<select value={form.collaboration_mode} onChange={(event) => updateForm('collaboration_mode', 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"> <NovaSelect value={form.collaboration_mode} onChange={(val) => updateForm('collaboration_mode', val)} searchable={false} options={[{ value: 'closed', label: 'Closed — curated by you only' }, { value: 'invite_only', label: 'Invite only' }, { value: 'open', label: 'Open submissions' }]} />
<option value="closed">Closed curated by you only</option>
<option value="invite_only">Invite only</option>
<option value="open">Open submissions</option>
</select>
</Field> </Field>
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} /> <Checkbox checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} label="Allow submissions" />
Allow submissions </div>
</label> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <Checkbox checked={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} label="Allow comments" />
<input type="checkbox" checked={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} /> </div>
Allow comments <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
</label> <Checkbox checked={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} label="Allow saves" />
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> </div>
<input type="checkbox" checked={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} /> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
Allow saves <Checkbox checked={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} label="Commercially eligible" />
</label> </div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} />
Commercially eligible
</label>
</div> </div>
{form.type === 'editorial' ? ( {form.type === 'editorial' ? (
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<Field label="Editorial Owner" help="Choose whether this editorial lives under the current curator, another staff account, or the system identity."> <Field label="Editorial Owner" help="Choose whether this editorial lives under the current curator, another staff account, or the system identity.">
<select value={form.editorial_owner_mode} onChange={(event) => updateForm('editorial_owner_mode', 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"> <NovaSelect value={form.editorial_owner_mode} onChange={(val) => updateForm('editorial_owner_mode', val)} searchable={false} options={[{ value: 'creator', label: 'Current curator' }, { value: 'staff_account', label: 'Staff account' }, { value: 'system', label: 'System editorial identity' }]} />
<option value="creator">Current curator</option>
<option value="staff_account">Staff account</option>
<option value="system">System editorial identity</option>
</select>
</Field> </Field>
{form.editorial_owner_mode === 'staff_account' ? ( {form.editorial_owner_mode === 'staff_account' ? (
<Field label="Staff Account Username" help="Must be an admin or moderator username."> <Field label="Staff Account Username" help="Must be an admin or moderator username.">
@@ -2156,13 +2100,7 @@ export default function CollectionManage() {
<input type="text" value={form.campaign_label} onChange={(event) => updateForm('campaign_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} /> <input type="text" value={form.campaign_label} onChange={(event) => updateForm('campaign_label', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={120} />
</Field> </Field>
<Field label="Spotlight Style" help="Controls the visual frame for the public campaign banner."> <Field label="Spotlight Style" help="Controls the visual frame for the public campaign banner.">
<select value={form.spotlight_style} onChange={(event) => updateForm('spotlight_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"> <NovaSelect value={form.spotlight_style} onChange={(val) => updateForm('spotlight_style', val)} searchable={false} options={[{ value: 'default', label: 'Default' }, { value: 'editorial', label: 'Editorial' }, { value: 'seasonal', label: 'Seasonal' }, { value: 'challenge', label: 'Challenge' }, { value: 'community', label: 'Community' }]} />
<option value="default">Default</option>
<option value="editorial">Editorial</option>
<option value="seasonal">Seasonal</option>
<option value="challenge">Challenge</option>
<option value="community">Community</option>
</select>
</Field> </Field>
<Field label="Banner Text" help="Short line shown in the collection spotlight banner."> <Field label="Banner Text" help="Short line shown in the collection spotlight banner.">
<input type="text" value={form.banner_text} onChange={(event) => updateForm('banner_text', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={200} /> <input type="text" value={form.banner_text} onChange={(event) => updateForm('banner_text', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={200} />
@@ -2195,14 +2133,7 @@ export default function CollectionManage() {
<AdvancedSection title="Scheduling & Lifecycle" icon="fa-calendar-days" defaultOpen={mode === 'edit'}> <AdvancedSection title="Scheduling & Lifecycle" icon="fa-calendar-days" defaultOpen={mode === 'edit'}>
<Field label="Lifecycle State" help="Draft keeps it hidden. Published makes it live. Archived retires it from active surfaces."> <Field label="Lifecycle State" help="Draft keeps it hidden. Published makes it live. Archived retires it from active surfaces.">
<select value={form.lifecycle_state} onChange={(event) => updateForm('lifecycle_state', 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"> <NovaSelect value={form.lifecycle_state} onChange={(val) => updateForm('lifecycle_state', val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'published', label: 'Published' }, { value: 'featured', label: 'Featured' }, { value: 'archived', label: 'Archived' }, { value: 'expired', label: 'Expired' }]} />
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
<option value="featured">Featured</option>
<option value="archived">Archived</option>
<option value="expired">Expired</option>
</select>
</Field> </Field>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -2244,10 +2175,9 @@ export default function CollectionManage() {
<Field label="Brand Safe Status"> <Field label="Brand Safe Status">
<input type="text" value={form.brand_safe_status} onChange={(event) => updateForm('brand_safe_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} /> <input type="text" value={form.brand_safe_status} onChange={(event) => updateForm('brand_safe_status', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" maxLength={40} />
</Field> </Field>
<label className="flex items-center gap-3 self-end rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <div className="flex items-center gap-3 self-end rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<input type="checkbox" checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} /> <Checkbox checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} label="Analytics enabled" />
Analytics enabled </div>
</label>
</div> </div>
<Field label="Editorial Notes" help="Internal editorial context for campaign planning, curation rationale, and staff handoff."> <Field label="Editorial Notes" help="Internal editorial context for campaign planning, curation rationale, and staff handoff.">
@@ -2800,19 +2730,9 @@ export default function CollectionManage() {
</div> </div>
<form onSubmit={handleInviteMember} className="mt-5 flex flex-col gap-3 xl:flex-row xl:items-start"> <form onSubmit={handleInviteMember} className="mt-5 flex flex-col gap-3 xl:flex-row xl:items-start">
<input value={inviteUsername} onChange={(event) => setInviteUsername(event.target.value)} placeholder="username" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" /> <input value={inviteUsername} onChange={(event) => setInviteUsername(event.target.value)} placeholder="username" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"> <NovaSelect value={inviteRole} onChange={(val) => setInviteRole(val)} searchable={false} options={[{ value: 'editor', label: 'Editor' }, { value: 'contributor', label: 'Contributor' }, { value: 'viewer', label: 'Viewer' }]} />
<option value="editor">Editor</option>
<option value="contributor">Contributor</option>
<option value="viewer">Viewer</option>
</select>
<div className="flex min-w-0 flex-1 flex-col gap-2 md:min-w-[240px]"> <div className="flex min-w-0 flex-1 flex-col gap-2 md:min-w-[240px]">
<select value={inviteExpiryMode} onChange={(event) => setInviteExpiryMode(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"> <NovaSelect value={inviteExpiryMode} onChange={(val) => setInviteExpiryMode(val)} searchable={false} options={[{ value: 'default', label: `Default (${inviteExpiryDays} days)` }, ...inviteExpiryOptions.map((days) => ({ value: String(days), label: `${days} day${days === 1 ? '' : 's'}` })), { value: 'custom', label: 'Custom date' }]} />
<option value="default">Default ({inviteExpiryDays} days)</option>
{inviteExpiryOptions.map((days) => (
<option key={days} value={String(days)}>{days} day{days === 1 ? '' : 's'}</option>
))}
<option value="custom">Custom date</option>
</select>
{inviteExpiryMode === 'custom' ? ( {inviteExpiryMode === 'custom' ? (
<input type="datetime-local" value={inviteCustomExpiry} onChange={(event) => setInviteCustomExpiry(event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" /> <input type="datetime-local" value={inviteCustomExpiry} onChange={(event) => setInviteCustomExpiry(event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
) : ( ) : (
@@ -3116,16 +3036,12 @@ export default function CollectionManage() {
<div className="mt-6 flex flex-col gap-3 xl:flex-row xl:items-end"> <div className="mt-6 flex flex-col gap-3 xl:flex-row xl:items-end">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Add manageable collection</label> <label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Add manageable collection</label>
<select <NovaSelect
value={selectedLinkedCollectionId} value={selectedLinkedCollectionId}
onChange={(event) => setSelectedLinkedCollectionId(event.target.value)} onChange={(value) => setSelectedLinkedCollectionId(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" options={linkedCollectionOptions.map((item) => ({ value: String(item.id), label: item.title }))}
> placeholder="Select a collection"
<option value="">Select a collection</option> />
{linkedCollectionOptions.map((item) => (
<option key={item.id} value={item.id}>{item.title}</option>
))}
</select>
</div> </div>
<button <button
type="button" type="button"
@@ -3186,37 +3102,33 @@ export default function CollectionManage() {
<div className="mt-6 grid gap-3 xl:grid-cols-[180px_minmax(0,1fr)_240px_auto] xl:items-end"> <div className="mt-6 grid gap-3 xl:grid-cols-[180px_minmax(0,1fr)_240px_auto] xl:items-end">
<div> <div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Entity type</label> <label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Entity type</label>
<select <NovaSelect
value={selectedEntityType} value={selectedEntityType}
onChange={(event) => { onChange={(value) => {
const nextType = event.target.value const nextOptions = Array.isArray(entityLinkOptions[value]) ? entityLinkOptions[value] : []
const nextOptions = Array.isArray(entityLinkOptions[nextType]) ? entityLinkOptions[nextType] : [] setSelectedEntityType(value)
setSelectedEntityType(nextType) setSelectedEntityId(String(nextOptions[0]?.id || ''))
setSelectedEntityId(nextOptions[0]?.id || '')
}} }}
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" options={[
> { value: 'creator', label: 'Creator' },
<option value="creator">Creator</option> { value: 'artwork', label: 'Artwork' },
<option value="artwork">Artwork</option> { value: 'story', label: 'Story' },
<option value="story">Story</option> { value: 'category', label: 'Category' },
<option value="category">Category</option> { value: 'campaign', label: 'Campaign' },
<option value="campaign">Campaign</option> { value: 'event', label: 'Event' },
<option value="event">Event</option> { value: 'tag', label: 'Tag or Theme' },
<option value="tag">Tag or Theme</option> ]}
</select> searchable={false}
/>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Choose entity</label> <label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Choose entity</label>
<select <NovaSelect
value={selectedEntityId} value={selectedEntityId}
onChange={(event) => setSelectedEntityId(event.target.value)} onChange={(value) => setSelectedEntityId(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" options={(entityLinkOptions[selectedEntityType] || []).map((item) => ({ value: String(item.id), label: item.label }))}
> placeholder="Select an entity"
<option value="">Select an entity</option> />
{(entityLinkOptions[selectedEntityType] || []).map((item) => (
<option key={`${selectedEntityType}-${item.id}`} value={item.id}>{item.label}</option>
))}
</select>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Relationship label</label> <label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Relationship label</label>
@@ -3319,31 +3231,32 @@ export default function CollectionManage() {
<div className="mt-6 grid gap-5 xl:grid-cols-2"> <div className="mt-6 grid gap-5 xl:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5"> <div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
<Field label="Moderation Status"> <Field label="Moderation Status">
<select <NovaSelect
value={collectionState?.moderation_status || 'active'} value={collectionState?.moderation_status || 'active'}
onChange={(event) => handleModerationStatusChange(event.target.value)} onChange={(value) => handleModerationStatusChange(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" options={[
> { value: 'active', label: 'Active' },
<option value="active">Active</option> { value: 'under_review', label: 'Under review' },
<option value="under_review">Under review</option> { value: 'restricted', label: 'Restricted' },
<option value="restricted">Restricted</option> { value: 'hidden', label: 'Hidden' },
<option value="hidden">Hidden</option> ]}
</select> searchable={false}
/>
</Field> </Field>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <div className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow comments</span> <span>Allow comments</span>
<input type="checkbox" checked={form.allow_comments} onChange={(event) => handleModerationToggle('allow_comments', event.target.checked)} /> <Checkbox checked={form.allow_comments} onChange={(event) => handleModerationToggle('allow_comments', event.target.checked)} />
</label> </div>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <div className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow submissions</span> <span>Allow submissions</span>
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => handleModerationToggle('allow_submissions', event.target.checked)} /> <Checkbox checked={form.allow_submissions} onChange={(event) => handleModerationToggle('allow_submissions', event.target.checked)} />
</label> </div>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"> <div className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">
<span>Allow saves</span> <span>Allow saves</span>
<input type="checkbox" checked={form.allow_saves} onChange={(event) => handleModerationToggle('allow_saves', event.target.checked)} /> <Checkbox checked={form.allow_saves} onChange={(event) => handleModerationToggle('allow_saves', event.target.checked)} />
</label> </div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react'
import ArtworkGallery from '../../components/artwork/ArtworkGallery' import ArtworkGallery from '../../components/artwork/ArtworkGallery'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge' import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
import NovaSelect from '../../components/ui/NovaSelect'
import SeoHead from '../../components/seo/SeoHead' import SeoHead from '../../components/seo/SeoHead'
import CommentForm from '../../components/social/CommentForm' import CommentForm from '../../components/social/CommentForm'
import CommentList from '../../components/social/CommentList' import CommentList from '../../components/social/CommentList'
@@ -985,11 +986,7 @@ export default function CollectionShow() {
<PageSection icon="fa-paper-plane" eyebrow="Submissions" title="Submit to this collection"> <PageSection icon="fa-paper-plane" eyebrow="Submissions" title="Submit to this collection">
{canSubmit && submissionArtworkOptions?.length ? ( {canSubmit && submissionArtworkOptions?.length ? (
<div className="space-y-3"> <div className="space-y-3">
<select value={selectedArtworkId} onChange={(event) => setSelectedArtworkId(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"> <NovaSelect value={String(selectedArtworkId || '')} onChange={(val) => setSelectedArtworkId(val)} placeholder="Select artwork" options={submissionArtworkOptions.map((a) => ({ value: String(a.id), label: a.title }))} />
{submissionArtworkOptions.map((artwork) => (
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
))}
</select>
<button type="button" onClick={handleSubmitArtwork} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-paper-plane fa-fw" />Submit artwork</button> <button type="button" onClick={handleSubmitArtwork} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-paper-plane fa-fw" />Submit artwork</button>
</div> </div>
) : ( ) : (

View File

@@ -2,6 +2,8 @@ import React from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import ShareToast from '../../components/ui/ShareToast' import ShareToast from '../../components/ui/ShareToast'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -618,9 +620,7 @@ export default function CollectionStaffProgramming() {
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-2"> <div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Collection"> <Field label="Collection">
<select value={assignmentForm.collection_id} onChange={(event) => setAssignmentForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"> <NovaSelect value={String(assignmentForm.collection_id || '')} onChange={(val) => setAssignmentForm((current) => ({ ...current, collection_id: val }))} options={collectionOptions.map((o) => ({ value: String(o.id), label: o.title }))} />
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</Field> </Field>
<Field label="Program Key" help="Use stable internal names like discover-spring or homepage-hero."> <Field label="Program Key" help="Use stable internal names like discover-spring or homepage-hero.">
<input list="program-key-options" value={assignmentForm.program_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /> <input list="program-key-options" value={assignmentForm.program_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
@@ -715,9 +715,7 @@ export default function CollectionStaffProgramming() {
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"> <div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Target Collection" help="Leave a selection in place to inspect one collection. Change it any time before running a diagnostic."> <Field label="Target Collection" help="Leave a selection in place to inspect one collection. Change it any time before running a diagnostic.">
<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"> <NovaSelect value={String(selectedCollectionId || '')} onChange={(val) => setSelectedCollectionId(val)} options={collectionOptions.map((o) => ({ value: String(o.id), label: o.title }))} />
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</Field> </Field>
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button> <button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button>
@@ -824,10 +822,9 @@ export default function CollectionStaffProgramming() {
) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3">Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.</div>} ) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3">Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.</div>}
</div> </div>
<label className="mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200"> <div className="mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200">
<input type="checkbox" checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} className="h-4 w-4 rounded border-white/20 bg-white/[0.04] text-sky-400 focus:ring-sky-300/40" /> <Checkbox checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} label="Placement eligible override" />
Placement eligible override </div>
</label>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300"> <div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -401,13 +403,13 @@ export default function CollectionStaffSurfaces() {
<div className="mt-6 grid gap-4 md:grid-cols-2"> <div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field> <Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field>
<Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field> <Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field>
<Field label="Mode"><select value={definitionForm.mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="automatic">Automatic</option><option value="hybrid">Hybrid</option></select></Field> <Field label="Mode"><NovaSelect value={definitionForm.mode} onChange={(val) => setDefinitionForm((current) => ({ ...current, mode: val }))} searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'automatic', label: 'Automatic' }, { value: 'hybrid', label: 'Hybrid' }]} /></Field>
<Field label="Ranking"><select value={definitionForm.ranking_mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, ranking_mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="ranking_score">Ranking score</option><option value="recent_activity">Recent activity</option><option value="quality_score">Quality score</option></select></Field> <Field label="Ranking"><NovaSelect value={definitionForm.ranking_mode} onChange={(val) => setDefinitionForm((current) => ({ ...current, ranking_mode: val }))} searchable={false} options={[{ value: 'ranking_score', label: 'Ranking score' }, { value: 'recent_activity', label: 'Recent activity' }, { value: 'quality_score', label: 'Quality score' }]} /></Field>
<Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field> <Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} />Active</label> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active" /></div>
</div> </div>
<Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field> <Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field>
<Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field> <Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field>
@@ -423,14 +425,14 @@ export default function CollectionStaffSurfaces() {
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2> <h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-2"> <div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Surface"><select value={placementForm.surface_key} onChange={(event) => setPlacementForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field> <Field label="Surface"><NovaSelect value={placementForm.surface_key} onChange={(val) => setPlacementForm((current) => ({ ...current, surface_key: val }))} options={surfaceKeyOptions.map((o) => ({ value: o, label: o }))} /></Field>
<Field label="Collection"><select value={placementForm.collection_id} onChange={(event) => setPlacementForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}</select></Field> <Field label="Collection"><NovaSelect value={String(placementForm.collection_id || '')} onChange={(val) => setPlacementForm((current) => ({ ...current, collection_id: val }))} options={collectionOptions.map((o) => ({ value: String(o.id), label: o.title }))} /></Field>
<Field label="Placement Type"><select value={placementForm.placement_type} onChange={(event) => setPlacementForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="scheduled_override">Scheduled override</option></select></Field> <Field label="Placement Type"><NovaSelect value={placementForm.placement_type} onChange={(val) => setPlacementForm((current) => ({ ...current, placement_type: val }))} searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'campaign', label: 'Campaign' }, { value: 'scheduled_override', label: 'Scheduled override' }]} /></Field>
<Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field> <Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active placement" /></div>
</div> </div>
<Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field> <Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
<div className="mt-5 flex flex-wrap items-center gap-3"> <div className="mt-5 flex flex-wrap items-center gap-3">
@@ -459,7 +461,7 @@ export default function CollectionStaffSurfaces() {
const checked = batchForm.collection_ids.includes(option.id) const checked = batchForm.collection_ids.includes(option.id)
return ( return (
<label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}> <label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}>
<input type="checkbox" checked={checked} onChange={() => toggleBatchCollection(option.id)} className="mt-1" /> <Checkbox checked={checked} onChange={() => toggleBatchCollection(option.id)} />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">{option.title}</span> <span className="block truncate text-sm font-semibold text-white">{option.title}</span>
<span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span> <span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span>
@@ -487,10 +489,10 @@ export default function CollectionStaffSurfaces() {
<p className="text-sm font-semibold text-white">Optional placement plan</p> <p className="text-sm font-semibold text-white">Optional placement plan</p>
<p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p> <p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Surface"><select value={batchForm.surface_key} onChange={(event) => setBatchForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="">No placement</option>{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field> <Field label="Surface"><NovaSelect value={batchForm.surface_key} onChange={(val) => setBatchForm((current) => ({ ...current, surface_key: val }))} placeholder="No placement" options={surfaceKeyOptions.map((o) => ({ value: o, label: o }))} /></Field>
<Field label="Placement Type"><select value={batchForm.placement_type} onChange={(event) => setBatchForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="campaign">Campaign</option><option value="manual">Manual</option><option value="scheduled_override">Scheduled override</option></select></Field> <Field label="Placement Type"><NovaSelect value={batchForm.placement_type} onChange={(val) => setBatchForm((current) => ({ ...current, placement_type: val }))} searchable={false} options={[{ value: 'campaign', label: 'Campaign' }, { value: 'manual', label: 'Manual' }, { value: 'scheduled_override', label: 'Scheduled override' }]} /></Field>
<Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active placement" /></div>
<Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field> <Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
</div> </div>

View File

@@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -565,11 +567,9 @@ export default function FeaturedArtworksAdmin() {
<Field label="Active" help="Inactive rows stay visible in admin but cannot win the homepage hero."> <Field label="Active" help="Inactive rows stay visible in admin but cannot win the homepage hero.">
<label className="flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100"> <label className="flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100">
<input <Checkbox
type="checkbox"
checked={Boolean(form.is_active)} checked={Boolean(form.is_active)}
onChange={(event) => setForm((current) => ({ ...current, is_active: event.target.checked }))} onChange={(event) => setForm((current) => ({ ...current, is_active: event.target.checked }))}
className="h-4 w-4 rounded border-white/20 bg-transparent text-sky-400 focus:ring-sky-300/30"
/> />
<span>{form.is_active ? 'Active on save' : 'Inactive on save'}</span> <span>{form.is_active ? 'Active on save' : 'Inactive on save'}</span>
</label> </label>
@@ -625,22 +625,9 @@ export default function FeaturedArtworksAdmin() {
placeholder="Filter by title, artist, or artwork ID" placeholder="Filter by title, artist, or artwork ID"
className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/> />
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={filter} onChange={(val) => setFilter(val)} searchable={false} options={[{ value: 'all', label: 'All rows' }, { value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, { value: 'expired', label: 'Expired' }, { value: 'winner', label: 'Winner' }, { value: 'eligible', label: 'Eligible' }, { value: 'ineligible', label: 'Not eligible' }]} />
<option value="all">All rows</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="expired">Expired</option>
<option value="winner">Winner</option>
<option value="eligible">Eligible</option>
<option value="ineligible">Not eligible</option>
</select>
<div className="grid grid-cols-[1fr_auto] gap-3"> <div className="grid grid-cols-[1fr_auto] gap-3">
<select value={sortKey} onChange={(event) => setSortKey(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"> <NovaSelect value={sortKey} onChange={(val) => setSortKey(val)} searchable={false} options={[{ value: 'priority', label: 'Priority' }, { value: 'featured_at', label: 'Featured Since' }, { value: 'expires_at', label: 'Expires' }, { value: 'score_30d', label: 'Medal Score (30d)' }]} />
<option value="priority">Priority</option>
<option value="featured_at">Featured Since</option>
<option value="expires_at">Expires</option>
<option value="score_30d">Medal Score (30d)</option>
</select>
<button type="button" onClick={() => setSortDirection((current) => current === 'desc' ? 'asc' : 'desc')} className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5"> <button type="button" onClick={() => setSortDirection((current) => current === 'desc' ? 'asc' : 'desc')} className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
{sortDirection === 'desc' ? 'Desc' : 'Asc'} {sortDirection === 'desc' ? 'Desc' : 'Asc'}
</button> </button>

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react' import { Head, Link, usePage } from '@inertiajs/react'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview' import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -396,16 +398,15 @@ export default function NovaCardsAdminIndex() {
</div> </div>
) : null} ) : null}
<div className="mt-3 max-w-xs"> <div className="mt-3 max-w-xs">
<label className="text-sm text-amber-50"> <div className="text-sm text-amber-50">
<span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Disposition</span> <span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Disposition</span>
<select <NovaSelect
value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])} value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])}
onChange={(event) => setReportDispositions((current) => ({ ...current, [report.id]: event.target.value }))} onChange={(value) => setReportDispositions((current) => ({ ...current, [report.id]: value }))}
className="w-full rounded-2xl border border-amber-200/20 bg-[#0d1726] px-4 py-3 text-white" options={dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => <option key={`${report.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)} />
</select> </div>
</label>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{(report.target.moderation_target.available_actions || []).map((actionItem) => ( {(report.target.moderation_target.available_actions || []).map((actionItem) => (
@@ -497,40 +498,34 @@ export default function NovaCardsAdminIndex() {
</div> </div>
<p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-300">{card.quote_text}</p> <p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-300">{card.quote_text}</p>
<div className="mt-4 grid gap-3 md:grid-cols-3"> <div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="text-sm text-slate-300"> <div className="text-sm text-slate-300">
<span className="mb-2 block">Status</span> <span className="mb-2 block">Status</span>
<select value={card.status} onChange={(event) => updateCard(card.id, { status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={card.status} onChange={(val) => updateCard(card.id, { status: val })} searchable={false} options={['draft', 'processing', 'published', 'hidden', 'rejected'].map((s) => ({ value: s, label: s }))} />
{['draft', 'processing', 'published', 'hidden', 'rejected'].map((item) => <option key={`${card.id}-${item}`} value={item}>{item}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Moderation</span> <span className="mb-2 block">Moderation</span>
<select value={card.moderation_status} onChange={(event) => updateCard(card.id, { moderation_status: event.target.value, disposition: preferredDisposition(event.target.value, cardDispositions[card.id]) })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={card.moderation_status} onChange={(val) => updateCard(card.id, { moderation_status: val, disposition: preferredDisposition(val, cardDispositions[card.id]) })} searchable={false} options={['pending', 'approved', 'flagged', 'rejected'].map((s) => ({ value: s, label: s }))} />
{['pending', 'approved', 'flagged', 'rejected'].map((item) => <option key={`${card.id}-mod-${item}`} value={item}>{item}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Disposition</span> <span className="mb-2 block">Disposition</span>
<select <NovaSelect
value={preferredDisposition(card.moderation_status, cardDispositions[card.id])} value={preferredDisposition(card.moderation_status, cardDispositions[card.id])}
onChange={(event) => { onChange={(val) => {
const disposition = event.target.value setCardDispositions((current) => ({ ...current, [card.id]: val }))
setCardDispositions((current) => ({ ...current, [card.id]: disposition })) updateCard(card.id, { moderation_status: card.moderation_status, disposition: val })
updateCard(card.id, { moderation_status: card.moderation_status, disposition })
}} }}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" searchable={false}
> options={dispositionOptionsForStatus(card.moderation_status)}
{dispositionOptionsForStatus(card.moderation_status).map((option) => <option key={`${card.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)} />
</select> </div>
</label> <div 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">
<label 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>Featured</span> <span>Featured</span>
<input type="checkbox" checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} className="h-4 w-4" /> <Checkbox checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} />
</label> </div>
<label 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 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>Allow remix</span> <span>Allow remix</span>
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} className="h-4 w-4" /> <Checkbox checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} />
</label> </div>
</div> </div>
<div className="mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400"> <div className="mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.likes_count || 0} likes</div> <div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.likes_count || 0} likes</div>
@@ -596,10 +591,10 @@ export default function NovaCardsAdminIndex() {
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.featured_cards_count || 0} featured</div> <div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.featured_cards_count || 0} featured</div>
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.total_views_count || 0} views</div> <div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.total_views_count || 0} views</div>
</div> </div>
<label className="mt-3 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 className="mt-3 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>Feature on editorial page</span> <span>Feature on editorial page</span>
<input type="checkbox" checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} className="h-4 w-4" /> <Checkbox checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} />
</label> </div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react' import { Head, Link, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -101,13 +103,10 @@ export default function NovaCardsAssetPackAdmin() {
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Pack name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Pack name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" /> <textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300"> <div className="text-sm text-slate-300">
<span className="mb-2 block">Type</span> <span className="mb-2 block">Type</span>
<select value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.type} onChange={(val) => setForm((current) => ({ ...current, type: val }))} searchable={false} options={[{ value: 'asset', label: 'asset' }, { value: 'template', label: 'template' }]} />
<option value="asset">asset</option> </div>
<option value="template">template</option>
</select>
</label>
<input value={form.preview_image} onChange={(event) => setForm((current) => ({ ...current, preview_image: event.target.value }))} placeholder="Preview image URL" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.preview_image} onChange={(event) => setForm((current) => ({ ...current, preview_image: event.target.value }))} placeholder="Preview image URL" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={JSON.stringify(form.manifest_json || {}, null, 2)} onChange={(event) => { <textarea value={JSON.stringify(form.manifest_json || {}, null, 2)} onChange={(event) => {
try { try {
@@ -118,8 +117,8 @@ export default function NovaCardsAssetPackAdmin() {
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" /> }} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200"> <div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label> <Checkbox checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} label="Active" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label> <Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
</div> </div>
<button type="button" onClick={savePack} className="mt-5 w-full 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">{selectedId ? 'Update pack' : 'Create pack'}</button> <button type="button" onClick={savePack} className="mt-5 w-full 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">{selectedId ? 'Update pack' : 'Create pack'}</button>
</section> </section>

View File

@@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react' import { Head, Link, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -105,19 +107,14 @@ export default function NovaCardsChallengeAdmin() {
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" /> <textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<textarea value={form.prompt} onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))} placeholder="Prompt" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" /> <textarea value={form.prompt} onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))} placeholder="Prompt" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300"> <div className="text-sm text-slate-300">
<span className="mb-2 block">Status</span> <span className="mb-2 block">Status</span>
<select value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.status} onChange={(val) => setForm((current) => ({ ...current, status: val }))} searchable={false} options={['draft', 'active', 'completed', 'archived'].map((s) => ({ value: s, label: s }))} />
{['draft', 'active', 'completed', 'archived'].map((status) => <option key={status} value={status}>{status}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Winner card</span> <span className="mb-2 block">Winner card</span>
<select value={form.winner_card_id} onChange={(event) => setForm((current) => ({ ...current, winner_card_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={String(form.winner_card_id || '')} onChange={(val) => setForm((current) => ({ ...current, winner_card_id: val }))} placeholder="No winner" options={cards.map((c) => ({ value: String(c.id), label: c.title }))} />
<option value="">No winner</option> </div>
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
</select>
</label>
<label className="text-sm text-slate-300"> <label className="text-sm text-slate-300">
<span className="mb-2 block">Starts at</span> <span className="mb-2 block">Starts at</span>
<input type="datetime-local" value={form.starts_at} onChange={(event) => setForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input type="datetime-local" value={form.starts_at} onChange={(event) => setForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
@@ -135,8 +132,8 @@ export default function NovaCardsChallengeAdmin() {
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" /> }} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200"> <div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label> <Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured</label> <Checkbox checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} label="Featured" />
</div> </div>
<button type="button" onClick={saveChallenge} className="mt-5 w-full 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">{selectedId ? 'Update challenge' : 'Create challenge'}</button> <button type="button" onClick={saveChallenge} className="mt-5 w-full 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">{selectedId ? 'Update challenge' : 'Create challenge'}</button>
</section> </section>

View File

@@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react' import { Head, Link, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -133,27 +135,22 @@ export default function NovaCardsCollectionAdmin() {
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]"> <div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collection editor</div> <div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collection editor</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label className="text-sm text-slate-300"> <div className="text-sm text-slate-300">
<span className="mb-2 block">Owner</span> <span className="mb-2 block">Owner</span>
<select value={form.user_id} onChange={(event) => setForm((current) => ({ ...current, user_id: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.user_id} onChange={(val) => setForm((current) => ({ ...current, user_id: Number(val) }))} options={admins.map((a) => ({ value: a.id, label: a.name || a.username }))} />
{admins.map((admin) => <option key={admin.id} value={admin.id}>{admin.name || admin.username}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Visibility</span> <span className="mb-2 block">Visibility</span>
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} searchable={false} options={[{ value: 'public', label: 'public' }, { value: 'private', label: 'private' }]} />
<option value="public">public</option> </div>
<option value="private">private</option>
</select>
</label>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Collection name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Collection name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" /> <textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200"> <div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official collection</label> <Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official collection" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured collection</label> <Checkbox checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} label="Featured collection" />
</div> </div>
{selected?.public_url ? <a href={selected.public_url} className="text-sky-100 transition hover:text-white" target="_blank" rel="noreferrer">Open public page</a> : null} {selected?.public_url ? <a href={selected.public_url} className="text-sky-100 transition hover:text-white" target="_blank" rel="noreferrer">Open public page</a> : null}
</div> </div>
@@ -167,10 +164,7 @@ export default function NovaCardsCollectionAdmin() {
) : ( ) : (
<> <>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<select value={cardId} onChange={(event) => setCardId(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={String(cardId || '')} onChange={(val) => setCardId(val)} placeholder="Select a card" options={cards.map((c) => ({ value: String(c.id), label: c.title }))} />
<option value="">Select a card</option>
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
</select>
<input value={cardNote} onChange={(event) => setCardNote(event.target.value)} placeholder="Optional curator note" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={cardNote} onChange={(event) => setCardNote(event.target.value)} placeholder="Optional curator note" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<button type="button" onClick={attachCard} 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">Add</button> <button type="button" onClick={attachCard} 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">Add</button>
</div> </div>

View File

@@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react' import { Head, Link, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -152,36 +154,26 @@ export default function NovaCardsTemplateAdmin() {
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Template name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Template name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" /> <input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" /> <textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300"> <div className="text-sm text-slate-300">
<span className="mb-2 block">Font preset</span> <span className="mb-2 block">Font preset</span>
<select value={form.config_json?.font_preset || 'modern-sans'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.config_json?.font_preset || 'modern-sans'} onChange={(val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: val } }))} options={fonts.map((f) => ({ value: f.key, label: f.label }))} searchable={false} />
{fonts.map((font) => <option key={font.key} value={font.key}>{font.label}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Gradient preset</span> <span className="mb-2 block">Gradient preset</span>
<select value={form.config_json?.gradient_preset || 'midnight-nova'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.config_json?.gradient_preset || 'midnight-nova'} onChange={(val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: val } }))} options={gradients.map((g) => ({ value: g.key, label: g.label }))} searchable={false} />
{gradients.map((gradient) => <option key={gradient.key} value={gradient.key}>{gradient.label}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Layout preset</span> <span className="mb-2 block">Layout preset</span>
<select value={form.config_json?.layout || 'quote_heavy'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.config_json?.layout || 'quote_heavy'} onChange={(val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: val } }))} options={['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((v) => ({ value: v, label: v }))} searchable={false} />
{['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((value) => <option key={value} value={value}>{value}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Text alignment</span> <span className="mb-2 block">Text alignment</span>
<select value={form.config_json?.text_align || 'center'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.config_json?.text_align || 'center'} onChange={(val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: val } }))} options={['left', 'center', 'right'].map((v) => ({ value: v, label: v }))} searchable={false} />
{['left', 'center', 'right'].map((value) => <option key={value} value={value}>{value}</option>)} </div>
</select> <div className="text-sm text-slate-300">
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Overlay style</span> <span className="mb-2 block">Overlay style</span>
<select value={form.config_json?.overlay_style || 'dark-soft'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"> <NovaSelect value={form.config_json?.overlay_style || 'dark-soft'} onChange={(val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: val } }))} options={['none', 'dark-soft', 'dark-strong', 'light-soft'].map((v) => ({ value: v, label: v }))} searchable={false} />
{['none', 'dark-soft', 'dark-strong', 'light-soft'].map((value) => <option key={value} value={value}>{value}</option>)} </div>
</select>
</label>
<label className="text-sm text-slate-300"> <label className="text-sm text-slate-300">
<span className="mb-2 block">Text color</span> <span className="mb-2 block">Text color</span>
<input type="color" value={form.config_json?.text_color || '#ffffff'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } }))} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" /> <input type="color" value={form.config_json?.text_color || '#ffffff'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } }))} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
@@ -191,16 +183,15 @@ export default function NovaCardsTemplateAdmin() {
<div className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Supported formats</div> <div className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Supported formats</div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{formats.map((format) => ( {formats.map((format) => (
<label key={format.key} 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"> <div key={format.key} 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">
<input type="checkbox" checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} className="h-4 w-4" /> <Checkbox checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} label={format.label} />
{format.label} </div>
</label>
))} ))}
</div> </div>
</div> </div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200"> <div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label> <Checkbox checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} label="Active" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label> <Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
</div> </div>
<button type="button" onClick={saveTemplate} className="mt-5 w-full 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">{selectedId ? 'Update template' : 'Create template'}</button> <button type="button" onClick={saveTemplate} className="mt-5 w-full 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">{selectedId ? 'Update template' : 'Create template'}</button>
</section> </section>

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard' import CollectionCard from '../../components/profile/collections/CollectionCard'
import SeoHead from '../../components/seo/SeoHead' import SeoHead from '../../components/seo/SeoHead'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -475,9 +476,7 @@ export default function SavedCollections() {
</div> </div>
{savedLists.length ? ( {savedLists.length ? (
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3"> <div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
<select value={selectedLists[collection.id] || savedLists[0]?.id || ''} onChange={(event) => setSelectedLists((current) => ({ ...current, [collection.id]: event.target.value }))} className="min-w-[180px] rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-2.5 text-sm text-white outline-none"> <NovaSelect value={String(selectedLists[collection.id] || savedLists[0]?.id || '')} onChange={(val) => setSelectedLists((current) => ({ ...current, [collection.id]: val }))} options={savedLists.map((l) => ({ value: String(l.id), label: l.title }))} />
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
</select>
<button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button> <button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button>
</div> </div>
) : null} ) : null}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead' import SeoHead from '../../components/seo/SeoHead'
import useWebShare from '../../hooks/useWebShare' import useWebShare from '../../hooks/useWebShare'
import NovaSelect from '../../components/ui/NovaSelect'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim().toLowerCase() return String(value || '').trim().toLowerCase()
@@ -1076,14 +1077,10 @@ export default function GroupShow() {
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} placeholder="Filter artworks" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /> <input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} placeholder="Filter artworks" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label> </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">Sort</span> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Sort</span>
<select value={artworkSort} onChange={(event) => setArtworkSort(event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"> <NovaSelect value={artworkSort} onChange={(val) => setArtworkSort(val)} searchable={false} options={[{ value: 'latest', label: 'Latest first' }, { value: 'oldest', label: 'Oldest first' }, { value: 'title', label: 'Title A-Z' }]} />
<option value="latest">Latest first</option> </div>
<option value="oldest">Oldest first</option>
<option value="title">Title A-Z</option>
</select>
</label>
</div> </div>
</div> </div>
<ArtworkGrid artworks={filteredArtworks} emptyLabel="No published artworks match the current filter." /> <ArtworkGrid artworks={filteredArtworks} emptyLabel="No published artworks match the current filter." />

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react' import { Head, Link, router, usePage } from '@inertiajs/react'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() { function getCsrfToken() {
if (typeof document === 'undefined') return '' if (typeof document === 'undefined') return ''
@@ -188,18 +189,16 @@ export default function AiBiographyAdmin() {
</label> </label>
{['status', 'scope', 'tier', 'visibility', 'review'].map((key) => ( {['status', 'scope', 'tier', 'visibility', 'review'].map((key) => (
<label key={key} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300"> <div key={key} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{key.replace('_', ' ')}</div> <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{key.replace('_', ' ')}</div>
<select <NovaSelect
value={filters[key] || 'all'} value={filters[key] || 'all'}
onChange={(event) => updateFilter(key, event.target.value)} onChange={(value) => updateFilter(key, value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none" className="mt-2"
> options={(filterOptions[key] || []).map((option) => ({ value: option.value, label: option.label }))}
{(filterOptions[key] || []).map((option) => ( searchable={false}
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option> />
))} </div>
</select>
</label>
))} ))}
<div className="lg:col-span-full flex flex-wrap gap-3"> <div className="lg:col-span-full flex flex-wrap gap-3">

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Head, usePage } from '@inertiajs/react' import { Head, usePage } from '@inertiajs/react'
import ArtworkViewer from '../../components/viewer/ArtworkViewer' import ArtworkViewer from '../../components/viewer/ArtworkViewer'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) { function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, { return fetch(url, {
@@ -140,31 +141,27 @@ export default function ArtworkMaturityQueue() {
</div> </div>
<div className="mt-5 grid gap-3 md:grid-cols-2"> <div className="mt-5 grid gap-3 md:grid-cols-2">
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300"> <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI action hint</div> <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI action hint</div>
<select <NovaSelect
value={aiAction} value={aiAction}
onChange={(event) => load(status, event.target.value, aiStatus)} onChange={(value) => load(status, value, aiStatus)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none" className="mt-2"
> options={(filterOptions.aiAction || []).map((option) => ({ value: option.value, label: option.label }))}
{(filterOptions.aiAction || []).map((option) => ( searchable={false}
<option key={option.value} value={option.value}>{option.label}</option> />
))} </div>
</select>
</label>
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300"> <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI processing status</div> <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI processing status</div>
<select <NovaSelect
value={aiStatus} value={aiStatus}
onChange={(event) => load(status, aiAction, event.target.value)} onChange={(value) => load(status, aiAction, value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none" className="mt-2"
> options={(filterOptions.aiStatus || []).map((option) => ({ value: option.value, label: option.label }))}
{(filterOptions.aiStatus || []).map((option) => ( searchable={false}
<option key={option.value} value={option.value}>{option.label}</option> />
))} </div>
</select>
</label>
</div> </div>
</section> </section>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') { async function requestJson(url, method = 'POST') {
const response = await fetch(url, { 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> <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" /> <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>
<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> <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"> <NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={typeOptions} searchable={false} />
{typeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select> <div className="space-y-2 text-sm text-slate-300">
</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">Content type</span> <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"> <NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={moduleOptions} searchable={false} />
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select>
</label>
<div className="flex items-end"> <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> <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> </div>

View File

@@ -791,6 +791,7 @@ export default function StudioArtworkEdit() {
.map((world) => ({ .map((world) => ({
world_id: Number(world.id), world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '', note: typeof world.note === 'string' ? world.note : '',
source_surface: 'navigation',
})) }))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0), .filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
evolution_target_artwork_id: evolutionTarget?.id || null, evolution_target_artwork_id: evolutionTarget?.id || null,
@@ -2166,18 +2167,14 @@ export default function StudioArtworkEdit() {
<div className="space-y-4"> <div className="space-y-4">
<label className="block"> <label className="block">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span> <span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span>
<select <NovaSelect
value={evolutionRelationType} value={evolutionRelationType}
onChange={(event) => setEvolutionRelationType(event.target.value)} onChange={(value) => setEvolutionRelationType(value)}
disabled={saving || !evolutionTarget} 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" className="mt-2"
> options={evolutionRelationTypes.map((option) => ({ value: option.value, label: option.label }))}
{evolutionRelationTypes.map((option) => ( searchable={false}
<option key={option.value} value={option.value}> />
{option.label}
</option>
))}
</select>
</label> </label>
{errors.evolution_relation_type?.[0] ? <p className="text-sm text-red-400">{errors.evolution_relation_type[0]}</p> : null} {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) => ( onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
Number(world.id) === Number(worldId) ? { ...world, note } : 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 React from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
function formatDate(value) { function formatDate(value) {
@@ -78,50 +79,35 @@ export default function StudioAssets() {
/> />
</label> </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> <span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Type</span>
<select <NovaSelect
value={filters.type || 'all'} value={filters.type || 'all'}
onChange={(event) => updateFilters({ type: event.target.value })} onChange={(value) => updateFilters({ type: value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" options={typeOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{typeOptions.map((option) => ( />
<option key={option.value} value={option.value} className="bg-slate-900"> </div>
{option.label}
</option>
))}
</select>
</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">Source</span> <span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Source</span>
<select <NovaSelect
value={filters.source || 'all'} value={filters.source || 'all'}
onChange={(event) => updateFilters({ source: event.target.value })} onChange={(value) => updateFilters({ source: value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" options={sourceOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{sourceOptions.map((option) => ( />
<option key={option.value} value={option.value} className="bg-slate-900"> </div>
{option.label}
</option>
))}
</select>
</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> <span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select <NovaSelect
value={filters.sort || 'recent'} value={filters.sort || 'recent'}
onChange={(event) => updateFilters({ sort: event.target.value })} onChange={(value) => updateFilters({ sort: value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" options={sortOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{sortOptions.map((option) => ( />
<option key={option.value} value={option.value} className="bg-slate-900"> </div>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300"> <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 StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown' 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') { async function requestJson(url, method = 'POST') {
const response = await fetch(url, { const response = await fetch(url, {
@@ -26,10 +207,13 @@ export default function StudioCalendar() {
const calendar = props.calendar || {} const calendar = props.calendar || {}
const filters = calendar.filters || {} const filters = calendar.filters || {}
const summary = calendar.summary || {} const summary = calendar.summary || {}
const currentView = filters.view || 'month'
const [busyKey, setBusyKey] = useState(null) const [busyKey, setBusyKey] = useState(null)
const [nowMs, setNowMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now())
const [selectedDay, setSelectedDay] = useState(null)
const updateFilters = (patch) => { const updateFilters = (patch) => {
setSelectedDay(null)
const next = { ...filters, ...patch } const next = { ...filters, ...patch }
trackStudioEvent('studio_scheduled_opened', { trackStudioEvent('studio_scheduled_opened', {
surface: studioSurface(), 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 runAction = async (pattern, item, key) => {
const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id)) const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id))
setBusyKey(`${key}:${item.id}`) setBusyKey(`${key}:${item.id}`)
@@ -67,6 +259,26 @@ export default function StudioCalendar() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [calendar.scheduled_items, summary.next_publish_at]) }, [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 ( return (
<StudioLayout title={props.title} subtitle={props.description}> <StudioLayout title={props.title} subtitle={props.description}>
<div className="space-y-6"> <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> <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" /> <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>
<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> <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>
<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> <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>
<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">Queue</span><NovaSelect value={filters.status || 'scheduled'} onChange={(val) => updateFilters({ status: val })} options={calendar.status_options || []} searchable={false} /></div>
</div> </div>
</section> </section>
@@ -93,12 +305,22 @@ export default function StudioCalendar() {
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5"> <section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
{filters.view === 'week' ? ( {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"> <div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
{(calendar.week?.days || []).map((day) => ( {(calendar.week?.days || []).map((day) => (
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-3"> <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="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>
))} ))}
</div> </div>
@@ -106,13 +328,23 @@ export default function StudioCalendar() {
) : filters.view === 'agenda' ? ( ) : filters.view === 'agenda' ? (
<> <>
<h2 className="text-lg font-semibold text-white">Agenda</h2> <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-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> </section>
@@ -134,6 +366,8 @@ export default function StudioCalendar() {
</section> </section>
</aside> </aside>
</div> </div>
<CalendarDayModal day={selectedDay} busyKey={busyKey} endpoints={props.endpoints} onAction={runAction} onClose={() => setSelectedDay(null)} nowMs={nowMs} />
</div> </div>
</StudioLayout> </StudioLayout>
) )

View File

@@ -7,6 +7,8 @@ import NovaCardGradientPicker from '../../components/nova-cards/NovaCardGradient
import NovaCardFontPicker from '../../components/nova-cards/NovaCardFontPicker' import NovaCardFontPicker from '../../components/nova-cards/NovaCardFontPicker'
import NovaCardAutosaveIndicator from '../../components/nova-cards/NovaCardAutosaveIndicator' import NovaCardAutosaveIndicator from '../../components/nova-cards/NovaCardAutosaveIndicator'
import NovaCardPresetPicker from '../../components/nova-cards/NovaCardPresetPicker' import NovaCardPresetPicker from '../../components/nova-cards/NovaCardPresetPicker'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
const defaultMobileSteps = [ const defaultMobileSteps = [
{ key: 'format', label: 'Format', description: 'Choose the canvas shape and basic direction.' }, { 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 currentProjectSummary = summarizeProjectSnapshot(card.project_json || {})
const editorTabs = [ const editorTabs = [
{ key: 'background', label: 'Background' }, { key: 'background', label: 'Canvas' },
{ key: 'content', label: 'Content' }, { key: 'content', label: 'Text' },
{ key: 'typography', label: 'Typography' }, { key: 'style', label: 'Style' },
{ key: 'layout', label: 'Layout' },
{ key: 'publish', label: 'Publish' }, { key: 'publish', label: 'Publish' },
] ]
@@ -1012,9 +1013,20 @@ export default function StudioCardEditor() {
{/* Tab panels */} {/* Tab panels */}
<div className="mt-2 rounded-[28px] border border-white/10 bg-white/[0.04] p-5"> <div className="mt-2 rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
{/* BACKGROUND TAB */} {/* CANVAS TAB */}
{activeTab === 'background' && ( {activeTab === 'background' && (
<div className="space-y-6"> <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>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Template</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} /> <NovaCardTemplatePicker templates={editorOptions.templates || []} selectedId={card.template_id} onSelect={handleTemplateSelect} />
@@ -1076,12 +1088,10 @@ export default function StudioCardEditor() {
<div> <div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Overlay &amp; depth</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"> <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> <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"> <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 }))} />
{overlayOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} </div>
</select>
</label>
<label className="block text-sm text-slate-300"> <label className="block text-sm text-slate-300">
<div className="mb-2 flex justify-between"> <div className="mb-2 flex justify-between">
<span>Overlay opacity</span> <span>Overlay opacity</span>
@@ -1098,12 +1108,10 @@ export default function StudioCardEditor() {
</div> </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" /> <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>
<label className="block text-sm text-slate-300"> <div className="block text-sm text-slate-300">
<span className="mb-2 block">Focal position</span> <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"> <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 }))} />
{(editorOptions.focal_positions || []).map((position) => <option key={position.key} value={position.key}>{position.label}</option>)} </div>
</select>
</label>
</> </>
)} )}
</div> </div>
@@ -1146,9 +1154,15 @@ export default function StudioCardEditor() {
</div> </div>
)} )}
{/* CONTENT TAB */} {/* TEXT TAB */}
{activeTab === 'content' && ( {activeTab === 'content' && (
<div className="space-y-4"> <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"> <label className="block text-sm text-slate-300">
<span className="mb-2 block">Title</span> <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" /> <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 === 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> <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> </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"> <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' }]} />
<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>
<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" /> <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"> <Checkbox checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} label="On" />
<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>
<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> <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>
</div> </div>
@@ -1203,17 +1207,12 @@ export default function StudioCardEditor() {
</div> </div>
</div> </div>
)} )}
</div>
</div> </div>
)} )}
{/* STYLE TAB */}
{/* TYPOGRAPHY TAB */} {activeTab === 'style' && (
{activeTab === 'typography' && (
<div className="space-y-6"> <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>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote size</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"> <div className="flex items-center gap-3">
@@ -1318,39 +1317,20 @@ export default function StudioCardEditor() {
<div> <div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote mark &amp; panel</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"> <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> <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"> <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 }))} />
{(editorOptions.quote_mark_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)} </div>
</select> <div className="block text-sm text-slate-300">
</label>
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Text panel style</span> <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"> <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 }))} />
{(editorOptions.text_panel_styles || []).map((style) => <option key={style.key} value={style.key}>{style.label}</option>)} </div>
</select>
</label>
</div> </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>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Layout preset</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"> <div className="flex flex-wrap gap-2">
@@ -1412,25 +1392,19 @@ export default function StudioCardEditor() {
<div> <div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Frame &amp; effects</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"> <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> <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"> <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 }))} />
{(editorOptions.frame_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)} </div>
</select>
</label>
<div className="grid grid-cols-2 gap-3"> <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> <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"> <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 }))} />
{(editorOptions.color_grade_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)} </div>
</select> <div className="block text-sm text-slate-300">
</label>
<label className="block text-sm text-slate-300">
<span className="mb-1.5 block text-xs">Effect</span> <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"> <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 }))} />
{(editorOptions.effect_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)} </div>
</select>
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -1489,19 +1463,17 @@ export default function StudioCardEditor() {
)} )}
</div> </div>
)} )}
</div>
</div> </div>
)} )}
{/* PUBLISH TAB */} {/* PUBLISH TAB */}
{activeTab === 'publish' && ( {activeTab === 'publish' && (
<div className="space-y-5"> <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> <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"> <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 }))} />
<option value="">Select category</option> </div>
{(editorOptions.categories || []).map((cat) => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
</select>
</label>
<div> <div>
<div className="mb-2 text-sm text-slate-300">Visibility</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_export', label: 'Allow export' },
{ key: 'allow_background_reuse', label: 'Allow background reuse' }, { key: 'allow_background_reuse', label: 'Allow background reuse' },
].map(({ key, label }) => ( ].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> <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]" /> <Checkbox checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} />
</label> </div>
))} ))}
</div> </div>
@@ -1541,22 +1513,16 @@ export default function StudioCardEditor() {
</label> </label>
{advancedMode && ( {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> <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"> <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 }))} />
<option value="">None</option> </div>
{(editorOptions.style_families || []).map((sf) => <option key={sf.key} value={sf.key}>{sf.label}</option>)}
</select>
</label>
)} )}
<div> <div>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Save to collection</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"> <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"> <NovaSelect value={String(selectedCollectionId || '')} onChange={(val) => setSelectedCollectionId(val)} placeholder="Default saved cards" options={collections.map((c) => ({ value: String(c.id), label: c.name }))} />
<option value="">Default saved cards</option>
{collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}
</select>
<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> <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> </div>
<button type="button" onClick={createCollection} className="mt-2 text-sm text-slate-400 transition hover:text-white">+ Create collection</button> <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 React, { useMemo, useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
const reportReasons = [ 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" 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>
<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> <span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
<select <NovaSelect
value={filters.module || 'all'} value={filters.module || 'all'}
onChange={(event) => updateFilters({ module: event.target.value })} onChange={(value) => updateFilters({ module: value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" options={moduleOptions.map((option) => ({ value: option.value, label: option.label }))}
> searchable={false}
{moduleOptions.map((option) => ( />
<option key={option.value} value={option.value} className="bg-slate-900"> </div>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
</section> </section>
@@ -352,20 +348,15 @@ export default function StudioComments() {
{reportFor === comment.id && ( {reportFor === comment.id && (
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4"> <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)]"> <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> <span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</span>
<select <NovaSelect
value={reportReason} value={reportReason}
onChange={(event) => setReportReason(event.target.value)} onChange={(value) => setReportReason(value)}
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white" options={reportReasons.map((reason) => ({ value: reason.value, label: reason.label }))}
> searchable={false}
{reportReasons.map((reason) => ( />
<option key={reason.value} value={reason.value} className="bg-slate-900"> </div>
{reason.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-slate-300"> <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> <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 { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
function SummaryCard({ label, value, icon }) { function SummaryCard({ label, value, icon }) {
return ( 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> <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" /> <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>
<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> <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"> <NovaSelect value={filters.sort || 'recent'} onChange={(val) => updateQuery({ sort: val, page: 1 })} options={listing.sort_options || []} searchable={false} />
{(listing.sort_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select> <div className="space-y-2 text-sm text-slate-300">
</label>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Relationship</span> <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"> <NovaSelect value={filters.relationship || 'all'} onChange={(val) => updateQuery({ relationship: val, page: 1 })} options={listing.relationship_options || []} searchable={false} />
{(listing.relationship_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select>
</label>
</div> </div>
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react' import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupAssets() { export default function StudioGroupAssets() {
const { props } = usePage() 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"> <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"> <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" /> <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> <NovaSelect value={form.data.category} onChange={(val) => form.setData('category', val)} options={props.categoryOptions || []} searchable={false} />
<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> <NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<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.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" /> <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> </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" /> <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"> <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"> <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 }))} />
<option value="">No linked project</option> <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>
{(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>
</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> <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> </form>
@@ -73,14 +72,8 @@ export default function StudioGroupAssets() {
</div> </div>
<div className="mt-4 grid gap-4 lg:grid-cols-3"> <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" /> <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"> <NovaSelect value={filters.data.category} onChange={(val) => filters.setData('category', val)} options={[{ value: 'all', label: 'All categories' }, ...(props.categoryOptions || [])]} searchable={false} />
<option value="all">All categories</option> <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} />
{(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>
</div> </div>
</form> </form>

View File

@@ -1,10 +1,12 @@
import React from 'react' import React from 'react'
import { useForm, usePage } from '@inertiajs/react' import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupChallengeEditor() { export default function StudioGroupChallengeEditor() {
const { props } = usePage() const { props } = usePage()
const challenge = props.challenge || null const challenge = props.challenge || null
const outcomeArtworkOptions = Array.isArray(challenge?.artworks) ? challenge.artworks : []
const form = useForm({ const form = useForm({
title: challenge?.title || '', title: challenge?.title || '',
summary: challenge?.summary || '', summary: challenge?.summary || '',
@@ -20,6 +22,14 @@ export default function StudioGroupChallengeEditor() {
linked_collection_id: challenge?.linked_collection?.id || '', linked_collection_id: challenge?.linked_collection?.id || '',
linked_project_id: challenge?.linked_project?.id || '', linked_project_id: challenge?.linked_project?.id || '',
featured_artwork_id: challenge?.featured_artwork?.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, cover_file: null,
}) })
const attachForm = useForm({ artwork_id: '' }) const attachForm = useForm({ artwork_id: '' })
@@ -34,6 +44,30 @@ export default function StudioGroupChallengeEditor() {
form.post(props.storeUrl, options) 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 ( return (
<StudioLayout title={props.title} subtitle={props.description}> <StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]"> <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.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" /> <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"> <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> <NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<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> <NovaSelect value={form.data.participation_scope} onChange={(val) => form.setData('participation_scope', val)} options={props.participationScopeOptions || []} searchable={false} />
<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.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <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" /> <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> </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.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" /> <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"> <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} />
<option value="">No judging mode</option>
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-3"> <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"> <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 }))} />
<option value="">No linked collection</option> <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 }))} />
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)} <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-6 text-slate-300">
</select> Featured result rendering now comes from structured outcomes. Attach entries first, then assign winners and finalists below.
<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"> </div>
<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>
</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" /> <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> </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> <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> </form>
@@ -81,10 +139,7 @@ export default function StudioGroupChallengeEditor() {
{props.attachArtworkUrl ? ( {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"> <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> <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"> <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 }))} />
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<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> <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> </form>
) : null} ) : null}

View File

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

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { useForm, usePage } from '@inertiajs/react' import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupEventEditor() { export default function StudioGroupEventEditor() {
const { props } = usePage() 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.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" /> <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"> <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> <NovaSelect value={form.data.event_type} onChange={(val) => form.setData('event_type', val)} options={props.typeOptions || []} searchable={false} />
<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> <NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<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.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <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" /> <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> </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" /> <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"> <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"> <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 }))} />
<option value="">No linked project</option> <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 }))} />
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)} <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 }))} />
</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>
</div> </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" /> <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>
<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> <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 React, { useMemo, useState } from 'react'
import { Link, router, usePage } from '@inertiajs/react' import { Link, router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function formatInviteTimestamp(value) { function formatInviteTimestamp(value) {
if (!value) return null 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]"> <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" /> <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"> <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' }]} />
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<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.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" /> <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> <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 React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function overrideMap(member) { function overrideMap(member) {
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : [] const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
@@ -86,11 +87,7 @@ export default function StudioGroupMembers() {
</div> </div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]"> <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" /> <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"> <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' }]} />
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<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.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> <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> </div>
@@ -128,11 +125,7 @@ export default function StudioGroupMembers() {
</div> </div>
<div> <div>
{canManageMembers && member.role !== 'owner' ? ( {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"> <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' }]} />
<option value="contributor">Contributor</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
) : <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>} ) : <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 ? ( {Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react' import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupPostEditor() { export default function StudioGroupPostEditor() {
const { props } = usePage() 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)]"> <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"> <section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="grid gap-4"> <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> <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"> <NovaSelect value={form.data.type} onChange={(val) => form.setData('type', val)} options={Array.isArray(props.typeOptions) ? props.typeOptions : []} searchable={false} />
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} </div>
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300"> <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> <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" /> <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 React from 'react'
import { router, useForm, usePage } from '@inertiajs/react' import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function normalizeIds(values) { function normalizeIds(values) {
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0) 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.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" /> <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"> <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> <NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<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.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <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.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" /> <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>
<div className="grid gap-4 md:grid-cols-2"> <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"> <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 }))} />
<option value="">No lead</option> <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 }))} />
{(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>
</div> </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"> <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 }))} />
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-2"> <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"> <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 }))} />
<option value="">No featured artwork</option> <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 }))} />
{(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>
</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" /> <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>
@@ -87,7 +74,7 @@ export default function StudioGroupProjectEditor() {
{props.statusUrl ? ( {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"> <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> <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> <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> </form>
) : null} ) : null}
@@ -95,10 +82,7 @@ export default function StudioGroupProjectEditor() {
{props.attachArtworkUrl ? ( {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"> <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> <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"> <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 }))} />
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<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> <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> </form>
) : null} ) : null}
@@ -106,10 +90,7 @@ export default function StudioGroupProjectEditor() {
{props.attachAssetUrl ? ( {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"> <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> <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"> <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 }))} />
<option value="">Choose asset</option>
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<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> <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> </form>
) : null} ) : 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" /> <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" /> <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"> <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"> <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 }))} />
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<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" /> <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> </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"> <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 }))} />
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<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" /> <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> <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> </div>

View File

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

View File

@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react' import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function toDateTimeInput(value) { function toDateTimeInput(value) {
return value ? String(value).slice(0, 16) : '' 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.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" /> <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"> <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> <NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
<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.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<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.current_stage} onChange={(val) => form.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
</div> </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" /> <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"> <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"> <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 }))} />
<option value="">No release lead</option> <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 }))} />
{(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>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <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"> <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 }))} />
<option value="">No linked collection</option> <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 }))} />
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)} </div>
</select> <div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
<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"> <Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature this release on the public group page" />
<option value="">No featured artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</div> </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" /> <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>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
@@ -91,7 +80,7 @@ export default function StudioGroupReleaseEditor() {
{props.stageUrl ? ( {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"> <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> <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"> <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> <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} {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 ? ( {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"> <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> <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"> <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 }))} />
<option value="">Choose artwork</option>
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
<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> <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> </form>
) : null} ) : 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"> <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> <h2 className="text-lg font-semibold text-white">Contributor credit</h2>
<div className="mt-4 space-y-3"> <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"> <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 }))} />
<option value="">Choose contributor</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<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" /> <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> <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> </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" /> <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" /> <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"> <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"> <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 }))} />
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
<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" /> <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> </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"> <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 }))} />
<option value="">No owner</option>
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
</select>
<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" /> <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> <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> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupReleases() { export default function StudioGroupReleases() {
const { props } = usePage() 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="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="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"> <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"> <NovaSelect value={currentBucket} onChange={(val) => router.get(window.location.pathname, { bucket: val }, { preserveScroll: true, preserveState: true })} options={bucketOptions} searchable={false} />
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{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} {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>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef, useState } from 'react' import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function resolveMediaPreviewUrl(path, filesCdnUrl) { function resolveMediaPreviewUrl(path, filesCdnUrl) {
const trimmed = String(path || '').trim() const trimmed = String(path || '').trim()
@@ -145,13 +146,10 @@ export default function StudioGroupSettings() {
</div> </div>
</div> </div>
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4"> <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> <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"> <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 }))} />
<option value="">Use latest published artwork</option> </div>
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
</select>
</label>
{selectedFeaturedArtwork ? ( {selectedFeaturedArtwork ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3"> <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} {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> <p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
)} )}
</div> </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> <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>
<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>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="grid gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-slate-200">Links</span> <span className="text-sm text-slate-200">Links</span>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') { async function requestJson(url, method = 'POST') {
const response = await fetch(url, { const response = await fetch(url, {
@@ -78,10 +79,10 @@ export default function StudioInbox() {
<h2 className="text-lg font-semibold text-white">Filters</h2> <h2 className="text-lg font-semibold text-white">Filters</h2>
<div className="mt-4 space-y-3"> <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">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> <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>
<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> <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>
<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> <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>
<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">Priority</span><NovaSelect value={filters.priority || 'all'} onChange={(val) => updateFilters({ priority: val })} options={inbox.priority_options || []} searchable={false} /></div>
</div> </div>
</section> </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 { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' 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.' }) { function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
if (!Array.isArray(items) || items.length === 0) { 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="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{item.title}</div> <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.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> </div>
</button> </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 }) { function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
return ( return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4"> <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"> <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-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span> <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"> <NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} </div>
</select>
</label>
<label className="grid gap-2 text-sm text-slate-300"> <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> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -67,28 +283,70 @@ function RelationCard({ relation, index, onChange, onRemove, onSearch, results,
) )
} }
export default function StudioNewsEditor() { function stripHtml(value) {
const { props } = usePage() return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
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({})
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 || '', title: article.title || '',
slug: article.slug || '', slug: article.slug || '',
excerpt: article.excerpt || '', excerpt: article.excerpt || '',
content: article.content || '', content: article.content || '',
cover_image: article.cover_image || '', 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 || '', 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', editorial_status: article.editorial_status || 'draft',
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '', published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
is_featured: Boolean(article.is_featured), is_featured: Boolean(article.is_featured),
is_pinned: Boolean(article.is_pinned), is_pinned: Boolean(article.is_pinned),
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [], tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
new_tag_names: [],
meta_title: article.meta_title || '', meta_title: article.meta_title || '',
meta_description: article.meta_description || '', meta_description: article.meta_description || '',
meta_keywords: article.meta_keywords || '', meta_keywords: article.meta_keywords || '',
@@ -103,18 +361,50 @@ export default function StudioNewsEditor() {
preview: relation.preview || null, preview: relation.preview || null,
query: relation.preview?.title || '', query: relation.preview?.title || '',
})) : [], })) : [],
}) }
}
const submit = (event) => { export default function StudioNewsEditor() {
event.preventDefault() 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) { const form = useForm(initialFormData)
form.patch(props.updateUrl)
useEffect(() => {
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
return 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 searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin) const url = new URL(props.entitySearchUrl, window.location.origin)
@@ -174,95 +464,225 @@ export default function StudioNewsEditor() {
setRelationResults((current) => ({ ...current, [index]: items })) 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 ( return (
<StudioLayout title={props.title} subtitle={props.description}> <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)]"> <ToastStack toasts={toasts} onDismiss={dismissToast} />
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5"> <form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,0.85fr)]">
<div className="grid gap-4"> <div className="space-y-6">
<label className="grid gap-2 text-sm text-slate-300"> <SectionCard
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span> eyebrow="Story workspace"
<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" /> 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.'}
</label> 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"> <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> <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" /> <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>
<label className="grid gap-2 text-sm text-slate-300"> <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> <span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<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>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> </label>
</div>
<label className="grid gap-2 text-sm text-slate-300"> <div className="grid gap-4">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span> <WorldMediaUploadField
<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="Cover image"
</label> 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"> <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> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</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" /> <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" />
</label> <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 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>}
</div> </div>
</div> </div>
</div> </SectionCard>
</section>
<section className="space-y-6"> <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="rounded-[28px] border border-white/10 bg-white/[0.03] p-5"> <div className="grid gap-3 text-sm text-slate-300">
<h2 className="text-xl font-semibold text-white">Publishing</h2> <RichTextEditor
<div className="mt-5 grid gap-4"> 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} {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"> <div className="grid gap-4 md:grid-cols-2">
<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> <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} />
<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"> </div>
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} <div className="grid gap-2 text-sm text-slate-300">
</select> <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} />
</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">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> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<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">Workflow status</span> <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} />
<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"> </div>
{(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"> <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> <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" /> <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> </label>
</div> </div>
@@ -283,51 +703,39 @@ export default function StudioNewsEditor() {
setAuthorQuery(item.title) setAuthorQuery(item.title)
form.setData('author_id', item.id) form.setData('author_id', item.id)
}} emptyLabel="Search to choose an author profile." /> }} emptyLabel="Search to choose an author profile." />
</div> <FieldError message={form.errors.author_id} />
<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>
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <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"> <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> <Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on newsroom surfaces" size={20} variant="accent" />
Feature on newsroom surfaces </div>
</label> <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<label 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_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} /> </div>
Pin to the top of the newsroom
</label>
</div> </div>
</div> </div>
</div> </SectionCard>
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5"> <SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
<h2 className="text-xl font-semibold text-white">SEO &amp; social</h2> <TagPicker
<div className="mt-5 grid gap-4"> 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"> <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> <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" /> <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" /> <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> </label>
</div> </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"> <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.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.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.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>
</div> </SectionCard>
</section> </div>
</form> </form>
</StudioLayout> </StudioLayout>
) )

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
function formatDate(value) { function formatDate(value) {
if (!value) return 'Draft' if (!value) return 'Draft'
@@ -36,6 +37,15 @@ export default function StudioNewsIndex() {
const filters = props.listing?.filters || {} const filters = props.listing?.filters || {}
const meta = props.listing?.meta || {} 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) => { const updateFilter = (next) => {
router.get('/studio/news', { router.get('/studio/news', {
...filters, ...filters,
@@ -89,45 +99,36 @@ export default function StudioNewsIndex() {
}} }}
/> />
</label> </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> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
<select <NovaSelect
value={filters.status || ''} value={filters.status || ''}
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })} onChange={(value) => updateFilter({ status: 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" placeholder="All statuses"
> options={(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ({ value: option.value, label: option.label }))}
<option value="">All statuses</option> searchable={false}
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ( />
<option key={option.value} value={option.value}>{option.label}</option> </div>
))} <div className="grid gap-2 text-sm text-slate-300">
</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">Type</span> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
<select <NovaSelect
value={filters.type || ''} value={filters.type || ''}
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })} onChange={(value) => updateFilter({ type: 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" placeholder="All types"
> options={(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ({ value: option.value, label: option.label }))}
<option value="">All types</option> searchable={false}
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ( />
<option key={option.value} value={option.value}>{option.label}</option> </div>
))} <div className="grid gap-2 text-sm text-slate-300">
</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> <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
<select <NovaSelect
value={filters.category_id || ''} value={filters.category_id || ''}
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })} onChange={(value) => updateFilter({ category_id: 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" placeholder="All categories"
> options={(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ({ value: String(option.id), label: option.name }))}
<option value="">All categories</option> searchable={false}
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ( />
<option key={option.id} value={option.id}>{option.name}</option> </div>
))}
</select>
</label>
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div> <div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
</div> </div>
</section> </section>
@@ -154,6 +155,7 @@ export default function StudioNewsIndex() {
<div className="mt-5 flex flex-wrap gap-2"> <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.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> <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>
</div> </div>
</article> </article>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import NovaSelect from '../../components/ui/NovaSelect'
const shortcutOptions = [ const shortcutOptions = [
{ value: '/dashboard/profile', label: 'Dashboard profile' }, { value: '/dashboard/profile', label: 'Dashboard profile' },
@@ -172,48 +173,30 @@ export default function StudioPreferences() {
</div> </div>
<div className="mt-6 grid gap-4 md:grid-cols-2"> <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> <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"> <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' }]} />
<option value="grid" className="bg-slate-900">Grid</option> </div>
<option value="list" className="bg-slate-900">List</option>
</select>
</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">Analytics date range</span> <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"> <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` }))} />
{[7, 14, 30, 60, 90].map((days) => ( </div>
<option key={days} value={days} className="bg-slate-900">Last {days} days</option>
))}
</select>
</label>
<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> <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"> <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' }]} />
<option value="resume-last" className="bg-slate-900">Resume the last draft I edited</option> </div>
<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>
<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> <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"> <NovaSelect value={form.default_landing_page} onChange={(val) => setForm((current) => ({ ...current, default_landing_page: val }))} searchable={false} options={landingOptions.map(([value, label]) => ({ value, label }))} />
{landingOptions.map(([value, label]) => ( </div>
<option key={value} value={value} className="bg-slate-900">{label}</option>
))}
</select>
</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">Card density</span> <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"> <NovaSelect value={form.card_density} onChange={(val) => setForm((current) => ({ ...current, card_density: val }))} searchable={false} options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]} />
<option value="comfortable" className="bg-slate-900">Comfortable</option> </div>
<option value="compact" className="bg-slate-900">Compact</option>
</select>
</label>
<label className="space-y-2 text-sm text-slate-300 md:col-span-2"> <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> <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 StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents' import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown' import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
import NovaSelect from '../../components/ui/NovaSelect'
async function requestJson(url, method = 'POST') { async function requestJson(url, method = 'POST') {
const response = await fetch(url, { 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> <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" /> <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>
<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> <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"> <NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={listing.module_options || []} searchable={false} />
{(listing.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select> <div className="space-y-2 text-sm text-slate-300">
</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">Date range</span> <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"> <NovaSelect value={filters.range || 'upcoming'} onChange={(val) => updateFilters({ range: val })} options={rangeOptions} searchable={false} />
{rangeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)} </div>
</select>
</label>
<label className="space-y-2 text-sm text-slate-300"> <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> <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" /> <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 React from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' import StudioLayout from '../../Layouts/StudioLayout'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioSearch() { export default function StudioSearch() {
const { props } = usePage() 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"> <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"> <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 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> <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>
<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">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={search.module_options || []} searchable={false} /></div>
</div> </div>
</section> </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 React from 'react'
import { router, usePage } from '@inertiajs/react' import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout' 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() { export default function StudioWorldsIndex() {
const { props } = usePage() const { props } = usePage()
@@ -15,26 +18,22 @@ export default function StudioWorldsIndex() {
return ( return (
<StudioLayout title={props.title} subtitle={props.description}> <StudioLayout title={props.title} subtitle={props.description}>
<div className="grid gap-6"> <div className="grid gap-6">
<WorldAnalyticsPortfolioPanel analytics={props.analytics} />
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5"> <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"> <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"> <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> <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" /> <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>
<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> <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"> <NovaSelect value={filters.status || ''} onChange={(val) => updateFilter('status', val)} options={[{ value: '', label: 'All statuses' }, ...(props.statusOptions || [])]} searchable={false} />
<option value="">All statuses</option> </div>
{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} <div className="grid gap-2 text-sm text-slate-300">
</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">Type</span> <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"> <NovaSelect value={filters.type || ''} onChange={(val) => updateFilter('type', val)} options={[{ value: '', label: 'All types' }, ...(props.typeOptions || [])]} searchable={false} />
<option value="">All types</option> </div>
{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<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> <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> </div>
</section> </section>
@@ -43,16 +42,18 @@ export default function StudioWorldsIndex() {
{items.length > 0 ? items.map((world) => ( {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]"> <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"> <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> <WorldStatusBadge badge={{ label: world.status, tone: 'slate' }} />
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.type}</span> <WorldStatusBadge badge={{ label: world.type, tone: 'slate' }} />
{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} {(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => <WorldStatusBadge key={`${world.id}-${badge.label}`} badge={badge} />)}
</div> </div>
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">{world.title}</h2> <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> <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} {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"> <div className="mt-5 flex flex-wrap gap-4 text-sm text-slate-400">
{world.timeframe_label ? <span>{world.timeframe_label}</span> : null} {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> <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} {world.theme_key ? <span>{world.theme_key}</span> : null}
</div> </div>
<div className="mt-5 flex flex-wrap gap-3 text-sm font-semibold"> <div className="mt-5 flex flex-wrap gap-3 text-sm font-semibold">

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import NovaSelect from '../ui/NovaSelect'
/** /**
* Modal for choosing a category in bulk. * Modal for choosing a category in bulk.
@@ -49,25 +50,17 @@ export default function BulkCategoryModal({ open, categories = [], onClose, onCo
{/* Category select */} {/* Category select */}
<div> <div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label> <label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select <NovaSelect
value={selectedId} value={selectedId}
onChange={(e) => setSelectedId(e.target.value)} onChange={(value) => setSelectedId(value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" placeholder="Select a category…"
> options={categories.flatMap((ct) => (
<option value="" className="bg-nova-900">Select a category</option> (ct.categories || []).flatMap((cat) => ([
{categories.map((ct) => ( { value: String(cat.id), label: cat.name, group: ct.name },
<optgroup key={ct.id} label={ct.name}> ...((cat.children || []).map((child) => ({ value: String(child.id), label: child.name, group: ct.name }))),
{ct.categories?.map((cat) => ( ]))
<React.Fragment key={cat.id}>
<option value={cat.id} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.id} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))} ))}
</select> />
</div> </div>
{/* Actions */} {/* Actions */}

View File

@@ -1,4 +1,5 @@
import React from 'react' import React, { useState } from 'react'
import NovaSelect from '../ui/NovaSelect'
function jumpToSection(targetId) { function jumpToSection(targetId) {
if (!targetId || typeof window === 'undefined') return if (!targetId || typeof window === 'undefined') return
@@ -11,24 +12,24 @@ function jumpToSection(targetId) {
} }
export default function DocsSidebarNav({ sections, ariaLabel = 'Sections on this page', selectLabel = 'Jump to section', navTitle = 'On this page' }) { export default function DocsSidebarNav({ sections, ariaLabel = 'Sections on this page', selectLabel = 'Jump to section', navTitle = 'On this page' }) {
const [selectedSection, setSelectedSection] = useState(null)
return ( return (
<> <>
<div className="lg:hidden"> <div className="lg:hidden">
<label htmlFor="groups-help-nav" className="sr-only">{selectLabel}</label> <label htmlFor="groups-help-nav" className="sr-only">{selectLabel}</label>
<select <NovaSelect
id="groups-help-nav" id="groups-help-nav"
className="w-full rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white outline-none" className="w-full"
defaultValue="" value={selectedSection}
onChange={(event) => { placeholder="Jump to a section"
jumpToSection(event.target.value) options={sections.map((section) => ({ value: section.id, label: section.label }))}
event.target.value = '' onChange={(value) => {
jumpToSection(value)
setSelectedSection(null)
}} }}
> searchable={false}
<option value="">Jump to a section</option> />
{sections.map((section) => (
<option key={section.id} value={section.id}>{section.label}</option>
))}
</select>
</div> </div>
<nav aria-label={ariaLabel} className="hidden lg:block lg:sticky lg:top-24"> <nav aria-label={ariaLabel} className="hidden lg:block lg:sticky lg:top-24">

View File

@@ -13,7 +13,7 @@ import { common, createLowlight } from 'lowlight';
import tippy from 'tippy.js'; import tippy from 'tippy.js';
import { buildBotFingerprint } from '../../lib/security/botFingerprint'; import { buildBotFingerprint } from '../../lib/security/botFingerprint';
import TurnstileField from '../security/TurnstileField'; import TurnstileField from '../security/TurnstileField';
import Select from '../ui/Select'; import NovaSelect from '../ui/NovaSelect';
type StoryType = { type StoryType = {
slug: string; slug: string;
@@ -446,9 +446,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [artworkModalOpen, setArtworkModalOpen] = useState(false); const [artworkModalOpen, setArtworkModalOpen] = useState(false);
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]); const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
const [artworkQuery, setArtworkQuery] = useState(''); const [artworkQuery, setArtworkQuery] = useState('');
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showLivePreview, setShowLivePreview] = useState(false);
const [livePreviewHtml, setLivePreviewHtml] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 }); const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({}); const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState(''); const [generalError, setGeneralError] = useState('');
@@ -456,6 +453,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [readMinutes, setReadMinutes] = useState(1); const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash'); const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const [captchaState, setCaptchaState] = useState({ const [captchaState, setCaptchaState] = useState({
required: false, required: false,
token: '', token: '',
@@ -661,6 +662,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
link: false,
underline: false,
heading: { levels: [1, 2, 3] }, heading: { levels: [1, 2, 3] },
}), }),
CodeBlockLowlight.configure({ CodeBlockLowlight.configure({
@@ -685,10 +688,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
DownloadAssetBlock, DownloadAssetBlock,
createSlashCommandExtension(insertActions), createSlashCommandExtension(insertActions),
], ],
immediatelyRender: false,
content: initialStory.content || EMPTY_DOC, content: initialStory.content || EMPTY_DOC,
editorProps: { editorProps: {
attributes: { attributes: {
class: 'tiptap prose prose-invert prose-headings:tracking-tight prose-p:text-[1.04rem] prose-p:leading-8 prose-p:text-stone-200 prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-400 prose-blockquote:text-stone-300 prose-code:text-sky-200 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-stone-200 focus:outline-none', class: 'tiptap prose prose-lg prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.85] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
}, },
handleDrop: (_view, event) => { handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0]; const file = event.dataTransfer?.files?.[0];
@@ -733,7 +737,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
if (!editor) return; if (!editor) return;
const updatePreview = () => { const updatePreview = () => {
setLivePreviewHtml(editor.getHTML());
const text = editor.getText().replace(/\s+/g, ' ').trim(); const text = editor.getText().replace(/\s+/g, ' ').trim();
const words = text === '' ? 0 : text.split(' ').length; const words = text === '' ? 0 : text.split(' ').length;
setWordCount(words); setWordCount(words);
@@ -804,6 +807,45 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}; };
}, [editor]); }, [editor]);
useEffect(() => {
if (!editor) return;
const updatePlusButton = () => {
const { from, to } = editor.state.selection;
if (from !== to) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
return;
}
const resolvedPos = editor.state.doc.resolve(from);
const parentNode = resolvedPos.parent;
if (parentNode.type.name === 'paragraph' && parentNode.content.size === 0) {
const coords = editor.view.coordsAtPos(from);
const containerRect = editorContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
return;
}
setPlusButtonState({
visible: true,
top: coords.top - 14,
left: containerRect.left - 48,
});
} else {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
}
};
editor.on('selectionUpdate', updatePlusButton);
editor.on('update', updatePlusButton);
return () => {
editor.off('selectionUpdate', updatePlusButton);
editor.off('update', updatePlusButton);
};
}, [editor]);
const payload = useCallback(() => ({ const payload = useCallback(() => ({
story_id: storyId, story_id: storyId,
title, title,
@@ -967,176 +1009,79 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}; };
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
<div className="sticky top-16 z-30 overflow-hidden rounded-[1.5rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,rgba(12,18,28,0.96),rgba(10,14,22,0.92))] p-4 shadow-[0_20px_70px_rgba(3,7,18,0.26)] backdrop-blur-xl"> {/* ── Nova top bar ─────────────────────────────────────────────────── */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl">
<div className="space-y-2"> <div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.24em] text-white/45"> <a href="/studio/stories" className="flex items-center gap-1.5 text-sm text-white/50 transition-colors hover:text-white/90">
<span className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-[11px] font-semibold text-white/70">{mode === 'create' ? 'New story' : 'Editing draft'}</span> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
<span>{wordCount.toLocaleString()} words</span> Stories
<span>{readMinutes} min read</span> </a>
<span>{saveStatus}</span> <span className="h-4 w-px bg-white/10" />
</div> <span className="hidden text-sm text-white/65 sm:inline">{saveStatus}</span>
<p className="max-w-2xl text-sm text-white/62">Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.</p> </div>
</div> <div className="flex items-center gap-2">
<span className="hidden text-xs text-white/55 lg:inline">{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}</span>
<div className="flex flex-wrap items-center gap-2"> <button
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">Insert block</button> type="button"
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">{showLivePreview ? 'Hide preview' : 'Preview'}</button> onClick={() => setSettingsOpen(true)}
<button type="button" onClick={() => persistStory('save_draft')} disabled={isSubmitting} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white transition hover:bg-white/[0.09] disabled:opacity-60">Save draft</button> title="Story settings"
<button type="button" onClick={() => persistStory('submit_review')} disabled={isSubmitting} className="rounded-xl border border-amber-400/30 bg-amber-400/12 px-3 py-2 text-sm text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-60">Submit review</button> className="rounded-full p-2 text-white/50 transition-colors hover:bg-white/[0.07] hover:text-white"
<button type="button" onClick={() => persistStory('publish_now')} disabled={isSubmitting} className="rounded-xl border border-emerald-400/30 bg-emerald-400/14 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-400/22 disabled:opacity-60">Publish now</button> >
</div> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</button>
<button
type="button"
onClick={() => persistStory('save_draft')}
disabled={isSubmitting}
className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-1.5 text-sm text-white/80 transition hover:bg-white/[0.10] disabled:opacity-50"
>
Save
</button>
<button
type="button"
onClick={() => persistStory('publish_now')}
disabled={isSubmitting}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.45)] transition hover:bg-sky-400 disabled:opacity-50"
>
Publish
</button>
</div> </div>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]"> {/* ── Writing canvas ───────────────────────────────────────────────── */}
<div className="space-y-6"> <div className="mx-auto max-w-[760px] overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)]">
<section className="overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.12),_transparent_26%),linear-gradient(180deg,rgba(16,22,33,0.96),rgba(9,12,19,0.92))] shadow-[0_22px_80px_rgba(4,8,20,0.24)]"> {coverImage ? (
{coverImage ? ( <div className="group relative overflow-hidden rounded-t-2xl">
<div className="relative h-56 overflow-hidden border-b border-white/10"> <img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
<img src={coverImage} alt="Story cover" className="h-full w-full object-cover" /> <div className="absolute inset-0 flex items-center justify-center gap-3 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent" /> <button
<div className="absolute bottom-4 left-4 rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/75">Cover preview</div> type="button"
</div> onClick={() => coverImageInputRef.current?.click()}
) : null} className="flex items-center gap-1.5 rounded-lg bg-white/15 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-white/25"
<div className="space-y-5 p-6 md:p-8">
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.22em] text-white/42">
<span>{storyTypes.find((type) => type.slug === storyType)?.name || 'Story'}</span>
<span>{status.replace(/_/g, ' ')}</span>
{scheduledFor ? <span>Scheduled {scheduledFor}</span> : null}
</div>
<div>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Give the story a title worth opening"
className="w-full border-0 bg-transparent px-0 text-4xl font-semibold tracking-tight text-white placeholder:text-white/25 focus:outline-none md:text-5xl"
/>
{titleError ? <p className="mt-2 text-sm text-rose-300">{titleError}</p> : null}
</div>
<div>
<textarea
value={excerpt}
onChange={(event) => setExcerpt(event.target.value)}
placeholder="Write a short dek that explains why this story matters."
rows={3}
className="w-full resize-none border-0 bg-transparent px-0 text-base leading-7 text-white/70 placeholder:text-white/25 focus:outline-none"
/>
{excerptError ? <p className="mt-2 text-sm text-rose-300">{excerptError}</p> : null}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Words</p>
<p className="mt-2 text-2xl font-semibold text-white">{wordCount.toLocaleString()}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Reading time</p>
<p className="mt-2 text-2xl font-semibold text-white">{readMinutes} min</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Status</p>
<p className="mt-2 text-2xl font-semibold capitalize text-white">{status.replace(/_/g, ' ')}</p>
</div>
</div>
</div>
</section>
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,19,28,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_90px_rgba(4,8,20,0.28)]">
<div className="border-b border-white/10 px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
{editor ? (
<>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bold') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('italic') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('underline') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleUnderline().run()}>Underline</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 2 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 3 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bulletList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBulletList().run()}>Bullets</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('orderedList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleOrderedList().run()}>Numbers</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('blockquote') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('codeBlock') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={toggleCodeBlockWithLanguage}>Code block</button>
<div className="inline-flex items-center gap-2 rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75">
<span className="text-white/50">Lang</span>
<div className="min-w-[10rem]">
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 py-1 text-sm text-white hover:border-white/20"
/>
</div>
</div>
<button type="button" className="rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75" onClick={() => openLinkPrompt(editor)}>Link</button>
</>
) : null}
</div>
</div>
{showInsertMenu && (
<div className="border-b border-white/10 bg-white/[0.03] px-5 py-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.uploadImage}>Upload image</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.image}>Image URL</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.artwork}>Embed artwork</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.video}>Video</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.download}>Download</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.code}>Code block</button>
<div className="col-span-2 rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 sm:col-span-3 xl:col-span-2">
<div className="mb-2 text-sm text-white/45">Language</div>
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 text-white hover:border-white/20"
/>
</div>
</div>
</div>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-2xl border border-white/10 bg-slate-950/95 px-2 py-1 shadow-lg backdrop-blur"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
> >
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button> Change
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('underline') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleUnderline().run()}>U</button> </button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button> <button
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button> type="button"
</div> onClick={() => setCoverImage('')}
)} className="flex items-center gap-1.5 rounded-lg bg-rose-500/70 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm transition hover:bg-rose-500/90"
>
<div className="px-6 py-8 md:px-10 md:py-10"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
<EditorContent editor={editor} /> Remove
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null} </button>
</div> </div>
</div>
) : null}
{showLivePreview && ( <div className="px-6 pb-24 pt-10 md:px-14 md:pt-14">
<div className="border-t border-white/10 bg-white/[0.02] px-6 py-6 md:px-10"> {/* Error / captcha banner */}
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-white/40">Live preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-slate-950 prose-p:text-stone-200" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
</section>
</div>
<aside className="space-y-4 xl:sticky xl:top-24 self-start">
{(generalError || captchaState.required) && ( {(generalError || captchaState.required) && (
<section className="rounded-[1.5rem] border border-amber-400/20 bg-amber-500/10 p-5"> <div className="mb-8 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-amber-100/70">Action needed</p> <p className="text-sm text-amber-200">{generalError || captchaState.message || 'Complete the captcha to continue.'}</p>
<p className="mt-3 text-sm text-amber-50">{generalError || captchaState.message || 'Complete the captcha challenge to continue.'}</p>
{captchaState.required && captchaState.siteKey ? ( {captchaState.required && captchaState.siteKey ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3"> <div className="mt-3">
<TurnstileField <TurnstileField
key={`story-editor-captcha-${captchaState.nonce}`} key={`story-editor-captcha-${captchaState.nonce}`}
provider={captchaState.provider} provider={captchaState.provider}
@@ -1147,126 +1092,328 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
/> />
</div> </div>
) : null} ) : null}
</section> </div>
)} )}
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5"> {/* Cover image upload shortcut */}
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Publish checklist</p> {!coverImage && (
<div className="mt-4 space-y-3"> <button
{readinessChecks.map((item) => ( type="button"
<div key={item.label} className="rounded-2xl border border-white/8 bg-black/10 px-4 py-3"> onClick={() => coverImageInputRef.current?.click()}
<div className="flex items-center justify-between gap-3"> className="mb-6 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/55 transition hover:bg-white/[0.05] hover:text-white/80"
<span className="text-sm font-medium text-white">{item.label}</span> >
<span className={`text-xs font-semibold uppercase tracking-[0.18em] ${item.ok ? 'text-emerald-300' : 'text-amber-200'}`}>{item.ok ? 'Ready' : 'Needs work'}</span> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div> Add a cover image
<p className="mt-2 text-sm text-white/48">{item.hint}</p> </button>
</div> )}
))}
</div>
</section>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5"> {/* Title */}
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Story settings</p> <div className="mb-3">
<div className="mt-4 space-y-4"> <textarea
<div> value={title}
<label className="mb-2 block text-sm font-medium text-white/80">Story type</label> onChange={(event) => {
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white"> setTitle(event.target.value);
{storyTypes.map((type) => ( event.target.style.height = 'auto';
<option key={type.slug} value={type.slug}>{type.name}</option> event.target.style.height = `${event.target.scrollHeight}px`;
))} }}
</select> onFocus={(event) => {
</div> event.target.style.height = 'auto';
event.target.style.height = `${event.target.scrollHeight}px`;
}}
placeholder="Title"
rows={1}
className="w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-[2.4rem] font-bold leading-tight tracking-tight text-white placeholder:text-white/35 focus:outline-none md:text-[2.8rem]"
/>
{titleError ? <p className="mt-1 text-sm text-rose-300">{titleError}</p> : null}
</div>
<div> {/* Excerpt / subtitle */}
<label className="mb-2 block text-sm font-medium text-white/80">Tags</label> <div className="mb-10 border-b border-white/[0.07] pb-8">
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="art direction, process, workflow" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> <textarea
{tagsError ? <p className="mt-2 text-sm text-rose-300">{tagsError}</p> : <p className="mt-2 text-xs text-white/40">Comma-separated. New tags are created automatically.</p>} value={excerpt}
</div> onChange={(event) => {
setExcerpt(event.target.value);
event.target.style.height = 'auto';
event.target.style.height = `${event.target.scrollHeight}px`;
}}
onFocus={(event) => {
event.target.style.height = 'auto';
event.target.style.height = `${event.target.scrollHeight}px`;
}}
placeholder="Write a short subtitle that sets the scene…"
rows={1}
className="w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-xl leading-relaxed text-white/75 placeholder:text-white/35 focus:outline-none"
/>
{excerptError ? <p className="mt-1 text-sm text-rose-300">{excerptError}</p> : null}
</div>
<div> {/* Body editor — the ref is on the wrapper so we can measure its left edge */}
<label className="mb-2 block text-sm font-medium text-white/80">Workflow status</label> <div className="relative" ref={editorContainerRef}>
<select value={status} onChange={(event) => setStatus(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white"> <EditorContent editor={editor} />
<option value="draft">Draft</option> {contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
<option value="pending_review">Pending Review</option> </div>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
</div>
<div> {/* Footer actions */}
<label className="mb-2 block text-sm font-medium text-white/80">Schedule publish</label> <div className="mt-16 flex flex-wrap items-center gap-3 border-t border-white/[0.07] pt-8 text-sm">
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white" /> {storyId && (
</div> <a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90">Preview</a>
</div> )}
</section> {storyId && (
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white/60 transition hover:bg-white/[0.08] hover:text-white/90">Analytics</a>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5"> )}
<div className="flex items-center justify-between gap-3"> <button
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Cover</p> type="button"
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-white/78">Upload</button> onClick={() => persistStory('submit_review')}
</div> disabled={isSubmitting}
<div className="mt-4 space-y-3"> className="rounded-full border border-amber-400/30 bg-amber-400/10 px-4 py-2 text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50"
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="https://..." className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> >
{coverImage ? <img src={coverImage} alt="Cover preview" className="h-40 w-full rounded-2xl object-cover" /> : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-white/38">Add a cover image to give the story more presence in feeds.</div>} Submit for review
</div> </button>
</section> {mode === 'edit' && storyId && (
<form
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5"> method="POST"
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">SEO & social</p> action={`/creator/stories/${storyId}`}
<div className="mt-4 space-y-3"> onSubmit={(e) => { if (!window.confirm('Delete this story permanently?')) e.preventDefault(); }}
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> className="ml-auto"
<textarea value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} rows={3} placeholder="Meta description" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> >
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> <input type="hidden" name="_token" value={csrfToken} />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" /> <input type="hidden" name="_method" value="DELETE" />
</div> <button type="submit" className="rounded-full border border-rose-500/30 bg-rose-500/10 px-4 py-2 text-rose-300 transition hover:bg-rose-500/20">Delete story</button>
</section> </form>
</aside> )}
</div>
</div>
</div> </div>
<div className="flex flex-wrap gap-3"> {/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
{storyId && ( {plusButtonState.visible && (
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Preview</a> <div className="fixed z-40" style={{ top: `${plusButtonState.top}px`, left: `${plusButtonState.left}px` }}>
)} <button
{storyId && ( type="button"
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-xl border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-sm text-violet-200">Analytics</a> onMouseDown={(e) => { e.preventDefault(); setPlusMenuOpen((v) => !v); }}
)} className={`flex h-8 w-8 items-center justify-center rounded-full border transition ${
{mode === 'edit' && storyId && ( plusMenuOpen
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => { ? 'border-sky-400/60 bg-sky-500/20 text-sky-300 shadow-[0_0_12px_rgba(14,165,233,0.35)]'
if (!window.confirm('Delete this story?')) { : 'border-white/20 bg-slate-900/90 text-white/60 shadow-[0_4px_16px_rgba(3,7,18,0.4)] hover:border-sky-400/50 hover:text-sky-300'
event.preventDefault(); }`}
} title="Add a block (or type / for commands)"
}}> >
<input type="hidden" name="_token" value={csrfToken} /> <svg
<input type="hidden" name="_method" value="DELETE" /> className={`h-4 w-4 transition-transform duration-200 ${plusMenuOpen ? 'rotate-45' : ''}`}
<button type="submit" className="rounded-xl border border-rose-500/40 bg-rose-500/20 px-3 py-2 text-sm text-rose-200">Delete</button> fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
</form> >
)} <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</div> </svg>
</button>
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} /> {/* Insert block dropdown */}
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} /> {plusMenuOpen && (
<div className="absolute left-10 top-0 w-52 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(18,24,36,0.99),rgba(10,14,22,0.99))] py-1 shadow-[0_16px_48px_rgba(3,7,18,0.5)] backdrop-blur-xl">
{artworkModalOpen && ( {([
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"> { label: 'Upload photo', icon: '🖼', key: 'uploadImage' },
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg"> { label: 'Image URL', icon: '🔗', key: 'image' },
<div className="mb-3 flex items-center justify-between"> { label: 'Artwork embed', icon: '🎨', key: 'artwork' },
<h3 className="text-sm font-semibold text-white">Embed Artwork</h3> { label: 'Video (YouTube…)', icon: '▶', key: 'video' },
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200">Close</button> { label: 'Gallery', icon: '⊞', key: 'gallery' },
</div> { label: 'Blockquote', icon: '❝', key: 'quote' },
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} className="mb-3 w-full rounded-xl border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200" placeholder="Search artworks" /> { label: 'Code block', icon: '⌨', key: 'code' },
<div className="grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2"> { label: 'Download link', icon: '↓', key: 'download' },
{artworkResults.map((item) => ( { label: 'Divider', icon: '—', key: 'divider' },
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="rounded-xl border border-gray-700 bg-gray-800 p-3 text-left hover:border-sky-400"> ] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-28 w-full rounded-lg object-cover" />} <button
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div> key={item.key}
<div className="text-xs text-gray-400">#{item.id}</div> type="button"
onMouseDown={(e) => {
e.preventDefault();
setPlusMenuOpen(false);
insertActions[item.key]();
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-white/75 transition-colors hover:bg-white/[0.07] hover:text-white"
>
<span className="w-5 text-center text-base leading-none opacity-70">{item.icon}</span>
{item.label}
</button> </button>
))} ))}
</div> </div>
)}
</div>
)}
{/* ── Floating inline formatting toolbar ───────────────────────────── */}
{editor && inlineToolbar.visible && (
<div
className="fixed z-50 flex items-center gap-0.5 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
{([
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
{ label: '⛓', title: 'Link', action: () => openLinkPrompt(editor), active: editor.isActive('link'), extra: '' },
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
] as Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>).map((item) => (
<button
key={item.title}
type="button"
title={item.title}
onMouseDown={(e) => e.preventDefault()}
onClick={item.action}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
>
{item.label}
</button>
))}
</div>
)}
{/* ── Settings slide-over panel ─────────────────────────────────────── */}
{settingsOpen && (
<>
<button
type="button"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setSettingsOpen(false)}
aria-label="Close settings"
/>
<div className="fixed bottom-0 right-0 top-0 z-50 flex w-full max-w-sm flex-col overflow-hidden border-l border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[20px_0_60px_rgba(3,7,18,0.5)]">
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-4">
<h2 className="font-semibold text-white">Story settings</h2>
<button type="button" onClick={() => setSettingsOpen(false)} className="rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-6 p-5">
{/* Readiness checklist */}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">Ready to publish?</p>
<div className="space-y-2">
{readinessChecks.map((check) => (
<div key={check.label} className={`flex items-start gap-3 rounded-xl p-3 ${check.ok ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-amber-500/10 border border-amber-500/20'}`}>
<span className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-500/25 text-emerald-300' : 'bg-amber-500/25 text-amber-300'}`}>
{check.ok ? '✓' : '!'}
</span>
<div>
<p className="text-sm font-medium text-white/85">{check.label}</p>
<p className="text-xs text-white/40">{check.hint}</p>
</div>
</div>
))}
</div>
</div>
{/* Publish actions */}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">Publish</p>
<div className="space-y-2">
<button type="button" onClick={() => { void persistStory('publish_now'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl bg-sky-500 px-4 py-3 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 disabled:opacity-50">Publish now</button>
<button type="button" onClick={() => { void persistStory('save_draft'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm text-white/75 transition hover:bg-white/[0.09] disabled:opacity-50">Save as draft</button>
<button type="button" onClick={() => { void persistStory('submit_review'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-200 transition hover:bg-amber-400/20 disabled:opacity-50">Submit for review</button>
{scheduledFor && (
<button type="button" onClick={() => { void persistStory('schedule_publish'); setSettingsOpen(false); }} disabled={isSubmitting} className="w-full rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm text-sky-200 transition hover:bg-sky-400/20 disabled:opacity-50">Schedule publish</button>
)}
</div>
</div>
{/* Cover image */}
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-widest text-white/35">Cover image</p>
<div className="flex items-center gap-3">
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="text-xs text-sky-400 underline-offset-2 transition-colors hover:underline">Upload file</button>
{coverImage && <button type="button" onClick={() => setCoverImage('')} className="text-xs text-rose-400 underline-offset-2 transition-colors hover:underline">Remove</button>}
</div>
</div>
<input value={coverImage} onChange={(e) => setCoverImage(e.target.value)} placeholder="Paste an image URL…" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
{coverImage && <img src={coverImage} alt="Cover preview" className="mt-3 h-36 w-full rounded-xl object-cover" />}
</div>
{/* Format */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Format</p>
<NovaSelect value={storyType} onChange={(val) => setStoryType(val)} options={storyTypes.map((t) => ({ value: t.slug, label: t.name }))} />
</div>
{/* Tags */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Tags</p>
<input value={tagsCsv} onChange={(e) => setTagsCsv(e.target.value)} placeholder="art direction, tutorial, process" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
{tagsError ? <p className="mt-1 text-xs text-rose-400">{tagsError}</p> : <p className="mt-1 text-xs text-white/30">Comma-separated. New tags created automatically.</p>}
</div>
{/* Status + schedule */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Workflow</p>
<NovaSelect value={status} onChange={(val) => setStatus(val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'pending_review', label: 'Pending Review' }, { value: 'published', label: 'Published' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'archived', label: 'Archived' }]} />
<input type="datetime-local" value={scheduledFor} onChange={(e) => setScheduledFor(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white focus:border-white/20 focus:outline-none" />
</div>
{/* SEO */}
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-white/35">SEO & social</p>
<div className="space-y-2">
<input value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} placeholder="Meta title (defaults to story title)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<textarea value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} rows={3} placeholder="Meta description (defaults to excerpt)" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={canonicalUrl} onChange={(e) => setCanonicalUrl(e.target.value)} placeholder="Canonical URL (optional)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={ogImage} onChange={(e) => setOgImage(e.target.value)} placeholder="OG image URL (defaults to cover)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
</div>
</div>
{/* Quick links */}
{storyId && (
<div className="space-y-2">
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white">Preview story</a>
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="block rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-center text-sm text-white/65 transition hover:bg-white/[0.08] hover:text-white">View analytics</a>
</div>
)}
</div>
</div>
</div>
</>
)}
{/* ── Artwork picker modal ──────────────────────────────────────────── */}
{artworkModalOpen && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 backdrop-blur-sm sm:items-center">
<div className="w-full max-w-2xl overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.99))] shadow-[0_24px_80px_rgba(3,7,18,0.7)]">
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<h3 className="font-semibold text-white">Embed an artwork</h3>
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded-full p-1.5 text-white/40 transition-colors hover:bg-white/[0.07] hover:text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-6">
<input
value={artworkQuery}
onChange={(e) => setArtworkQuery(e.target.value)}
className="mb-4 w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
placeholder="Search your artworks…"
autoFocus
/>
<div className="grid max-h-72 gap-3 overflow-y-auto sm:grid-cols-3">
{artworkResults.map((item) => (
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="overflow-hidden rounded-xl border border-white/10 bg-white/[0.04] text-left transition hover:border-sky-400/40 hover:shadow-lg">
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-24 w-full object-cover" />}
<div className="p-2">
<p className="line-clamp-1 text-xs font-medium text-white/80">{item.title}</p>
</div>
</button>
))}
{artworkResults.length === 0 && artworkQuery.length > 0 && (
<p className="col-span-3 py-8 text-center text-sm text-white/35">No artworks found for &ldquo;{artworkQuery}&rdquo;</p>
)}
</div>
</div>
</div> </div>
</div> </div>
)} )}
{/* Hidden file inputs */}
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import NovaSelect from '../ui/NovaSelect'
const TYPE_LABELS = { const TYPE_LABELS = {
style: 'Style', style: 'Style',
@@ -208,15 +209,12 @@ export default function NovaCardPresetPicker({
required required
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:border-sky-400/40" className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:border-sky-400/40"
/> />
<select <NovaSelect
value={captureType} value={captureType}
onChange={(e) => setCaptureType(e.target.value)} onChange={(value) => setCaptureType(value)}
className="w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm text-white outline-none focus:border-sky-400/40" options={typeKeys.map((type) => ({ value: type, label: TYPE_LABELS[type] }))}
> searchable={false}
{typeKeys.map((type) => ( />
<option key={type} value={type}>{TYPE_LABELS[type]}</option>
))}
</select>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"

View File

@@ -1,9 +1,10 @@
import React, { forwardRef } from 'react' import React, { Children } from 'react'
import NovaSelect from './NovaSelect'
/** /**
* Nova Select styled native <select> * Legacy Select wrapper.
* *
* Accepts the same options API as a plain <select>: * Accepts the same options API as a plain select:
* - Pass children (<option>, <optgroup>) directly, OR * - Pass children (<option>, <optgroup>) directly, OR
* - Pass `options` array of { value, label } and optional `placeholder` * - Pass `options` array of { value, label } and optional `placeholder`
* *
@@ -13,82 +14,46 @@ import React, { forwardRef } from 'react'
* @prop {string} error - validation error * @prop {string} error - validation error
* @prop {string} hint - helper text * @prop {string} hint - helper text
* @prop {boolean} required - asterisk on label * @prop {boolean} required - asterisk on label
* @prop {string} size - 'sm' | 'md' | 'lg' * @prop {string} size - ignored, kept for backward compatibility
*/ */
const Select = forwardRef(function Select( function Select({ label, error, hint, required, options, placeholder, size = 'md', id, className = '', children, ...rest }) {
{ label, error, hint, required, options, placeholder, size = 'md', id, className = '', children, style, ...rest }, void size
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const sizeClass = { const normalizedOptions = options || Children.toArray(children).flatMap((child) => {
sm: 'py-1.5 text-xs', if (!React.isValidElement(child)) return []
md: 'py-2.5 text-sm',
lg: 'py-3 text-base',
}[size] ?? 'py-2.5 text-sm'
const inputClass = [ if (child.type === 'optgroup') {
'block w-full rounded-xl border bg-white/[0.06] text-white', const groupLabel = child.props.label
'pl-3.5 pr-9', return Children.toArray(child.props.children)
'appearance-none cursor-pointer', .filter((optionChild) => React.isValidElement(optionChild))
'bg-no-repeat bg-right', .map((optionChild) => ({
'transition-all duration-150', value: optionChild.props.value,
'focus:outline-none focus:ring-2 focus:ring-offset-0', label: optionChild.props.children,
error group: groupLabel,
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40' disabled: optionChild.props.disabled,
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40', }))
'disabled:opacity-50 disabled:cursor-not-allowed', }
sizeClass,
className, return [{
].join(' ') value: child.props.value,
label: child.props.children,
disabled: child.props.disabled,
}]
})
return ( return (
<div className="flex flex-col gap-1.5"> <NovaSelect
{label && ( id={id}
<label htmlFor={inputId} className="text-sm font-medium text-white/85"> label={label}
{label} error={error}
{required && <span className="text-red-400 ml-1">*</span>} hint={hint}
</label> required={required}
)} options={normalizedOptions}
placeholder={placeholder}
<div className="relative"> className={className}
<select {...rest}
id={inputId} />
ref={ref}
className={inputClass}
aria-invalid={!!error}
style={{
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
backgroundImage: 'none',
...style,
}}
{...rest}
>
{placeholder && <option value="" className="bg-nova-900">{placeholder}</option>}
{options
? options.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900 text-white">
{o.label}
</option>
))
: children}
</select>
{/* Custom chevron */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
</div>
) )
}) }
export default Select export default Select

View File

@@ -15,7 +15,7 @@ import React from 'react'
* @param {string} [props.error] Validation error message * @param {string} [props.error] Validation error message
* @param {function} props.onRootChange Called with (rootId: string) * @param {function} props.onRootChange Called with (rootId: string)
* @param {function} props.onSubChange Called with (subId: string) * @param {function} props.onSubChange Called with (subId: string)
* @param {Array} [props.allRoots] All root options (for the hidden accessible select) * @param {Array} [props.allRoots] All root options (for cross-type fallback)
* @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info * @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info
*/ */
export default function CategorySelector({ export default function CategorySelector({
@@ -99,45 +99,6 @@ export default function CategorySelector({
</div> </div>
)} )}
{/* Accessible hidden select (screen readers / fallback) */}
<div className="sr-only">
<label htmlFor="category-root-select">Root category</label>
<select
id="category-root-select"
value={String(rootCategoryId || '')}
onChange={(e) => {
const nextRootId = String(e.target.value || '')
if (onRootChangeAll) {
const matched = allRoots.find((r) => String(r.id) === nextRootId)
onRootChangeAll(nextRootId, matched?.contentTypeValue ?? null)
} else {
onRootChange?.(nextRootId)
}
}}
>
<option value="">Select root category</option>
{rootOptions.map((root) => (
<option key={root.id} value={String(root.id)}>{root.name}</option>
))}
</select>
{hasSubcategories && (
<>
<label htmlFor="category-sub-select">Subcategory</label>
<select
id="category-sub-select"
value={String(subCategoryId || '')}
onChange={(e) => onSubChange?.(String(e.target.value || ''))}
>
<option value="">Select subcategory</option>
{selectedRoot.children.map((sub) => (
<option key={sub.id} value={String(sub.id)}>{sub.name}</option>
))}
</select>
</>
)}
</div>
{error && ( {error && (
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p> <p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
)} )}

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import TagInput from '../tags/TagInput' import TagInput from '../tags/TagInput'
import ScreenshotUploader from './ScreenshotUploader' import ScreenshotUploader from './ScreenshotUploader'
import Checkbox from '../../Components/ui/Checkbox' import Checkbox from '../../Components/ui/Checkbox'
import NovaSelect from '../ui/NovaSelect'
const STEP_PRELOAD = 1 const STEP_PRELOAD = 1
const STEP_DETAILS = 2 const STEP_DETAILS = 2
@@ -515,18 +516,12 @@ export default function UploadWizard({
<div> <div>
<label className="mb-2 block text-sm text-white/80">Category</label> <label className="mb-2 block text-sm text-white/80">Category</label>
<select <NovaSelect
value={details.category_id} value={details.category_id}
onChange={(event) => setDetails((prev) => ({ ...prev, category_id: event.target.value }))} onChange={(value) => setDetails((prev) => ({ ...prev, category_id: value }))}
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white" options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))}
> placeholder="Select category"
<option value="">Select category</option> />
{categoryOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react'
import NovaSelect from '../../../ui/NovaSelect'
function Button({ tone = 'default', children, ...props }) {
const tones = {
default: 'border-white/10 bg-white/[0.04] text-slate-200',
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
}
return <button type="button" {...props} className={`rounded-2xl border px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${tones[tone] || tones.default} ${props.className || ''}`.trim()}>{children}</button>
}
export default function WorldSuggestionActions({ item, busyKey = '', onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
const targets = Array.isArray(item?.section_targets) ? item.section_targets : []
const [selectedSection, setSelectedSection] = useState(item?.default_section_key || targets[0]?.value || '')
const isBusy = Boolean(busyKey)
useEffect(() => {
setSelectedSection(item?.state?.section_key || item?.default_section_key || targets[0]?.value || '')
}, [item?.default_section_key, item?.state?.section_key, targets])
if (!item) {
return null
}
if (item.state?.status === 'dismissed' || item.state?.status === 'not_relevant') {
return (
<div className="mt-4 flex flex-wrap gap-2">
<Button tone="sky" disabled={isBusy} onClick={() => onRestore(item)}>Restore</Button>
</div>
)
}
return (
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="grid gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Section target</span>
<div className="flex flex-col gap-2 sm:flex-row">
<NovaSelect value={selectedSection} onChange={(val) => setSelectedSection(val)} options={targets} searchable={false} className="min-w-0 flex-1" />
<Button tone="emerald" disabled={isBusy || !selectedSection} onClick={() => onAddSection(item, selectedSection, false)}>Add to section</Button>
<Button tone="amber" disabled={isBusy || !selectedSection} onClick={() => onAddFeatured(item, selectedSection)}>Add as featured</Button>
</div>
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
<Button tone={item.state?.status === 'pinned' ? 'sky' : 'default'} disabled={isBusy || !selectedSection} onClick={() => item.state?.status === 'pinned' ? onRestore(item) : onPin(item, selectedSection)}>{item.state?.status === 'pinned' ? 'Unpin' : 'Pin for later'}</Button>
<Button tone="default" disabled={isBusy} onClick={() => onDismiss(item)}>Dismiss</Button>
<Button tone="rose" disabled={isBusy} onClick={() => onNotRelevant(item)}>Not relevant</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import Checkbox from '../../../ui/Checkbox'
import NovaSelect from '../../../ui/NovaSelect'
function FilterSelect({ label, value, onChange, options = [] }) {
const novaOptions = [{ value: '', label: 'All' }, ...options.map((o) => ({ value: o.value, label: typeof o.count === 'number' ? `${o.label} (${o.count})` : o.label }))]
return (
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
<NovaSelect value={value || ''} onChange={onChange} options={novaOptions} searchable={false} />
</div>
)
}
export default function WorldSuggestionFilters({ filters, value, onChange }) {
return (
<div className="grid gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.9fr)]">
<FilterSelect label="Category" value={value.category} onChange={(nextValue) => onChange({ ...value, category: nextValue })} options={filters?.category_options || []} />
<FilterSelect label="Entity type" value={value.type} onChange={(nextValue) => onChange({ ...value, type: nextValue })} options={filters?.type_options || []} />
<FilterSelect label="Section target" value={value.section} onChange={(nextValue) => onChange({ ...value, section: nextValue })} options={filters?.section_options || []} />
<FilterSelect label="Sort" value={value.sort} onChange={(nextValue) => onChange({ ...value, sort: nextValue })} options={filters?.sort_options || []} />
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<Checkbox checked={value.challengeOnly} onChange={(event) => onChange({ ...value, challengeOnly: event.target.checked })} label="Challenge-linked only" />
</div>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<Checkbox checked={value.communityOnly} onChange={(event) => onChange({ ...value, communityOnly: event.target.checked })} label="Community submissions only" />
</div>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<Checkbox checked={value.recurringOnly} onChange={(event) => onChange({ ...value, recurringOnly: event.target.checked })} label="Recurring-history informed" />
</div>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<Checkbox checked={value.analyticsOnly} onChange={(event) => onChange({ ...value, analyticsOnly: event.target.checked })} label="Analytics-informed only" />
</div>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<Checkbox checked={value.showSuppressed} onChange={(event) => onChange({ ...value, showSuppressed: event.target.checked })} label="Show suppressed" />
</div>
</div>
)
}

Binary file not shown.

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
function createSearchDocumentContentType(string $name, int $sortOrder = 0): int
{
return (int) DB::table('content_types')->insertGetId([
'name' => $name,
'slug' => Str::slug($name) . '-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
'sort_order' => $sortOrder,
]);
}
function createSearchDocumentCategory(int $contentTypeId, string $name, int $sortOrder = 0): int
{
return (int) DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => $name,
'slug' => Str::slug($name) . '-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => $sortOrder,
'created_at' => now(),
'updated_at' => now(),
]);
}
it('indexes all attached categories and content types while preserving a primary category', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
]);
$wallpapersId = createSearchDocumentContentType('Wallpapers');
$digitalArtId = createSearchDocumentContentType('Digital Art');
$fantasyId = createSearchDocumentCategory($wallpapersId, 'Fantasy', 10);
$mattePaintingId = createSearchDocumentCategory($digitalArtId, 'Matte Painting', 20);
$artwork->categories()->sync([$mattePaintingId, $fantasyId]);
$payload = $artwork->fresh(['categories.contentType'])->toSearchableArray();
$fantasySlug = (string) DB::table('categories')->where('id', $fantasyId)->value('slug');
$mattePaintingSlug = (string) DB::table('categories')->where('id', $mattePaintingId)->value('slug');
$wallpapersSlug = (string) DB::table('content_types')->where('id', $wallpapersId)->value('slug');
$digitalArtSlug = (string) DB::table('content_types')->where('id', $digitalArtId)->value('slug');
expect($payload['category'])->toBe($fantasySlug)
->and($payload['content_type'])->toBe($wallpapersSlug)
->and($payload['categories'])->toBe([$fantasySlug, $mattePaintingSlug])
->and($payload['content_types'])->toBe([$wallpapersSlug, $digitalArtSlug]);
});

View File

@@ -0,0 +1,48 @@
<?php
use App\Http\Controllers\Web\BrowseGalleryController;
it('sorts latest content-type gallery pages by published date instead of draft creation date', function (): void {
$sortMap = (new ReflectionClass(BrowseGalleryController::class))->getConstant('SORT_MAP');
expect($sortMap['latest'] ?? null)->toBe(['published_at_ts:desc']);
expect($sortMap['oldest'] ?? null)->toBe(['published_at_ts:asc']);
});
it('uses published date as the recency tie-breaker on default content-type explore pages', function (): void {
$sortMap = (new ReflectionClass(BrowseGalleryController::class))->getConstant('SORT_MAP');
$cacheVersion = (new ReflectionClass(BrowseGalleryController::class))->getConstant('CACHE_VERSION');
expect($sortMap['trending'] ?? null)->toBe([
'trending_score_24h:desc',
'trending_score_7d:desc',
'favorites_count:desc',
'published_at_ts:desc',
]);
expect($sortMap['fresh'] ?? null)->toBe([
'published_at_ts:desc',
'trending_score_7d:desc',
'favorites_count:desc',
]);
expect($cacheVersion)->toBe('v4');
});
it('anchors category gallery filters to the content type and all descendant category slugs', function (): void {
$controller = app(BrowseGalleryController::class);
$method = new ReflectionMethod(BrowseGalleryController::class, 'categoryPageFilterExpression');
$method->setAccessible(true);
$filter = $method->invoke($controller, 'skins', ['audio', 'winamp', 'aplayer']);
expect($filter)->toBe(
'is_public = true AND is_approved = true AND '
. '(content_type = "skins" OR content_types = "skins") '
. 'AND ('
. '(category = "audio" OR categories = "audio") OR '
. '(category = "winamp" OR categories = "winamp") OR '
. '(category = "aplayer" OR categories = "aplayer")'
. ')'
);
});

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$legacyDatabasePath = base_path('test-results/legacy-password-export.sqlite');
$legacyDirectory = dirname($legacyDatabasePath);
if (! is_dir($legacyDirectory)) {
mkdir($legacyDirectory, 0777, true);
}
@unlink($legacyDatabasePath);
touch($legacyDatabasePath);
config()->set('database.connections.legacy', [
'driver' => 'sqlite',
'database' => $legacyDatabasePath,
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('legacy');
Schema::connection('legacy')->create('users', function (Blueprint $table): void {
$table->unsignedBigInteger('user_id')->primary();
$table->string('password2')->nullable();
$table->string('password')->nullable();
$table->unsignedTinyInteger('should_migrate')->default(0);
});
});
afterEach(function (): void {
Schema::connection('legacy')->dropIfExists('users');
$legacyDatabasePath = base_path('test-results/legacy-password-export.sqlite');
@unlink($legacyDatabasePath);
$sqlPath = base_path('test-results/export-legacy-passwords.sql');
@unlink($sqlPath);
});
it('exports only legacy users flagged with should_migrate=1', function (): void {
DB::connection('legacy')->table('users')->insert([
[
'user_id' => 101,
'password2' => '$2y$12$abcdefghijklmnopqrstuvABCDEFGHIJKLMNOpqrstuvwxyz12345',
'password' => null,
'should_migrate' => 1,
],
[
'user_id' => 102,
'password2' => '$2y$12$zzzzzzzzzzzzzzzzzzzzzzABCDEFGHIJKLMNOpqrstuvwxyz12345',
'password' => null,
'should_migrate' => 0,
],
[
'user_id' => 103,
'password2' => 'abc123',
'password' => null,
'should_migrate' => 1,
],
]);
$sqlPath = base_path('test-results/export-legacy-passwords.sql');
$code = Artisan::call('skinbase:export-legacy-passwords', [
'--sql' => $sqlPath,
'--chunk' => 1,
]);
$output = Artisan::output();
$sql = file_get_contents($sqlPath);
expect($code)->toBe(0)
->and($output)->toContain('Wrote 1 rows to: ' . $sqlPath)
->and($sql)->not->toBeFalse()
->and($sql)->toContain('user_id=101')
->and($sql)->not->toContain('user_id=102')
->and($sql)->not->toContain('user_id=103')
->and($sql)->toContain('-- Exported: 1 user(s)');
});

View File

@@ -14,4 +14,6 @@ it('artworks scout index settings include maturity filter fields used by search
expect($filterableAttributes)->toContain('maturity_level'); expect($filterableAttributes)->toContain('maturity_level');
expect($filterableAttributes)->toContain('maturity_status'); expect($filterableAttributes)->toContain('maturity_status');
expect($filterableAttributes)->toContain('published_as_type'); expect($filterableAttributes)->toContain('published_as_type');
expect($filterableAttributes)->toContain('categories');
expect($filterableAttributes)->toContain('content_types');
}); });

View File

@@ -318,6 +318,116 @@ it('lets owners manage releases, contributors, milestones, and publishing throug
->and(GroupReleaseMilestone::query()->where('group_release_id', $release->id)->count())->toBe(1); ->and(GroupReleaseMilestone::query()->where('group_release_id', $release->id)->count())->toBe(1);
}); });
it('lets owners manage challenge outcomes and exposes them on public challenge pages', function () {
$owner = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create([
'visibility' => Group::VISIBILITY_PUBLIC,
]);
app(GroupMembershipService::class)->ensureOwnerMembership($group);
$winner = Artwork::factory()->for($owner, 'user')->create([
'group_id' => $group->id,
'uploaded_by_user_id' => $owner->id,
'primary_author_user_id' => $owner->id,
'artwork_status' => 'published',
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
'is_approved' => true,
'published_at' => now(),
]);
$finalist = Artwork::factory()->for($owner, 'user')->create([
'group_id' => $group->id,
'uploaded_by_user_id' => $owner->id,
'primary_author_user_id' => $owner->id,
'artwork_status' => 'published',
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
'is_approved' => true,
'published_at' => now(),
]);
$this->actingAs($owner)
->post(route('studio.groups.challenges.store', ['group' => $group]), [
'title' => 'Pixel Week Results',
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay()->toDateTimeString(),
'end_at' => now()->addDay()->toDateTimeString(),
'judging_mode' => 'curated',
])
->assertRedirect();
$challenge = GroupChallenge::query()->where('group_id', $group->id)->latest('id')->firstOrFail();
$this->actingAs($owner)
->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [
'artwork_id' => $winner->id,
])
->assertRedirect();
$this->actingAs($owner)
->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [
'artwork_id' => $finalist->id,
])
->assertRedirect();
$this->actingAs($owner)
->patch(route('studio.groups.challenges.update', ['group' => $group, 'challenge' => $challenge]), [
'title' => 'Pixel Week Results',
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay()->toDateTimeString(),
'end_at' => now()->addDay()->toDateTimeString(),
'judging_mode' => 'curated',
'outcomes' => [
[
'artwork_id' => $winner->id,
'outcome_type' => 'winner',
'position' => 1,
'sort_order' => 0,
'title_override' => 'Grand Winner',
'note' => 'Top overall result.',
],
[
'artwork_id' => $finalist->id,
'outcome_type' => 'finalist',
'sort_order' => 1,
'note' => 'Strong finalist showing.',
],
],
])
->assertRedirect();
$challenge->refresh();
expect($challenge->featured_artwork_id)->toBe($winner->id);
$this->assertDatabaseHas('group_challenge_outcomes', [
'group_challenge_id' => $challenge->id,
'artwork_id' => $winner->id,
'outcome_type' => 'winner',
]);
$this->assertDatabaseHas('group_challenge_outcomes', [
'group_challenge_id' => $challenge->id,
'artwork_id' => $finalist->id,
'outcome_type' => 'finalist',
]);
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupChallengeShow')
->where('challenge.outcome_sections.winner.items.0.title', $winner->title)
->where('challenge.outcome_sections.finalist.items.0.title', $finalist->title));
});
it('renders public release pages and the studio reputation dashboard with v4 payloads', function () { it('renders public release pages and the studio reputation dashboard with v4 payloads', function () {
$viewer = User::factory()->create(); $viewer = User::factory()->create();
$owner = User::factory()->create(); $owner = User::factory()->create();

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function createPublicWorldForProfileHistory(array $overrides = []): World
{
return World::factory()->create(array_merge([
'title' => 'World History ' . strtolower(fake()->bothify('####')),
'slug' => 'world-history-' . strtolower(fake()->bothify('####')),
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
], $overrides));
}
function createPublicArtworkForProfileHistory(User $creator, array $overrides = []): Artwork
{
return Artwork::factory()->for($creator)->create(array_merge([
'title' => 'World Artwork ' . strtolower(fake()->bothify('####')),
'slug' => 'world-artwork-' . strtolower(fake()->bothify('####')),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
], $overrides));
}
it('exposes normalized world history on public profile pages', function (): void {
$creator = User::factory()->create([
'username' => 'worldhist-' . strtolower(fake()->bothify('####')),
]);
$groupOwner = User::factory()->create();
$group = Group::factory()->create([
'owner_user_id' => $groupOwner->id,
'visibility' => Group::VISIBILITY_PUBLIC,
'status' => Group::LIFECYCLE_ACTIVE,
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Autumn Finals',
'slug' => 'autumn-finals-' . strtolower(fake()->bothify('####')),
'summary' => 'Final round challenge.',
'description' => 'Final round challenge.',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDays(2),
'end_at' => now()->addDays(2),
'created_by_user_id' => $groupOwner->id,
]);
$world = createPublicWorldForProfileHistory([
'title' => 'Autumn Finals 2026',
'slug' => 'autumn-finals-2026-' . strtolower(fake()->bothify('####')),
'linked_challenge_id' => $challenge->id,
'recurrence_key' => 'autumn-finals',
'edition_year' => 2026,
]);
$artwork = createPublicArtworkForProfileHistory($creator, [
'title' => 'Autumn Skyline',
'slug' => 'autumn-skyline-' . strtolower(fake()->bothify('####')),
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => false,
'reviewed_at' => now()->subDay(),
]);
GroupChallengeOutcome::query()->create([
'group_challenge_id' => $challenge->id,
'artwork_id' => $artwork->id,
'user_id' => $creator->id,
'outcome_type' => GroupChallengeOutcome::TYPE_WINNER,
'awarded_by_user_id' => $groupOwner->id,
'awarded_at' => now()->subHour(),
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('worldHistory.summary.available', true)
->where('worldHistory.summary.world_appearances', 1)
->where('worldHistory.summary.worlds_joined', 1)
->where('worldHistory.summary.winner_appearances', 1)
->where('worldHistory.summary.most_recent_world_activity.primary_recognition.key', 'winner')
->where('worldHistory.entries.0.world.title', 'Autumn Finals 2026')
->where('worldHistory.entries.0.primary_recognition.key', 'winner')
->where('worldHistory.entries.0.recognition_keys.0', 'winner')
->where('worldHistory.entries.0.challenge.title', 'Autumn Finals')
->where('worldHistory.entries.0.linked_artwork.title', 'Autumn Skyline'));
});
it('filters stale public world rewards while preserving owner-only context counts', function (): void {
$creator = User::factory()->create([
'username' => 'worldfilter-' . strtolower(fake()->bothify('####')),
]);
$staleWorld = createPublicWorldForProfileHistory([
'title' => 'Removed Entry World',
'slug' => 'removed-entry-world-' . strtolower(fake()->bothify('####')),
]);
$pendingWorld = createPublicWorldForProfileHistory([
'title' => 'Pending Entry World',
'slug' => 'pending-entry-world-' . strtolower(fake()->bothify('####')),
]);
$removedArtwork = createPublicArtworkForProfileHistory($creator, [
'title' => 'Removed Entry Artwork',
'slug' => 'removed-entry-artwork-' . strtolower(fake()->bothify('####')),
]);
$pendingArtwork = createPublicArtworkForProfileHistory($creator, [
'title' => 'Pending Entry Artwork',
'slug' => 'pending-entry-artwork-' . strtolower(fake()->bothify('####')),
]);
$removedSubmission = WorldSubmission::query()->create([
'world_id' => $staleWorld->id,
'artwork_id' => $removedArtwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_REMOVED,
'removed_at' => now()->subHour(),
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $staleWorld->id,
'artwork_id' => $removedArtwork->id,
'world_submission_id' => $removedSubmission->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
'granted_at' => now()->subMinutes(30),
]);
WorldSubmission::query()->create([
'world_id' => $pendingWorld->id,
'artwork_id' => $pendingArtwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_PENDING,
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('worldHistory.summary.available', false)
->where('worldHistory.entries', [])
->where('worldHistory.owner_context', null));
$this->actingAs($creator)
->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('worldHistory.summary.available', false)
->where('worldHistory.owner_context.pending_submissions', 1)
->where('worldHistory.owner_context.removed_or_blocked_submissions', 1)
->where('worldHistory.owner_context.hidden_public_entries', 1));
});
it('supports the canonical worlds profile tab route', function (): void {
$creator = User::factory()->create([
'username' => 'worldtab-' . strtolower(fake()->bothify('####')),
]);
$world = createPublicWorldForProfileHistory([
'title' => 'Featured Worlds 2026',
'slug' => 'featured-worlds-2026-' . strtolower(fake()->bothify('####')),
'edition_year' => 2026,
]);
$artwork = createPublicArtworkForProfileHistory($creator, [
'title' => 'Featured Worlds Artwork',
'slug' => 'featured-worlds-artwork-' . strtolower(fake()->bothify('####')),
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'featured_at' => now()->subMinutes(10),
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'world_submission_id' => $submission->id,
'reward_type' => 'featured',
'grant_source' => 'automatic',
'granted_at' => now()->subMinutes(5),
]);
$this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('initialTab', 'worlds')
->where('profileTabUrls.worlds', url('/@' . strtolower((string) $creator->username) . '/worlds'))
->where('worldHistory.summary.available', true)
->where('worldHistory.entries.0.primary_recognition.key', 'featured'));
});

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRewardGrant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
it('exposes world rewards on public profile pages', function (): void {
$creator = User::factory()->create([
'username' => 'profilerewards-' . strtolower(fake()->bothify('####')),
]);
$worldOwner = User::factory()->create();
$world = World::factory()->create([
'title' => 'Spring Vibes 2026',
'slug' => 'spring-vibes-2026-' . strtolower(fake()->bothify('####')),
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'created_by_user_id' => $worldOwner->id,
]);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Profile Reward Artwork',
'slug' => 'profile-reward-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'reward_type' => 'featured',
'grant_source' => 'automatic',
'granted_at' => now()->subHour(),
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('worldRewards.count', 1)
->where('worldRewards.items.0.badge_label', 'Spring Vibes 2026 Featured'));
});
it('prioritizes higher-signal world rewards ahead of participation on profile reward grids while keeping recents chronological', function (): void {
$creator = User::factory()->create([
'username' => 'profilepriority-' . strtolower(fake()->bothify('####')),
]);
$worldOwner = User::factory()->create();
$participantWorld = World::factory()->create([
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026-' . strtolower(fake()->bothify('####')),
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'created_by_user_id' => $worldOwner->id,
]);
$winnerWorld = World::factory()->create([
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026-' . strtolower(fake()->bothify('####')),
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'created_by_user_id' => $worldOwner->id,
]);
$participantArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Participant Artwork',
'slug' => 'participant-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$winnerArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Winner Artwork',
'slug' => 'winner-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $participantWorld->id,
'artwork_id' => $participantArtwork->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
'granted_at' => now()->subMinutes(10),
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $winnerWorld->id,
'artwork_id' => $winnerArtwork->id,
'reward_type' => 'winner',
'grant_source' => 'manual',
'granted_at' => now()->subHour(),
]);
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('worldRewards.count', 2)
->where('worldRewards.items.0.badge_label', 'Pixel Week 2026 Winner')
->where('worldRewards.items.1.badge_label', 'Retro Month 2026 Participant')
->where('worldRewards.recent.0.badge_label', 'Retro Month 2026 Participant'));
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\UploadBatch;
use App\Models\UploadBatchItem;
use App\Models\User;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\TagService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function uploadQueueArtwork(array $attributes = []): Artwork
{
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attributes));
}
function uploadQueueCategory(string $typeName = 'Photography', string $categoryName = 'Portraits'): Category
{
$suffix = Str::lower(Str::random(6));
$contentType = ContentType::query()->create([
'name' => $typeName,
'slug' => Str::slug($typeName) . '-' . $suffix,
'order' => 1,
'hide_from_menu' => false,
]);
return Category::query()->create([
'content_type_id' => $contentType->id,
'name' => $categoryName,
'slug' => Str::slug($categoryName) . '-' . $suffix,
'is_active' => true,
'sort_order' => 1,
]);
}
beforeEach(function () {
if (DB::connection()->getDriverName() === 'sqlite') {
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
return max($args);
}, -1);
}
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
test('studio upload queue page loads', function () {
$this->get('/studio/upload-queue')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioUploadQueue')
->where('title', 'Upload Queue')
->has('queue.status_options')
->has('queue.sort_options'));
});
test('upload queue batch creation creates draft artworks and queue items with defaults', function () {
$contentType = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
'order' => 1,
'hide_from_menu' => false,
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'Landscapes',
'slug' => 'landscapes',
'is_active' => true,
'sort_order' => 1,
]);
$response = $this->postJson('/api/studio/upload-queue/batches', [
'name' => 'Spring Set',
'files' => [
['name' => 'forest-light.png'],
['name' => 'city-night.webp'],
],
'defaults' => [
'category_id' => $category->id,
'tags' => ['forest', 'set'],
'visibility' => 'unlisted',
],
]);
$response->assertCreated()
->assertJsonPath('batch.name', 'Spring Set')
->assertJsonCount(2, 'items');
$batch = UploadBatch::query()->firstOrFail();
expect($batch->total_items)->toBe(2);
$items = UploadBatchItem::query()->with(['artwork.categories', 'artwork.tags'])->get();
expect($items)->toHaveCount(2);
foreach ($items as $item) {
expect($item->artwork)->not->toBeNull()
->and($item->artwork->visibility)->toBe('unlisted')
->and($item->artwork->categories->pluck('id')->all())->toBe([$category->id])
->and($item->artwork->tags->pluck('slug')->sort()->values()->all())->toBe(['forest', 'set']);
}
});
test('upload finish updates queue item when batch item id is supplied', function () {
config()->set('forum_bot_protection.enabled', false);
config()->set('uploads.queue_derivatives', false);
config()->set('uploads.storage_root', storage_path('framework/testing/uploads'));
Queue::fake();
File::deleteDirectory((string) config('uploads.storage_root'));
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Queue batch',
'status' => 'uploading',
'total_items' => 1,
]);
$artwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'is_public' => false,
'is_approved' => false,
'published_at' => null,
'artwork_status' => 'draft',
]);
$item = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => 'queue-test.png',
]);
$sessionId = (string) Str::uuid();
$tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png');
$sourceImage = base_path('public/favicon/favicon-96x96.png');
File::ensureDirectoryExists(dirname($tmpPath));
File::copy($sourceImage, $tmpPath);
app(UploadSessionRepository::class)->create(
$sessionId,
$this->user->id,
$tmpPath,
UploadSessionStatus::TMP,
'127.0.0.1'
);
$token = app(UploadTokenService::class)->generate($sessionId, $this->user->id);
$this->withHeader('X-Upload-Token', $token)
->postJson('/api/uploads/finish', [
'session_id' => $sessionId,
'artwork_id' => $artwork->id,
'batch_item_id' => $item->id,
'file_name' => 'queue-test.png',
])
->assertOk()
->assertJsonPath('artwork_id', $artwork->id)
->assertJsonPath('status', UploadSessionStatus::PROCESSED);
$item->refresh();
expect($item->status)->toBe('processing')
->and($item->processing_stage)->toBe('maturity_check');
});
test('upload queue bulk publish only publishes ready items', function () {
$contentType = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
'order' => 1,
'hide_from_menu' => false,
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'Portraits',
'slug' => 'portraits',
'is_active' => true,
'sort_order' => 1,
]);
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Publish batch',
'status' => 'processing',
'total_items' => 2,
]);
$readyArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Ready artwork',
'file_name' => 'ready.webp',
'file_path' => 'artworks/test/ready.webp',
'hash' => str_repeat('a', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'visibility' => 'public',
'is_public' => false,
'is_approved' => false,
'artwork_status' => 'draft',
'published_at' => null,
'maturity_status' => 'clear',
'maturity_ai_status' => 'succeeded',
]);
$readyArtwork->categories()->sync([$category->id]);
$blockedArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Blocked artwork',
'file_name' => 'blocked.webp',
'file_path' => 'artworks/test/blocked.webp',
'hash' => str_repeat('b', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'visibility' => 'public',
'is_public' => false,
'is_approved' => false,
'artwork_status' => 'draft',
'published_at' => null,
'maturity_status' => 'suspected',
'maturity_ai_status' => 'succeeded',
]);
$blockedArtwork->categories()->sync([$category->id]);
$readyItem = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $readyArtwork->id,
'original_filename' => 'ready.webp',
]);
$blockedItem = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $blockedArtwork->id,
'original_filename' => 'blocked.webp',
]);
$this->postJson('/api/studio/upload-queue/bulk', [
'action' => 'publish',
'item_ids' => [$readyItem->id, $blockedItem->id],
])
->assertOk()
->assertJsonPath('success', 1)
->assertJsonPath('failed', 1);
$readyArtwork->refresh();
$blockedArtwork->refresh();
expect($readyArtwork->artwork_status)->toBe('published')
->and($readyArtwork->published_at)->not->toBeNull()
->and($blockedArtwork->artwork_status)->toBe('draft')
->and($blockedArtwork->published_at)->toBeNull();
});
test('upload queue bulk delete only affects owned drafts', function () {
$otherUser = User::factory()->create();
$ownedBatch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Delete batch',
'status' => 'processing',
'total_items' => 1,
]);
$ownedArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
]);
$foreignBatch = UploadBatch::query()->create([
'user_id' => $otherUser->id,
'name' => 'Foreign batch',
'status' => 'processing',
'total_items' => 1,
]);
$foreignArtwork = uploadQueueArtwork([
'user_id' => $otherUser->id,
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
]);
$ownedItem = UploadBatchItem::query()->create([
'upload_batch_id' => $ownedBatch->id,
'user_id' => $this->user->id,
'artwork_id' => $ownedArtwork->id,
'original_filename' => 'owned.webp',
]);
$foreignItem = UploadBatchItem::query()->create([
'upload_batch_id' => $foreignBatch->id,
'user_id' => $otherUser->id,
'artwork_id' => $foreignArtwork->id,
'original_filename' => 'foreign.webp',
]);
$this->postJson('/api/studio/upload-queue/bulk', [
'action' => 'delete',
'item_ids' => [$ownedItem->id, $foreignItem->id],
'confirm' => 'DELETE',
])
->assertOk()
->assertJsonPath('success', 1);
$ownedItem->refresh();
$foreignItem->refresh();
expect($ownedItem->status)->toBe('deleted')
->and(Artwork::withTrashed()->find($ownedArtwork->id)?->trashed())->toBeTrue()
->and($foreignItem->status)->not->toBe('deleted')
->and(Artwork::find($foreignArtwork->id))->not->toBeNull();
});
test('upload queue retry rejects drafts without processed media', function () {
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Retry batch',
'status' => 'completed_with_errors',
'total_items' => 1,
]);
$artwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'file_path' => '',
'hash' => '',
]);
$item = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => 'retry.webp',
'status' => 'failed',
]);
$this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry')
->assertStatus(422)
->assertJsonValidationErrors(['item']);
});
test('upload queue item failure does not break the rest of the batch', function () {
$response = $this->postJson('/api/studio/upload-queue/batches', [
'name' => 'Mixed batch',
'files' => [
['name' => 'good.webp'],
['name' => 'bad.webp'],
],
]);
$batchId = (int) $response->json('batch.id');
$items = UploadBatchItem::query()->where('upload_batch_id', $batchId)->orderBy('id')->get();
expect($items)->toHaveCount(2);
$this->postJson('/api/studio/upload-queue/items/' . $items[1]->id . '/fail', [
'error_code' => 'invalid_file',
'error_message' => 'Invalid image payload.',
])->assertOk();
$payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batchId]);
$queueItems = collect($payload['items'])->keyBy('id');
expect($queueItems)->toHaveCount(2)
->and($queueItems[$items[0]->id]['status'])->not->toBe('failed')
->and($queueItems[$items[1]->id]['status'])->toBe('failed')
->and($queueItems[$items[1]->id]['error_message'])->toBe('Invalid image payload.');
});
test('upload queue processing states update correctly per item', function () {
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Processing batch',
'status' => 'uploading',
'total_items' => 1,
]);
$artwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Processing artwork',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING,
]);
$item = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => 'processing.webp',
'status' => 'uploaded',
'processing_stage' => 'queued',
]);
$queue = app(UploadQueueService::class);
$queued = $queue->markItemProcessingQueued($item->id);
expect($queued->status)->toBe('processing')
->and($queued->processing_stage)->toBe('thumbnails');
$artwork->forceFill([
'file_name' => 'processing.webp',
'file_path' => 'artworks/test/processing.webp',
'hash' => str_repeat('c', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
])->saveQuietly();
$processed = $queue->markItemMediaProcessed($item->id);
expect($processed->status)->toBe('processing')
->and($processed->processing_stage)->toBe('maturity_check');
});
test('upload queue publish readiness respects metadata and maturity review rules', function () {
$category = uploadQueueCategory();
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Readiness batch',
'status' => 'processing',
'total_items' => 4,
]);
$readyArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Ready artwork',
'file_name' => 'ready.webp',
'file_path' => 'artworks/test/ready.webp',
'hash' => str_repeat('d', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
]);
$readyArtwork->categories()->sync([$category->id]);
$missingMetadataArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => '',
'file_name' => 'metadata.webp',
'file_path' => 'artworks/test/metadata.webp',
'hash' => str_repeat('e', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
]);
$missingMetadataArtwork->categories()->sync([$category->id]);
$reviewArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Review artwork',
'file_name' => 'review.webp',
'file_path' => 'artworks/test/review.webp',
'hash' => str_repeat('f', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
]);
$reviewArtwork->categories()->sync([$category->id]);
$processingArtwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Processing artwork',
'file_name' => 'pending',
'file_path' => '',
'hash' => '',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING,
]);
$processingArtwork->categories()->sync([$category->id]);
$items = collect([
[$readyArtwork, 'ready.webp'],
[$missingMetadataArtwork, 'metadata.webp'],
[$reviewArtwork, 'review.webp'],
[$processingArtwork, 'processing.webp'],
])->map(function (array $entry) use ($batch) {
[$artwork, $filename] = $entry;
return UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => $filename,
'status' => 'processing',
'processing_stage' => 'maturity_check',
]);
});
$payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batch->id]);
$byFilename = collect($payload['items'])->keyBy('original_filename');
expect($byFilename['ready.webp']['status'])->toBe('ready')
->and($byFilename['ready.webp']['is_ready_to_publish'])->toBeTrue()
->and($byFilename['metadata.webp']['status'])->toBe('needs_metadata')
->and($byFilename['metadata.webp']['is_ready_to_publish'])->toBeFalse()
->and($byFilename['review.webp']['status'])->toBe('needs_review')
->and($byFilename['review.webp']['is_ready_to_publish'])->toBeFalse()
->and($byFilename['processing.webp']['status'])->toBe('processing')
->and($byFilename['processing.webp']['is_ready_to_publish'])->toBeFalse();
});
test('upload queue retry works for safe failure cases', function () {
Queue::fake();
$category = uploadQueueCategory();
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'Retry safe batch',
'status' => 'completed_with_errors',
'total_items' => 1,
]);
$artwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Retry safe artwork',
'file_name' => 'retry-safe.webp',
'file_path' => 'artworks/test/retry-safe.webp',
'hash' => str_repeat('g', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING,
]);
$artwork->categories()->sync([$category->id]);
$item = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => 'retry-safe.webp',
'status' => 'failed',
'processing_stage' => 'finalized',
'error_code' => 'vision_timeout',
'error_message' => 'Vision analysis timed out.',
]);
$this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry')
->assertOk()
->assertJsonPath('ok', true);
$item->refresh();
expect($item->status)->toBe('processing')
->and($item->processing_stage)->toBe('maturity_check')
->and($item->error_code)->toBeNull()
->and($item->error_message)->toBeNull();
Queue::assertPushed(AutoTagArtworkJob::class);
Queue::assertPushed(DetectArtworkMaturityJob::class);
Queue::assertPushed(GenerateArtworkEmbeddingJob::class);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class);
});
test('upload queue AI generation does not overwrite manual metadata silently', function () {
Queue::fake();
$category = uploadQueueCategory();
$batch = UploadBatch::query()->create([
'user_id' => $this->user->id,
'name' => 'AI batch',
'status' => 'completed_with_errors',
'total_items' => 1,
]);
$artwork = uploadQueueArtwork([
'user_id' => $this->user->id,
'title' => 'Manual title',
'description' => 'Manual description',
'file_name' => 'manual.webp',
'file_path' => 'artworks/test/manual.webp',
'hash' => str_repeat('h', 64),
'thumb_ext' => 'webp',
'file_ext' => 'webp',
'artwork_status' => 'draft',
'is_public' => false,
'published_at' => null,
'maturity_status' => ArtworkMaturityService::STATUS_CLEAR,
'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED,
]);
$artwork->categories()->sync([$category->id]);
app(TagService::class)->syncStudioTags($artwork, ['manual-tag']);
$item = UploadBatchItem::query()->create([
'upload_batch_id' => $batch->id,
'user_id' => $this->user->id,
'artwork_id' => $artwork->id,
'original_filename' => 'manual.webp',
'status' => 'failed',
'processing_stage' => 'finalized',
'error_code' => 'metadata_failed',
'error_message' => 'AI metadata generation failed.',
]);
$this->postJson('/api/studio/upload-queue/bulk', [
'action' => 'generate_ai',
'item_ids' => [$item->id],
])
->assertOk()
->assertJsonPath('success', 1)
->assertJsonPath('failed', 0);
$artwork->refresh();
expect($artwork->title)->toBe('Manual title')
->and($artwork->description)->toBe('Manual description')
->and($artwork->categories()->pluck('categories.id')->all())->toBe([$category->id])
->and($artwork->tags()->pluck('tags.slug')->all())->toBe(['manual-tag']);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class);
});

View File

@@ -69,7 +69,7 @@ it('dispatches AI processing jobs after upload finish publishes successfully', f
->and($artwork->width)->toBeGreaterThan(0) ->and($artwork->width)->toBeGreaterThan(0)
->and($artwork->height)->toBeGreaterThan(0); ->and($artwork->height)->toBeGreaterThan(0);
expect(File::exists((string) $tmpPath))->toBeTrue(); expect(File::exists((string) $tmpPath))->toBeFalse();
}); });
it('blocks upload finish only when the hash already belongs to a published artwork', function () { it('blocks upload finish only when the hash already belongs to a published artwork', function () {

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\World;
use App\Models\WorldAnalyticsEvent;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function analyticsWorld(User $creator, array $attributes = []): World
{
$slugSuffix = Str::lower(Str::random(6));
return World::query()->create(array_merge([
'title' => 'Analytics World ' . $slugSuffix,
'slug' => 'analytics-world-' . $slugSuffix,
'tagline' => 'Measured campaign storytelling.',
'summary' => 'A world used to verify analytics reporting.',
'description' => 'Analytics world description',
'theme_key' => 'summer',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_CAMPAIGN,
'is_featured' => true,
'published_at' => now()->subDays(5),
'starts_at' => now()->subDays(3),
'ends_at' => now()->addDays(10),
'created_by_user_id' => $creator->id,
], $attributes));
}
it('records worlds analytics events through the public api', function (): void {
$creator = User::factory()->create();
$world = analyticsWorld($creator);
$this->postJson(route('api.worlds.analytics.events.store'), [
'world_id' => $world->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'entity_type' => 'world',
'entity_id' => $world->id,
'entity_title' => $world->title,
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_token' => 'guest-analytics-token',
])->assertAccepted()->assertJson(['ok' => true]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'entity_type' => 'world',
'entity_id' => $world->id,
'entity_title' => $world->title,
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'viewer_type' => 'guest',
'visitor_key' => hash('sha256', 'visitor:guest-analytics-token'),
]);
$this->postJson(route('api.worlds.analytics.events.store'), [
'world_id' => $world->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_token' => 'guest-analytics-impression',
])->assertAccepted()->assertJson(['ok' => true]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'viewer_type' => 'guest',
'visitor_key' => hash('sha256', 'visitor:guest-analytics-impression'),
]);
});
it('includes analytics summaries and edition comparison on studio world pages', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'analyticsmod-' . Str::lower(Str::random(6)),
]);
$groupOwner = User::factory()->create([
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->create([
'name' => 'Retro Group ' . Str::upper(Str::random(4)),
'slug' => 'retro-group-' . Str::lower(Str::random(4)),
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Retro Challenge ' . Str::upper(Str::random(4)),
'slug' => 'retro-challenge-' . Str::lower(Str::random(4)),
'summary' => 'A linked challenge for analytics verification.',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay(),
'end_at' => now()->addDays(7),
'created_by_user_id' => $groupOwner->id,
]);
$currentWorld = analyticsWorld($moderator, [
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026-' . Str::lower(Str::random(4)),
'recurrence_key' => 'retro-month',
'edition_year' => 2026,
'linked_challenge_id' => $challenge->id,
]);
$previousWorld = analyticsWorld($moderator, [
'title' => 'Retro Month 2025',
'slug' => 'retro-month-2025-' . Str::lower(Str::random(4)),
'recurrence_key' => 'retro-month',
'edition_year' => 2025,
'starts_at' => now()->subYear(),
'ends_at' => now()->subYear()->addDays(10),
'published_at' => now()->subYear()->subDays(2),
]);
$artwork = Artwork::factory()->for($moderator)->create([
'title' => 'Analytics Artwork',
'slug' => 'analytics-artwork-' . Str::lower(Str::random(4)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $currentWorld->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $moderator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
WorldRewardGrant::query()->create([
'user_id' => $moderator->id,
'world_id' => $currentWorld->id,
'artwork_id' => $artwork->id,
'reward_type' => 'winner',
'grant_source' => 'manual',
'granted_at' => now()->subHours(6),
]);
WorldRewardGrant::query()->create([
'user_id' => $moderator->id,
'world_id' => $previousWorld->id,
'artwork_id' => $artwork->id,
'reward_type' => 'featured',
'grant_source' => 'manual',
'granted_at' => now()->subYear(),
]);
collect([
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_key' => hash('sha256', 'visitor:impression-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_impression',
'section_key' => 'card',
'source_surface' => 'worlds_index',
'source_detail' => 'featured',
'visitor_key' => hash('sha256', 'visitor:impression-two'),
'occurred_at' => Carbon::now()->subHours(3),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'worlds_index',
'source_detail' => 'featured',
'visitor_key' => hash('sha256', 'visitor:viewer-two'),
'occurred_at' => Carbon::now()->subHours(3),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_clicked',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'entity_type' => 'world',
'entity_id' => $currentWorld->id,
'entity_title' => $currentWorld->title,
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'source_surface' => 'homepage_spotlight',
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_challenge_cta_clicked',
'section_key' => 'challenge',
'challenge_id' => $challenge->id,
'visitor_key' => hash('sha256', 'visitor:challenge-viewer'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_submission_created',
'section_key' => 'community_submissions',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:submission'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_submission_approved',
'section_key' => 'community_submissions',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:approval'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_reward_granted',
'section_key' => 'rewards',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:reward'),
'occurred_at' => Carbon::now()->subHours(1),
],
[
'world_id' => $previousWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'navigation',
'source_detail' => 'archive',
'visitor_key' => hash('sha256', 'visitor:archive-viewer'),
'occurred_at' => Carbon::now()->subYear(),
],
])->each(function (array $attributes) use ($currentWorld, $previousWorld): void {
$world = (int) $attributes['world_id'] === (int) $currentWorld->id ? $currentWorld : $previousWorld;
WorldAnalyticsEvent::query()->create(array_merge([
'world_slug' => $world->slug,
'world_type' => $world->type,
'recurrence_key' => $world->recurrence_key,
'edition_year' => $world->edition_year,
'viewer_type' => 'guest',
'user_id' => null,
'meta' => null,
], $attributes));
});
$this->actingAs($moderator)
->get(route('studio.worlds.edit', ['world' => $currentWorld->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('world.analytics.ranges.30d.summary.views', 2)
->where('world.analytics.ranges.30d.summary.unique_visitors', 2)
->where('world.analytics.ranges.30d.summary.promotion_impressions', 2)
->where('world.analytics.ranges.30d.summary.cta_clicks', 1)
->where('world.analytics.ranges.30d.summary.reward_grants', 1)
->where('world.analytics.ranges.30d.participation.live', 1)
->where('world.analytics.ranges.30d.sources.0.impressions', 1)
->where('world.analytics.ranges.30d.sources.0.clickthrough_rate', 1)
->where('world.analytics.ranges.30d.challenge.linked_challenge_id', $challenge->id)
->where('world.analytics.ranges.30d.challenge.click_to_submission_conversion', 1)
->where('world.analytics.ranges.30d.sources.0.source_surface', 'homepage_spotlight')
->where('world.analytics.edition_comparison.recurrence_key', 'retro-month')
->has('world.analytics.edition_comparison.editions', 2));
});

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Services\GroupChallengeService;
use App\Services\Worlds\WorldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function challengeLinkedWorld(?User $moderator = null, array $attributes = []): World
{
$moderator ??= User::factory()->create([
'role' => 'moderator',
'username' => 'worldchallenge-' . Str::lower(Str::random(6)),
]);
return World::factory()->create(array_merge([
'created_by_user_id' => $moderator->id,
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'accepts_submissions' => true,
'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
'submission_note_enabled' => true,
'community_section_enabled' => true,
'allow_readd_after_removal' => true,
'submission_starts_at' => now()->subDay(),
'submission_ends_at' => now()->addDays(7),
], $attributes));
}
function worldUpdatePayload(World $world, array $overrides = []): array
{
return array_merge([
'title' => $world->title,
'status' => $world->status,
'type' => $world->type,
'tagline' => $world->tagline,
'summary' => $world->summary,
'description' => $world->description,
'accepts_submissions' => (bool) $world->accepts_submissions,
'participation_mode' => $world->participation_mode,
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'community_section_enabled' => (bool) $world->community_section_enabled,
'allow_readd_after_removal' => (bool) $world->allow_readd_after_removal,
'is_featured' => (bool) $world->is_featured,
'is_active_campaign' => (bool) $world->is_active_campaign,
'is_homepage_featured' => (bool) $world->is_homepage_featured,
'is_recurring' => (bool) $world->is_recurring,
'cta_label' => $world->cta_label,
'cta_url' => $world->cta_url,
'badge_label' => $world->badge_label,
'badge_description' => $world->badge_description,
'badge_url' => $world->badge_url,
'linked_challenge_id' => $world->linked_challenge_id,
'show_linked_challenge_section' => (bool) ($world->show_linked_challenge_section ?? true),
'show_linked_challenge_entries' => (bool) ($world->show_linked_challenge_entries ?? true),
'show_linked_challenge_winners' => (bool) ($world->show_linked_challenge_winners ?? true),
'show_linked_challenge_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true),
'auto_grant_challenge_world_rewards' => (bool) ($world->auto_grant_challenge_world_rewards ?? true),
'challenge_teaser_override' => $world->challenge_teaser_override,
'relations' => [],
], $overrides);
}
function linkedGroupChallenge(Group $group, User $owner, array $attributes = []): GroupChallenge
{
return GroupChallenge::query()->create(array_merge([
'group_id' => $group->id,
'title' => 'Pixel Week Finals',
'slug' => 'pixel-week-finals-' . Str::lower(Str::random(6)),
'summary' => 'Challenge finale.',
'description' => 'Challenge finale description.',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay(),
'end_at' => now()->addDay(),
'created_by_user_id' => $owner->id,
'featured_artwork_id' => null,
], $attributes));
}
it('syncs winner rewards from linked challenge outcomes', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Challenge Winner Artwork',
'slug' => 'challenge-winner-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
'outcomes' => [[
'artwork_id' => $artwork->id,
'outcome_type' => 'winner',
'position' => 1,
'sort_order' => 0,
'title_override' => 'Grand Winner',
]],
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'world_submission_id' => $submission->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('syncs finalist rewards from linked challenge outcomes', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Challenge Finalist Artwork',
'slug' => 'challenge-finalist-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
'outcomes' => [[
'artwork_id' => $artwork->id,
'outcome_type' => 'finalist',
'sort_order' => 0,
'note' => 'Finalist award.',
]],
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'world_submission_id' => $submission->id,
'reward_type' => 'finalist',
'grant_source' => 'challenge',
]);
});
it('syncs challenge winner rewards when challenge relations are added to a world', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Relation Sync Artwork',
'slug' => 'relation-sync-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner, [
'featured_artwork_id' => $artwork->id,
]);
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
'relations' => [[
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]],
]));
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('syncs challenge winner rewards when a primary linked challenge is set on a world', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Primary Challenge Sync Artwork',
'slug' => 'primary-challenge-sync-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner, [
'featured_artwork_id' => $artwork->id,
]);
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
'linked_challenge_id' => $challenge->id,
]));
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('revokes challenge-sourced winner rewards when linked challenge winners are cleared', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Revoked Challenge Winner',
'slug' => 'revoked-challenge-winner',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
$challengeService = app(GroupChallengeService::class);
$challengeService->update($challenge, $groupOwner, ['featured_artwork_id' => $artwork->id]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'world_submission_id' => $submission->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
$challengeService->update($challenge->fresh(), $groupOwner, ['featured_artwork_id' => null]);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});

View File

@@ -5,23 +5,33 @@ declare(strict_types=1);
use App\Models\World; use App\Models\World;
use Database\Seeders\WorldLaunchSeeder; use Database\Seeders\WorldLaunchSeeder;
it('seeds launch worlds with a featured current world and archived recurrence', function (): void { it('seeds a live Spring Vibes activation and recurring archive editions', function (): void {
$this->seed(WorldLaunchSeeder::class); $this->seed(WorldLaunchSeeder::class);
$featuredCurrent = World::query() $springVibes = World::query()
->where('slug', 'like', 'retro-month-%') ->where('slug', 'like', 'spring-vibes-%')
->where('is_featured', true) ->campaignActive()
->current() ->where('is_homepage_featured', true)
->first(); ->first();
expect($featuredCurrent)->not->toBeNull(); expect($springVibes)->not->toBeNull();
expect($featuredCurrent?->worldRelations()->count())->toBeGreaterThan(0); expect($springVibes?->title)->toStartWith('Spring Vibes');
expect($springVibes?->worldRelations()->count())->toBeGreaterThan(0);
expect($springVibes?->campaign_priority)->toBeGreaterThan(0);
expect($springVibes?->teaser_title)->toBe('Now live: Spring Vibes');
$archivedEdition = World::query() $archivedEdition = World::query()
->where('parent_world_id', $featuredCurrent?->id) ->where('parent_world_id', $springVibes?->id)
->where('status', World::STATUS_ARCHIVED) ->where('status', World::STATUS_ARCHIVED)
->first(); ->first();
$upcomingCampaign = World::query()
->where('slug', 'like', 'pixel-week-%')
->first();
expect($archivedEdition)->not->toBeNull(); expect($archivedEdition)->not->toBeNull();
expect(World::query()->count())->toBeGreaterThanOrEqual(6); expect($upcomingCampaign)->not->toBeNull();
expect($upcomingCampaign?->is_active_campaign)->toBeTrue();
expect($upcomingCampaign?->promotion_starts_at)->not->toBeNull();
expect(World::query()->count())->toBeGreaterThanOrEqual(8);
}); });

View File

@@ -4,14 +4,22 @@ declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Models\World; use App\Models\World;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Services\HomepageService; use App\Services\HomepageService;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia; use Inertia\Testing\AssertableInertia;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
function publicWorld(array $attributes = []): World function publicWorld(array $attributes = []): World
{ {
$creator = $attributes['creator'] ?? User::factory()->create([ $creator = $attributes['creator'] ?? User::factory()->create([
'username' => 'publicworlds', 'username' => 'publicworlds-' . Str::lower(Str::random(6)),
'name' => 'Public Worlds', 'name' => 'Public Worlds',
]); ]);
@@ -22,18 +30,55 @@ function publicWorld(array $attributes = []): World
'slug' => 'summer-slam-2026', 'slug' => 'summer-slam-2026',
'tagline' => 'Sunlit publishing and warm-color campaigns.', 'tagline' => 'Sunlit publishing and warm-color campaigns.',
'summary' => 'A bright world for summer culture across the platform.', 'summary' => 'A bright world for summer culture across the platform.',
'teaser_title' => 'Now live: Summer Slam',
'teaser_summary' => 'A bright world for summer culture across the platform.',
'description' => 'Public world description', 'description' => 'Public world description',
'theme_key' => 'summer', 'theme_key' => 'summer',
'status' => World::STATUS_PUBLISHED, 'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_SEASONAL, 'type' => World::TYPE_SEASONAL,
'is_featured' => true, 'is_featured' => true,
'starts_at' => Carbon::parse('2026-06-01 00:00:00'), 'is_active_campaign' => true,
'ends_at' => Carbon::parse('2026-08-31 23:59:59'), 'is_homepage_featured' => true,
'published_at' => Carbon::parse('2026-04-01 10:00:00'), 'campaign_priority' => 250,
'campaign_label' => 'Seasonal spotlight',
'starts_at' => Carbon::now()->subDays(2),
'ends_at' => Carbon::now()->addDays(14),
'promotion_starts_at' => Carbon::now()->subDay(),
'promotion_ends_at' => Carbon::now()->addDays(7),
'published_at' => Carbon::now()->subDays(10),
'created_by_user_id' => $creator->id, 'created_by_user_id' => $creator->id,
], $attributes)); ], $attributes));
} }
function worldNewsCategory(array $attributes = []): NewsCategory
{
return NewsCategory::query()->create(array_merge([
'name' => 'World Updates',
'slug' => 'world-updates-' . Str::lower(Str::random(6)),
'description' => 'Editorial context for worlds and linked campaigns.',
'position' => 0,
'is_active' => true,
], $attributes));
}
function publishedWorldNews(User $author, NewsCategory $category, array $attributes = []): NewsArticle
{
return NewsArticle::query()->create(array_merge([
'title' => 'World challenge update',
'slug' => 'world-challenge-update-' . Str::lower(Str::random(6)),
'excerpt' => 'An editorial update for the linked world challenge.',
'content' => "# World challenge update\n\nEditorial context for the linked challenge.",
'author_id' => $author->id,
'category_id' => $category->id,
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'status' => 'published',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'published_at' => now()->subHour(),
'is_featured' => true,
'is_pinned' => false,
], $attributes));
}
it('renders public worlds index and detail pages', function (): void { it('renders public worlds index and detail pages', function (): void {
$world = publicWorld(); $world = publicWorld();
@@ -41,7 +86,8 @@ it('renders public worlds index and detail pages', function (): void {
->assertOk() ->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page ->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldIndex') ->component('World/WorldIndex')
->where('featuredWorld.title', 'Summer Slam 2026') ->where('spotlightWorld.title', 'Summer Slam 2026')
->where('spotlightWorld.campaign_state_label', 'Live now')
->has('activeWorlds')); ->has('activeWorlds'));
$this->get(route('worlds.show', ['world' => $world->slug])) $this->get(route('worlds.show', ['world' => $world->slug]))
@@ -52,6 +98,545 @@ it('renders public worlds index and detail pages', function (): void {
->where('world.slug', 'summer-slam-2026')); ->where('world.slug', 'summer-slam-2026'));
}); });
it('includes rewarded contributors on public world pages', function (): void {
$creator = User::factory()->create([
'username' => 'rewardedcreator-' . Str::lower(Str::random(6)),
]);
$world = publicWorld();
$artwork = \App\Models\Artwork::factory()->for($creator)->create([
'title' => 'World Winner Artwork',
'slug' => 'world-winner-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => \App\Models\Artwork::VISIBILITY_PUBLIC,
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'reward_type' => 'winner',
'grant_source' => 'manual',
'granted_at' => now()->subHour(),
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('rewardedContributors.count', 1)
->where('rewardedContributors.creator_count', 1)
->where('rewardedContributors.counts.winner', 1)
->where('rewardedContributors.items.0.badge_label', $world->title . ' Winner'));
});
it('renders recap payloads for ended worlds with published recaps', function (): void {
$creator = User::factory()->create([
'username' => 'recapcreator-' . Str::lower(Str::random(6)),
'name' => 'Recap Creator',
]);
$world = publicWorld([
'creator' => $creator,
'title' => 'Summer Slam 2025',
'slug' => 'summer-slam-2025',
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'accepts_submissions' => true,
'community_section_enabled' => true,
'starts_at' => now()->subDays(30),
'ends_at' => now()->subDays(5),
'recap_status' => World::RECAP_STATUS_PUBLISHED,
'recap_title' => 'Summer Slam 2025 recap',
'recap_summary' => 'A tighter archive-facing summary for the edition.',
'recap_intro' => '<p>The edition closed with standout artworks, community participation, and a published editorial recap.</p>',
'recap_cover_path' => 'worlds/recaps/summer-slam-2025-cover.jpg',
'recap_published_at' => now()->subDay(),
'recap_stats_snapshot_json' => [
'captured_at' => now()->subDay()->toIso8601String(),
'summary' => [
'views' => 1200,
'unique_visitors' => 640,
'submissions' => 18,
'live_participations' => 18,
'featured_participations' => 3,
'reward_grants' => 1,
'challenge_clicks' => 42,
'winner_count' => 1,
'finalist_count' => 0,
'featured_artwork_count' => 2,
],
],
]);
$category = worldNewsCategory([
'name' => 'Recap Stories',
'slug' => 'recap-stories-' . Str::lower(Str::random(6)),
]);
$article = publishedWorldNews($creator, $category, [
'title' => 'Summer Slam 2025 closing recap',
'slug' => 'summer-slam-2025-closing-recap-' . Str::lower(Str::random(6)),
'excerpt' => 'The final recap story for Summer Slam 2025.',
]);
$world->update(['recap_article_id' => $article->id]);
$featuredArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Curated Edition Highlight',
'slug' => 'curated-edition-highlight-' . Str::lower(Str::random(6)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$communityArtwork = Artwork::factory()->for($creator)->create([
'title' => 'Community Spotlight Piece',
'slug' => 'community-spotlight-piece-' . Str::lower(Str::random(6)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$group = Group::factory()->for($creator, 'owner')->create([
'name' => 'Recap Crew',
'slug' => 'recap-crew-' . Str::lower(Str::random(6)),
]);
$world->worldRelations()->create([
'section_key' => 'featured_artworks',
'related_type' => 'artwork',
'related_id' => $featuredArtwork->id,
'context_label' => 'Editorial highlight',
'sort_order' => 0,
'is_featured' => true,
]);
$world->worldRelations()->create([
'section_key' => 'featured_creators',
'related_type' => 'user',
'related_id' => $creator->id,
'context_label' => 'Edition lead',
'sort_order' => 0,
'is_featured' => true,
]);
$world->worldRelations()->create([
'section_key' => 'featured_groups',
'related_type' => 'group',
'related_id' => $group->id,
'context_label' => 'Community group',
'sort_order' => 0,
'is_featured' => true,
]);
$world->worldRelations()->create([
'section_key' => 'news',
'related_type' => 'news',
'related_id' => $article->id,
'context_label' => 'Closing story',
'sort_order' => 0,
'is_featured' => true,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $communityArtwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'featured_at' => now()->subHours(12),
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
WorldRewardGrant::query()->create([
'user_id' => $creator->id,
'world_id' => $world->id,
'world_submission_id' => $submission->id,
'artwork_id' => $communityArtwork->id,
'reward_type' => 'winner',
'grant_source' => 'manual',
'granted_at' => now()->subHours(6),
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Summer Slam 2025')
->where('world.has_recap', true)
->where('world.cta_label', 'Read full recap')
->where('world.cta_url', route('news.show', ['slug' => $article->slug]))
->where('recap.status', 'published')
->where('recap.title', 'Summer Slam 2025 recap')
->where('recap.summary', 'A tighter archive-facing summary for the edition.')
->where('recap.cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/summer-slam-2025-cover.jpg')
->where('recap.article.title', 'Summer Slam 2025 closing recap')
->where('recap.featured_artworks.items.0.title', 'Curated Edition Highlight')
->where('recap.community_highlights.items.0.title', 'Community Spotlight Piece')
->where('recap.creators.items.0.title', 'Recap Creator')
->where('recap.creators.rewarded.0.badge_label', 'Summer Slam 2025 Winner')
->where('recap.stats.source', 'snapshot')
->where('recap.stats.items.0.key', 'views')
->where('sections', []));
});
it('exposes linked challenge panels, derived entries, and challenge backlinks', function (): void {
$owner = User::factory()->create([
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->for($owner, 'owner')->create();
$world = publicWorld([
'linked_challenge_id' => null,
'show_linked_challenge_section' => true,
'show_linked_challenge_entries' => true,
'show_linked_challenge_winners' => true,
'challenge_teaser_override' => 'World-specific framing for the linked challenge.',
]);
$winner = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Challenge Champion',
'slug' => 'challenge-champion',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$entry = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Challenge Entry Two',
'slug' => 'challenge-entry-two',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$finalist = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Challenge Finalist',
'slug' => 'challenge-finalist',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'World Challenge Finals',
'slug' => 'world-challenge-finals-' . Str::lower(Str::random(6)),
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay(),
'end_at' => now()->addDay(),
'created_by_user_id' => $owner->id,
'featured_artwork_id' => null,
]);
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
$challenge->artworks()->attach($entry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 2]);
$challenge->outcomes()->create([
'artwork_id' => $winner->id,
'user_id' => $owner->id,
'outcome_type' => 'winner',
'position' => 1,
'sort_order' => 0,
'title_override' => 'Grand Winner',
'awarded_by_user_id' => $owner->id,
'awarded_at' => now(),
]);
$challenge->outcomes()->create([
'artwork_id' => $finalist->id,
'user_id' => $owner->id,
'outcome_type' => 'finalist',
'sort_order' => 1,
'note' => 'Outstanding finalist selection.',
'awarded_by_user_id' => $owner->id,
'awarded_at' => now(),
]);
$world->update([
'linked_challenge_id' => $challenge->id,
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('linkedChallenge.title', 'World Challenge Finals')
->where('linkedChallenge.summary', 'World-specific framing for the linked challenge.')
->where('linkedChallenge.state_label', 'Winners announced')
->where('linkedChallengeEntries.items.0.title', 'Challenge Champion')
->where('linkedChallengeWinners.item.title', 'Challenge Champion')
->where('linkedChallengeFinalists.items.0.title', 'Challenge Finalist')
->where('world.challenge_cta_label', 'See results'));
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupChallengeShow')
->where('linkedWorld.title', $world->title)
->where('linkedWorld.public_url', route('worlds.show', ['world' => $world->slug]))
->where('linkedWorld.campaign_label', 'Seasonal spotlight')
->where('linkedWorld.challenge_cta_label', 'See results'));
});
it('hides selected linked challenge entries from the derived world feed', function (): void {
$owner = User::factory()->create([
'username' => 'challenge-hide-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->for($owner, 'owner')->create();
$world = publicWorld();
$hiddenEntry = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Hidden Challenge Entry',
'slug' => 'hidden-challenge-entry',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$visibleEntry = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Visible Challenge Entry',
'slug' => 'visible-challenge-entry',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Hidden Entries Challenge',
'slug' => 'hidden-entries-challenge-' . Str::lower(Str::random(6)),
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay(),
'end_at' => now()->addDay(),
'created_by_user_id' => $owner->id,
]);
$challenge->artworks()->attach($hiddenEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
$challenge->artworks()->attach($visibleEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
$world->update([
'linked_challenge_id' => $challenge->id,
'hidden_linked_challenge_artwork_ids_json' => [$hiddenEntry->id],
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('linkedChallengeEntries.hidden_count', 1)
->where('linkedChallengeEntries.items.0.title', 'Visible Challenge Entry')
->has('linkedChallengeEntries.items', 1));
});
it('maps community-vote challenges to a voting state on the world page', function (): void {
$owner = User::factory()->create([
'username' => 'challenge-vote-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->for($owner, 'owner')->create();
$world = publicWorld();
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Vote Live Challenge',
'slug' => 'vote-live-challenge-' . Str::lower(Str::random(6)),
'summary' => 'Vote for the best entry.',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ENDED,
'judging_mode' => 'community_vote',
'start_at' => now()->subDays(7),
'end_at' => now()->subHour(),
'created_by_user_id' => $owner->id,
]);
$world->update([
'linked_challenge_id' => $challenge->id,
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('linkedChallenge.state', 'voting')
->where('linkedChallenge.state_label', 'Voting live')
->where('linkedChallenge.cta_label', 'View entries')
->where('world.challenge_cta_label', 'View entries'));
});
it('shifts linked challenge CTAs into recap mode for archived worlds', function (): void {
$owner = User::factory()->create([
'username' => 'challenge-archive-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->for($owner, 'owner')->create();
$world = publicWorld([
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => now()->subDays(20),
'ends_at' => now()->subDays(3),
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Archive Recap Challenge',
'slug' => 'archive-recap-challenge-' . Str::lower(Str::random(6)),
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDays(5),
'end_at' => now()->addDays(2),
'created_by_user_id' => $owner->id,
]);
$world->update([
'linked_challenge_id' => $challenge->id,
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('linkedChallenge.state', 'closed')
->where('linkedChallenge.cta_label', 'View challenge recap')
->where('world.challenge_cta_label', 'View challenge recap'));
});
it('surfaces linked challenge recap stories and keeps derived sections in recap mode for archived worlds', function (): void {
$owner = User::factory()->create([
'username' => 'challenge-recap-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->for($owner, 'owner')->create();
$world = publicWorld([
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => now()->subDays(30),
'ends_at' => now()->subDays(5),
'show_linked_challenge_section' => true,
'show_linked_challenge_entries' => true,
'show_linked_challenge_winners' => true,
'show_linked_challenge_finalists' => true,
]);
$winner = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Recap Winner',
'slug' => 'recap-winner-' . Str::lower(Str::random(6)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$finalist = Artwork::factory()->for($owner)->create([
'group_id' => $group->id,
'title' => 'Recap Finalist',
'slug' => 'recap-finalist-' . Str::lower(Str::random(6)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'World Recap Challenge',
'slug' => 'world-recap-challenge-' . Str::lower(Str::random(6)),
'summary' => 'Challenge summary',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDays(10),
'end_at' => now()->subDays(2),
'created_by_user_id' => $owner->id,
'featured_artwork_id' => null,
]);
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
$challenge->outcomes()->create([
'artwork_id' => $winner->id,
'user_id' => $owner->id,
'outcome_type' => 'winner',
'position' => 1,
'sort_order' => 0,
'awarded_by_user_id' => $owner->id,
'awarded_at' => now(),
]);
$challenge->outcomes()->create([
'artwork_id' => $finalist->id,
'user_id' => $owner->id,
'outcome_type' => 'finalist',
'sort_order' => 1,
'awarded_by_user_id' => $owner->id,
'awarded_at' => now(),
]);
$category = worldNewsCategory([
'name' => 'Challenge Recaps',
'slug' => 'challenge-recaps-' . Str::lower(Str::random(6)),
]);
$recap = publishedWorldNews($owner, $category, [
'title' => 'World Recap Challenge results recap',
'slug' => 'world-recap-challenge-results-' . Str::lower(Str::random(6)),
'excerpt' => 'Winner highlights and finalist recap from the linked challenge.',
'content' => "# Results recap\n\nWinner highlights and finalist recap.",
]);
$world->update([
'linked_challenge_id' => $challenge->id,
]);
$world->worldRelations()->create([
'section_key' => 'news',
'related_type' => 'news',
'related_id' => $recap->id,
'context_label' => 'Challenge recap',
'sort_order' => 0,
'is_featured' => true,
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('linkedChallenge.state', 'closed')
->where('linkedChallenge.cta_label', 'View challenge recap')
->where('linkedChallenge.cta_url', route('news.show', ['slug' => $recap->slug]))
->where('linkedChallenge.challenge_url', route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
->where('linkedChallenge.story.title', 'World Recap Challenge results recap')
->where('linkedChallenge.story.intent', 'recap')
->where('linkedChallengeEntries.description', 'Entries from the linked challenge remain visible here so the world recap preserves the full field of work.')
->where('linkedChallengeWinners.description', 'This world is carrying the linked challenge result forward so the campaign recap stays visible here too.')
->where('linkedChallengeFinalists.description', 'Finalists from the linked challenge remain visible here so the archived world keeps the complete recap in view.')
->where('world.challenge_cta_label', 'View challenge recap')
->where('world.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupChallengeShow')
->where('linkedWorld.title', $world->title)
->where('linkedWorld.challenge_cta_label', 'View challenge recap')
->where('linkedWorld.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
});
it('falls back to the theme icon when the stored world icon is blank whitespace', function (): void { it('falls back to the theme icon when the stored world icon is blank whitespace', function (): void {
$world = publicWorld([ $world = publicWorld([
'title' => 'Spring Vibes', 'title' => 'Spring Vibes',
@@ -111,11 +696,107 @@ it('keeps archived worlds publicly visible', function (): void {
->assertSee('Halloween World 2025'); ->assertSee('Halloween World 2025');
}); });
it('resolves recurring family and archived edition routes to the correct edition', function (): void {
publicWorld([
'title' => 'Spring Vibes 2025',
'slug' => 'spring-vibes-2025',
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2025,
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => Carbon::parse('2025-03-01 00:00:00'),
'ends_at' => Carbon::parse('2025-04-01 00:00:00'),
'published_at' => Carbon::parse('2025-02-20 10:00:00'),
]);
$currentEdition = publicWorld([
'title' => 'Spring Vibes 2026',
'slug' => 'spring-vibes-2026',
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2026,
'campaign_priority' => 600,
]);
$this->get(route('worlds.show', ['world' => 'spring-vibes']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Spring Vibes 2026')
->where('world.public_url', route('worlds.show', ['world' => 'spring-vibes']))
->has('archiveEditions', 1)
->where('archiveEditions.0.title', 'Spring Vibes 2025'));
$this->get(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Spring Vibes 2025')
->where('currentEdition.title', 'Spring Vibes 2026')
->where('archiveNotice.current_edition.title', 'Spring Vibes 2026'));
$this->get(route('worlds.show', ['world' => $currentEdition->slug]))
->assertRedirect(route('worlds.show', ['world' => 'spring-vibes']));
$this->get(route('worlds.show', ['world' => 'spring-vibes-2025']))
->assertRedirect(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]));
});
it('exposes adjacent previous and next editions inside the archive payload', function (): void {
publicWorld([
'title' => 'Retro Month 2024',
'slug' => 'retro-month-2024',
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'edition_year' => 2024,
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => Carbon::parse('2024-04-01 00:00:00'),
'ends_at' => Carbon::parse('2024-04-30 00:00:00'),
'published_at' => Carbon::parse('2024-03-20 10:00:00'),
]);
publicWorld([
'title' => 'Retro Month 2025',
'slug' => 'retro-month-2025',
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'edition_year' => 2025,
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => Carbon::parse('2025-04-01 00:00:00'),
'ends_at' => Carbon::parse('2025-04-30 00:00:00'),
'published_at' => Carbon::parse('2025-03-20 10:00:00'),
]);
publicWorld([
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'edition_year' => 2026,
'campaign_priority' => 200,
]);
$this->get(route('worlds.editions.show', ['world' => 'retro-month', 'year' => 2025]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('world.title', 'Retro Month 2025')
->where('previousEdition.title', 'Retro Month 2024')
->where('nextEdition.title', 'Retro Month 2026'));
});
it('exposes a homepage world spotlight when a featured world exists', function (): void { it('exposes a homepage world spotlight when a featured world exists', function (): void {
publicWorld([ publicWorld([
'title' => 'Pixel Week 2026', 'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026', 'slug' => 'pixel-week-2026',
'theme_key' => 'pixel-week', 'theme_key' => 'pixel-week',
'teaser_title' => 'Pixel Week is open for submissions',
]); ]);
app(HomepageService::class)->clearGuestPayloadCache(); app(HomepageService::class)->clearGuestPayloadCache();
@@ -125,4 +806,65 @@ it('exposes a homepage world spotlight when a featured world exists', function (
->assertSee(route('worlds.index'), false) ->assertSee(route('worlds.index'), false)
->assertSee('pixel-week-2026') ->assertSee('pixel-week-2026')
->assertSee('Pixel Week 2026'); ->assertSee('Pixel Week 2026');
});
it('splits live, upcoming, and archived worlds on the public index', function (): void {
publicWorld([
'title' => 'Spring Vibes',
'slug' => 'spring-vibes',
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2026,
'campaign_priority' => 500,
]);
publicWorld([
'title' => 'Spring Vibes 2025',
'slug' => 'spring-vibes-2025',
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2025,
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => Carbon::now()->subDays(400),
'ends_at' => Carbon::now()->subDays(365),
'promotion_starts_at' => null,
'promotion_ends_at' => null,
'published_at' => Carbon::now()->subDays(420),
]);
publicWorld([
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'starts_at' => Carbon::now()->addDays(10),
'ends_at' => Carbon::now()->addDays(24),
'promotion_starts_at' => Carbon::now()->addDays(8),
'promotion_ends_at' => Carbon::now()->addDays(18),
'teaser_title' => 'Retro Month is coming up',
]);
publicWorld([
'title' => 'Halloween World 2025',
'slug' => 'halloween-world-2025',
'theme_key' => 'halloween',
'status' => World::STATUS_ARCHIVED,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'starts_at' => Carbon::now()->subDays(40),
'ends_at' => Carbon::now()->subDays(20),
'promotion_starts_at' => null,
'promotion_ends_at' => null,
'published_at' => Carbon::now()->subDays(50),
]);
$this->get(route('worlds.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldIndex')
->where('spotlightWorld.title', 'Spring Vibes')
->has('upcomingWorlds', 1)
->has('recurringWorldFamilies', 1)
->where('recurringWorldFamilies.0.title', 'Spring Vibes')
->has('archivedWorlds', 2));
}); });

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Services\Worlds\WorldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function recurringWorldModerator(): User
{
return User::factory()->create([
'role' => 'moderator',
'username' => 'recurrence-mod-' . Str::lower(Str::random(6)),
'name' => 'Recurrence Moderator',
]);
}
function studioRecurringWorld(User $creator, array $attributes = []): World
{
return World::factory()->create(array_merge([
'created_by_user_id' => $creator->id,
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'starts_at' => Carbon::now()->subDays(14),
'ends_at' => Carbon::now()->addDays(7),
'promotion_starts_at' => Carbon::now()->subDays(10),
'promotion_ends_at' => Carbon::now()->addDays(5),
'submission_starts_at' => Carbon::now()->subDays(7),
'submission_ends_at' => Carbon::now()->addDays(5),
'published_at' => Carbon::now()->subDays(21),
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 200,
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'recurrence_rule' => 'yearly',
'edition_year' => 2026,
'cta_url' => 'https://skinbase.test/worlds/retro-month',
'badge_url' => 'https://skinbase.test/badges/retro-month',
], $attributes));
}
it('creates a clean next edition draft for recurring worlds', function (): void {
$moderator = recurringWorldModerator();
$source = studioRecurringWorld($moderator);
WorldRelation::query()->create([
'world_id' => $source->id,
'section_key' => 'featured_artworks',
'related_type' => WorldRelation::TYPE_ARTWORK,
'related_id' => 123,
'context_label' => 'Carry-over candidate',
'sort_order' => 0,
'is_featured' => true,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $source->id]))
->post(route('studio.worlds.new-edition', ['world' => $source->id]), [
'copy_mode' => WorldService::COPY_MODE_STRUCTURE_ONLY,
]);
$edition = World::query()
->whereKeyNot($source->id)
->latest('id')
->firstOrFail();
expect($edition->title)->toBe('Retro Month 2027')
->and($edition->slug)->toBe('retro-month-2027')
->and($edition->status)->toBe(World::STATUS_DRAFT)
->and($edition->is_recurring)->toBeTrue()
->and($edition->recurrence_key)->toBe('retro-month')
->and($edition->recurrence_rule)->toBe('yearly')
->and($edition->edition_year)->toBe(2027)
->and($edition->parent_world_id)->toBe($source->id)
->and($edition->starts_at)->toBeNull()
->and($edition->ends_at)->toBeNull()
->and($edition->promotion_starts_at)->toBeNull()
->and($edition->promotion_ends_at)->toBeNull()
->and($edition->submission_starts_at)->toBeNull()
->and($edition->submission_ends_at)->toBeNull()
->and($edition->published_at)->toBeNull()
->and($edition->is_active_campaign)->toBeFalse()
->and($edition->is_homepage_featured)->toBeFalse()
->and($edition->campaign_priority)->toBeNull()
->and($edition->cta_url)->toBeNull()
->and($edition->badge_url)->toBeNull()
->and($edition->worldRelations()->count())->toBe(0);
});
it('rejects next edition creation for non-recurring worlds', function (): void {
$moderator = recurringWorldModerator();
$world = World::factory()->create([
'created_by_user_id' => $moderator->id,
'title' => 'One-Off Showcase 2026',
'slug' => 'one-off-showcase-2026',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'is_recurring' => false,
'recurrence_key' => null,
'recurrence_rule' => null,
'edition_year' => null,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $world->id]))
->post(route('studio.worlds.new-edition', ['world' => $world->id]))
->assertSessionHasErrors(['recurrence_key']);
expect(World::query()->count())->toBe(1);
});
it('rejects duplicate recurrence years when storing worlds', function (): void {
$moderator = recurringWorldModerator();
studioRecurringWorld($moderator, [
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Pixel Week Draft',
'slug' => 'pixel-week-draft',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_EVENT,
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
])
->assertSessionHasErrors(['edition_year']);
expect(World::query()->count())->toBe(1);
});
it('rejects publishing a second current edition for the same recurrence family', function (): void {
$moderator = recurringWorldModerator();
studioRecurringWorld($moderator, [
'title' => 'Spring Vibes 2026',
'slug' => 'spring-vibes-2026',
'recurrence_key' => 'spring-vibes',
'edition_year' => 2026,
'status' => World::STATUS_PUBLISHED,
'starts_at' => Carbon::now()->subDays(3),
'ends_at' => Carbon::now()->addDays(10),
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Spring Vibes 2027',
'slug' => 'spring-vibes-2027',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'starts_at' => Carbon::now()->subDay()->toIso8601String(),
'ends_at' => Carbon::now()->addDays(14)->toIso8601String(),
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2027,
])
->assertSessionHasErrors(['status']);
expect(World::query()->count())->toBe(1);
});

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\User; use App\Models\User;
use App\Models\World; use App\Models\World;
use App\Models\WorldRewardGrant;
use App\Models\WorldRelation; use App\Models\WorldRelation;
use App\Models\WorldSubmission; use App\Models\WorldSubmission;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -82,7 +83,7 @@ it('creates pending world submissions when publishing an artwork draft', functio
'category' => $categoryId, 'category' => $categoryId,
'tags' => ['world', 'submission'], 'tags' => ['world', 'submission'],
'world_submissions' => [ 'world_submissions' => [
['world_id' => $world->id, 'note' => 'Fits the active theme.'], ['world_id' => $world->id, 'note' => 'Fits the active theme.', 'source_surface' => 'upload_flow'],
], ],
]) ])
->assertOk() ->assertOk()
@@ -96,6 +97,15 @@ it('creates pending world submissions when publishing an artwork draft', functio
'is_featured' => false, 'is_featured' => false,
'note' => 'Fits the active theme.', 'note' => 'Fits the active theme.',
]); ]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_submission_created',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => 'World Upload',
]);
}); });
it('creates live world participation immediately for auto-add worlds', function (): void { it('creates live world participation immediately for auto-add worlds', function (): void {
@@ -133,6 +143,14 @@ it('creates live world participation immediately for auto-add worlds', function
'status' => WorldSubmission::STATUS_LIVE, 'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => false, 'is_featured' => false,
]); ]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
}); });
it('syncs world submissions from the studio artwork editor update flow', function (): void { it('syncs world submissions from the studio artwork editor update flow', function (): void {
@@ -319,7 +337,8 @@ it('shows and reviews world participation in the studio world editor', function
->component('Studio/StudioWorldEditor') ->component('Studio/StudioWorldEditor')
->where('world.participation_mode', World::PARTICIPATION_MODE_MANUAL_APPROVAL) ->where('world.participation_mode', World::PARTICIPATION_MODE_MANUAL_APPROVAL)
->where('world.submission_review_queue.counts.pending', 1) ->where('world.submission_review_queue.counts.pending', 1)
->where('world.submission_review_queue.items.0.artwork.title', 'Queue Artwork')); ->where('world.submission_review_queue.items.0.artwork.title', 'Queue Artwork')
->where('world.submission_review_queue.items.0.can_grant_manual_rewards', false));
$this->actingAs($moderator) $this->actingAs($moderator)
->post(route('studio.worlds.submissions.approve', ['world' => $world->id, 'submission' => $submission->id])) ->post(route('studio.worlds.submissions.approve', ['world' => $world->id, 'submission' => $submission->id]))
@@ -336,6 +355,20 @@ it('shows and reviews world participation in the studio world editor', function
'reviewed_by_user_id' => $moderator->id, 'reviewed_by_user_id' => $moderator->id,
]); ]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'featured',
'grant_source' => 'automatic',
]);
$this->actingAs($moderator) $this->actingAs($moderator)
->post(route('studio.worlds.submissions.block', ['world' => $world->id, 'submission' => $submission->id]), [ ->post(route('studio.worlds.submissions.block', ['world' => $world->id, 'submission' => $submission->id]), [
'review_note' => 'Off brief for this world.', 'review_note' => 'Off brief for this world.',
@@ -348,6 +381,114 @@ it('shows and reviews world participation in the studio world editor', function
'moderation_reason' => 'Off brief for this world.', 'moderation_reason' => 'Off brief for this world.',
'is_featured' => false, 'is_featured' => false,
]); ]);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'featured',
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
});
it('allows moderators to grant and revoke manual world rewards', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'worldrewardmod-' . Str::lower(Str::random(6)),
]);
$creator = User::factory()->create();
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Reward Artwork',
'slug' => 'reward-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.rewards.grant', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']), [
'review_note' => 'Editorial pick for the final showcase.',
])
->assertRedirect();
$grant = WorldRewardGrant::query()->where('user_id', $creator->id)->where('world_id', $world->id)->where('reward_type', 'winner')->first();
expect($grant)->not->toBeNull();
$this->assertDatabaseHas('notifications', [
'type' => 'world_reward_granted',
]);
$this->assertDatabaseHas('user_activities', [
'user_id' => $creator->id,
'type' => 'world_reward',
'entity_type' => 'world_reward',
'entity_id' => $grant->id,
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.rewards.revoke', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']))
->assertRedirect();
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
]);
});
it('rejects manual world rewards for non-live submissions', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'worldrewardpending-' . Str::lower(Str::random(6)),
]);
$creator = User::factory()->create();
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Pending Reward Artwork',
'slug' => 'pending-reward-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
$submission = WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_PENDING,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $world->id]))
->post(route('studio.worlds.submissions.rewards.grant', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']), [
'review_note' => 'Tried to award too early.',
])
->assertRedirect(route('studio.worlds.edit', ['world' => $world->id]))
->assertSessionHasErrors(['submission']);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
]);
}); });
it('renders only live community submissions on public world pages and hides pending or blocked ones', function (): void { it('renders only live community submissions on public world pages and hides pending or blocked ones', function (): void {
@@ -478,4 +619,72 @@ it('exposes world participation badges on the artwork page for curated and live
return $items->count() === 1 return $items->count() === 1
&& $items->contains(fn (array $item): bool => ($item['badge_label'] ?? null) === 'Featured in Retro Month'); && $items->contains(fn (array $item): bool => ($item['badge_label'] ?? null) === 'Featured in Retro Month');
}); });
});
it('prioritizes active campaign worlds in creator submission options', function (): void {
$creator = User::factory()->create();
$liveCampaign = acceptingWorld(attributes: [
'title' => 'Spring Vibes',
'slug' => 'spring-vibes',
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 500,
'campaign_label' => 'Live now',
'teaser_title' => 'Now live: Spring Vibes',
'teaser_summary' => 'Fresh spring palettes and active submissions.',
'promotion_starts_at' => now()->subHour(),
'promotion_ends_at' => now()->addDays(5),
]);
$regularWorld = acceptingWorld(attributes: [
'title' => 'Open Worlds Lab',
'slug' => 'open-worlds-lab',
'is_active_campaign' => false,
'is_homepage_featured' => false,
'campaign_priority' => null,
]);
$options = app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($creator);
expect($options)->toHaveCount(2)
->and($options[0]['id'])->toBe($liveCampaign->id)
->and($options[0]['teaser_title'])->toBe('Now live: Spring Vibes')
->and(collect($options[0]['status_badges'])->pluck('label')->all())->toContain('Live now', 'Featured')
->and($options[1]['id'])->toBe($regularWorld->id);
});
it('only exposes the canonical current edition for recurring submission options', function (): void {
$creator = User::factory()->create();
acceptingWorld(attributes: [
'title' => 'Pixel Week 2025',
'slug' => 'pixel-week-2025',
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2025,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'campaign_priority' => 50,
'starts_at' => now()->subDays(30),
'ends_at' => now()->addDays(2),
]);
$currentEdition = acceptingWorld(attributes: [
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 500,
'teaser_title' => 'Now live: Pixel Week 2026',
]);
$options = app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($creator);
expect($options)->toHaveCount(1)
->and($options[0]['id'])->toBe($currentEdition->id)
->and($options[0]['title'])->toBe('Pixel Week 2026');
}); });