Replace native selects with NovaSelect
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import NovaSelect from '../components/ui/NovaSelect'
|
||||
|
||||
const navItems = [
|
||||
{ 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">
|
||||
{hasSectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block flex-1">
|
||||
<div className="block flex-1">
|
||||
<span className="sr-only">Settings section</span>
|
||||
<select
|
||||
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"
|
||||
<NovaSelect
|
||||
className="w-full"
|
||||
value={activeSection || ''}
|
||||
onChange={(e) => onSectionChange(e.target.value)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
|
||||
{section.label}{dirtyMap[section.key] ? ' •' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => onSectionChange(value)}
|
||||
options={sections.map((section) => ({ value: section.key, label: `${section.label}${dirtyMap[section.key] ? ' •' : ''}` }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
{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">
|
||||
Unsaved
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import NovaSelect from '../components/ui/NovaSelect'
|
||||
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
|
||||
|
||||
const baseNavGroups = [
|
||||
@@ -15,8 +16,9 @@ const baseNavGroups = [
|
||||
label: 'Create',
|
||||
items: [
|
||||
{ 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 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' },
|
||||
],
|
||||
},
|
||||
@@ -34,8 +36,9 @@ const baseNavGroups = [
|
||||
label: 'Library',
|
||||
items: [
|
||||
{ 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: '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: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' },
|
||||
],
|
||||
@@ -71,7 +74,7 @@ const baseNavGroups = [
|
||||
const baseQuickCreateItems = [
|
||||
{ label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
@@ -145,14 +148,25 @@ function navigateToStudioUrl(targetUrl) {
|
||||
}
|
||||
|
||||
function NavLink({ item, active }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
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 (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={className}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
@@ -169,6 +183,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const currentGroup = props.studioGroup || null
|
||||
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
|
||||
const canManageWorlds = canManageNews
|
||||
const isStaff = Boolean(props.auth?.user?.is_staff)
|
||||
|
||||
const navGroups = baseNavGroups.map((group) => {
|
||||
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
|
||||
@@ -312,6 +327,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
isStaff={isStaff}
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
onQuickCreate={handleQuickCreateClick}
|
||||
onContextChange={handleContextChange}
|
||||
@@ -328,6 +344,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
isStaff={isStaff}
|
||||
onQuickCreate={handleQuickCreateClick}
|
||||
onContextChange={handleContextChange}
|
||||
/>
|
||||
@@ -384,25 +401,22 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
|
||||
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
|
||||
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" />
|
||||
<select
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(event) => onContextChange?.(event.target.value)}
|
||||
className="bg-transparent text-sm text-white outline-none"
|
||||
>
|
||||
<option value="" className="bg-slate-950 text-white">Personal studio</option>
|
||||
{studioGroups.map((group) => (
|
||||
<option key={group.slug} value={group.slug} className="bg-slate-950 text-white">
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, onNavigate, onQuickCreate, onContextChange }) {
|
||||
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
|
||||
return (
|
||||
<>
|
||||
<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 ? (
|
||||
<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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(event) => onContextChange?.(event.target.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"
|
||||
>
|
||||
<option value="">Personal studio</option>
|
||||
{studioGroups.map((group) => (
|
||||
<option key={group.slug} value={group.slug}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
className="mt-2"
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -437,6 +451,20 @@ function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCrea
|
||||
</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>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { startTransition, useDeferredValue, useEffect, useRef, useState }
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import CategoryCard from '../components/category/CategoryCard'
|
||||
import Pagination from '../components/forum/Pagination'
|
||||
import NovaSelect from '../components/ui/NovaSelect'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
@@ -13,6 +14,26 @@ const PAGE_SIZE = 24
|
||||
|
||||
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() {
|
||||
return (
|
||||
<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())
|
||||
}
|
||||
|
||||
function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '' }) {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [popularCategories, setPopularCategories] = useState([])
|
||||
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: PAGE_SIZE, total: 0 })
|
||||
const [summary, setSummary] = useState({ total_categories: 0, total_artworks: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories', pageDescription = '', initialData = null }) {
|
||||
const bootstrap = normalizeInitialData(initialData)
|
||||
const hasInitialData = initialData !== null
|
||||
const initialRequestRef = useRef(hasInitialData ? bootstrap.request : null)
|
||||
|
||||
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 [error, setError] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState(() => getInitialSearchQuery())
|
||||
const [sort, setSort] = useState(() => getInitialSort())
|
||||
const [currentPage, setCurrentPage] = useState(() => getInitialPage())
|
||||
const [searchQuery, setSearchQuery] = useState(() => (hasInitialData ? (bootstrap.request.query || '') : getInitialSearchQuery()))
|
||||
const [sort, setSort] = useState(() => (hasInitialData ? (bootstrap.request.sort || 'popular') : getInitialSort()))
|
||||
const [currentPage, setCurrentPage] = useState(() => (hasInitialData ? (bootstrap.request.page || 1) : getInitialPage()))
|
||||
const deferredQuery = useDeferredValue(searchQuery)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
@@ -199,6 +224,20 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
void loadCategories({
|
||||
@@ -334,24 +373,19 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
|
||||
/>
|
||||
</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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value)
|
||||
onChange={(value) => {
|
||||
setSort(value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
aria-label="Sort categories"
|
||||
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"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
id="categories-sort"
|
||||
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</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')
|
||||
|
||||
if (mountElement) {
|
||||
@@ -452,5 +487,6 @@ if (mountElement) {
|
||||
|
||||
createRoot(mountElement).render(<CategoriesPage {...props} />)
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoriesPage
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
const DEFAULT_SEARCH_FILTERS = {
|
||||
q: '',
|
||||
@@ -262,13 +264,7 @@ function BulkActionsPanel({
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-4">
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
</SearchField>
|
||||
|
||||
{form.action === 'assign_campaign' ? (
|
||||
@@ -284,11 +280,7 @@ function BulkActionsPanel({
|
||||
|
||||
{form.action === 'update_lifecycle' ? (
|
||||
<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">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</SearchField>
|
||||
) : null}
|
||||
|
||||
@@ -344,15 +336,13 @@ function SearchResults({ state, endpoints, selectedIds, onToggleSelected }) {
|
||||
{state.collections.map((collection) => (
|
||||
<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">
|
||||
<label className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(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
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
<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 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">
|
||||
<option value="">Any type</option>
|
||||
{(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) }))} />
|
||||
</SearchField>
|
||||
|
||||
<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">
|
||||
<option value="">Any visibility</option>
|
||||
{(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) }))} />
|
||||
</SearchField>
|
||||
|
||||
<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">
|
||||
<option value="">Any lifecycle</option>
|
||||
{(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) }))} />
|
||||
</SearchField>
|
||||
|
||||
<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">
|
||||
<option value="">Any workflow</option>
|
||||
{(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) }))} />
|
||||
</SearchField>
|
||||
|
||||
<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">
|
||||
<option value="">Any health state</option>
|
||||
{(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
<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) }))} />
|
||||
</SearchField>
|
||||
|
||||
<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">
|
||||
<option value="">Any placement state</option>
|
||||
<option value="1">Eligible</option>
|
||||
<option value="0">Blocked</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</SearchField>
|
||||
|
||||
<div className="flex items-end gap-3 xl:col-span-1">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
const SEARCH_SELECT_OPTIONS = {
|
||||
type: [
|
||||
@@ -174,6 +175,33 @@ function SearchPanel({ search }) {
|
||||
const options = search.options || {}
|
||||
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 (
|
||||
<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">
|
||||
@@ -183,75 +211,20 @@ function SearchPanel({ search }) {
|
||||
</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>
|
||||
</div>
|
||||
<form method="GET" action="/collections/search" 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" />
|
||||
<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">
|
||||
<option value="">All types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="editorial">Editorial</option>
|
||||
</select>
|
||||
<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">
|
||||
<option value="trending">Trending</option>
|
||||
<option value="recent">Recent</option>
|
||||
<option value="quality">Quality</option>
|
||||
<option value="evergreen">Evergreen</option>
|
||||
</select>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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" />
|
||||
<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' }]} />
|
||||
<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' }]} />
|
||||
<NovaSelect value={localFilters.category} onChange={(val) => updateFilter('category', val)} placeholder="Any category" options={(options.category || []).map((item) => ({ value: item.value, label: item.label }))} />
|
||||
<NovaSelect value={localFilters.mode} onChange={(val) => updateFilter('mode', val)} placeholder="Any curation mode" searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'smart', label: 'Smart' }]} />
|
||||
<NovaSelect value={localFilters.style} onChange={(val) => updateFilter('style', val)} placeholder="Any style signal" options={(options.style || []).map((item) => ({ value: item.value, label: item.label }))} />
|
||||
<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' }]} />
|
||||
<NovaSelect value={localFilters.theme} onChange={(val) => updateFilter('theme', val)} placeholder="Any theme" options={(options.theme || []).map((item) => ({ value: item.value, label: item.label }))} />
|
||||
<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' }]} />
|
||||
<NovaSelect value={localFilters.color} onChange={(val) => updateFilter('color', val)} placeholder="Any color palette" options={(options.color || []).map((item) => ({ value: item.value, label: item.label }))} />
|
||||
<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" />
|
||||
<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" />
|
||||
<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 }))} />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
@@ -466,15 +468,19 @@ function MemberCard({ member, onRoleChange, onRemove, onAccept, onDecline, onTra
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{member?.can_revoke ? (
|
||||
<select
|
||||
<div className="min-w-[150px]">
|
||||
<NovaSelect
|
||||
value={member?.role}
|
||||
onChange={(event) => onRoleChange?.(member, event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"
|
||||
>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
onChange={(value) => onRoleChange?.(member, value)}
|
||||
options={[
|
||||
{ value: 'editor', label: 'Editor' },
|
||||
{ value: 'contributor', label: 'Contributor' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
searchable={false}
|
||||
className="text-xs font-semibold uppercase tracking-[0.14em]"
|
||||
/>
|
||||
</div>
|
||||
) : 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}
|
||||
@@ -528,28 +534,27 @@ function LayoutModuleCard({ module, index, total, onToggle, onSlotChange, onMove
|
||||
<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>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
<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">
|
||||
<Checkbox
|
||||
checked={module.enabled}
|
||||
disabled={module.locked}
|
||||
onChange={(event) => onToggle(module.key, event.target.checked)}
|
||||
label={module.locked ? 'Required' : (module.enabled ? 'Enabled' : 'Disabled')}
|
||||
/>
|
||||
{module.locked ? 'Required' : (module.enabled ? 'Enabled' : 'Disabled')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field label="Placement">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={module.slot}
|
||||
onChange={(event) => onSlotChange(module.key, 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"
|
||||
>
|
||||
{module.slots.map((slot) => (
|
||||
<option key={slot} value={slot}>{slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar'}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onSlotChange(module.key, value)}
|
||||
options={module.slots.map((slot) => ({
|
||||
value: slot,
|
||||
label: slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar',
|
||||
}))}
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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)]">
|
||||
<Field label="Field">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={rule.field}
|
||||
onChange={(event) => onFieldChange(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"
|
||||
>
|
||||
{fieldOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onFieldChange(value)}
|
||||
options={fieldOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Operator">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={rule.operator}
|
||||
onChange={(event) => onOperatorChange(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"
|
||||
>
|
||||
{operatorOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onOperatorChange(value)}
|
||||
options={operatorOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{rule.field === 'created_at' ? (
|
||||
@@ -688,23 +687,20 @@ function SmartRuleRow({
|
||||
</Field>
|
||||
) : rule.field === 'is_featured' || rule.field === 'is_mature' ? (
|
||||
<Field label="Value">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={rule.value ? 'true' : 'false'}
|
||||
onChange={(event) => onValueChange(event.target.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"
|
||||
>
|
||||
{rule.field === 'is_featured' ? (
|
||||
<>
|
||||
<option value="true">Featured only</option>
|
||||
<option value="false">Not featured</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="true">Mature only</option>
|
||||
<option value="false">Not mature</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
onChange={(value) => onValueChange(value === 'true')}
|
||||
options={rule.field === 'is_featured'
|
||||
? [
|
||||
{ value: 'true', label: 'Featured only' },
|
||||
{ value: 'false', label: 'Not featured' },
|
||||
]
|
||||
: [
|
||||
{ value: 'true', label: 'Mature only' },
|
||||
{ value: 'false', label: 'Not mature' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
) : rule.field === 'tags' ? (
|
||||
<Field label="Value" help="Type a tag name exactly as it appears on your artworks.">
|
||||
@@ -718,16 +714,13 @@ function SmartRuleRow({
|
||||
</Field>
|
||||
) : valueOptions.length ? (
|
||||
<Field label="Value">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={rule.value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="">Select one</option>
|
||||
{valueOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onValueChange(value)}
|
||||
options={valueOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
placeholder="Select one"
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Value">
|
||||
@@ -1917,11 +1910,7 @@ export default function CollectionManage() {
|
||||
/>
|
||||
</Field>
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1965,12 +1954,7 @@ export default function CollectionManage() {
|
||||
/>
|
||||
</Field>
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1986,73 +1970,49 @@ export default function CollectionManage() {
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<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">
|
||||
<option value="cover_heavy">Cover Heavy</option>
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="artwork_first">Artwork First</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
<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">
|
||||
<option value="default">Default</option>
|
||||
<option value="subtle-blue">Subtle Blue</option>
|
||||
<option value="violet">Violet</option>
|
||||
<option value="amber">Amber</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{!isSmartMode ? (
|
||||
<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">
|
||||
<option value="manual">Manual</option>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="popular">Most popular</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Match Mode" help="All rules must match, or any one rule is enough.">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={smartRules.match}
|
||||
onChange={(event) => setSmartRules((current) => ({ ...current, match: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="all">All rules</option>
|
||||
<option value="any">Any rule</option>
|
||||
</select>
|
||||
onChange={(val) => setSmartRules((current) => ({ ...current, match: val }))}
|
||||
searchable={false}
|
||||
options={[{ value: 'all', label: 'All rules' }, { value: 'any', label: 'Any rule' }]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!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.'}>
|
||||
<select
|
||||
value={form.cover_artwork_id}
|
||||
onChange={(event) => updateForm('cover_artwork_id', event.target.value)}
|
||||
<NovaSelect
|
||||
value={String(form.cover_artwork_id || '')}
|
||||
onChange={(val) => updateForm('cover_artwork_id', val)}
|
||||
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"
|
||||
>
|
||||
<option value="">Automatic cover</option>
|
||||
{attachedCoverOptions.map((artwork) => (
|
||||
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder="Automatic cover"
|
||||
options={attachedCoverOptions.map((a) => ({ value: String(a.id), label: a.title }))}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Smart Sort" help="How matching artworks should be ordered in this collection.">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={smartRules.sort}
|
||||
onChange={(event) => {
|
||||
setSmartRules((current) => ({ ...current, sort: event.target.value }))
|
||||
updateForm('sort_mode', event.target.value)
|
||||
onChange={(val) => {
|
||||
setSmartRules((current) => ({ ...current, sort: val }))
|
||||
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"
|
||||
>
|
||||
{(smartRuleOptions?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
options={(smartRuleOptions?.sort_options || [])}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
@@ -2061,48 +2021,32 @@ export default function CollectionManage() {
|
||||
<AdvancedSection title="Collaboration & Access" icon="fa-user-group" defaultOpen={mode === 'edit'}>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<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">
|
||||
<option value="personal">Personal</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="editorial">Editorial</option>
|
||||
</select>
|
||||
<NovaSelect value={form.type} onChange={(val) => updateForm('type', val)} searchable={false} options={[{ value: 'personal', label: 'Personal' }, { value: 'community', label: 'Community' }, { value: 'editorial', label: 'Editorial' }]} />
|
||||
</Field>
|
||||
<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">
|
||||
<option value="closed">Closed — curated by you only</option>
|
||||
<option value="invite_only">Invite only</option>
|
||||
<option value="open">Open submissions</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} />
|
||||
Allow submissions
|
||||
</label>
|
||||
<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.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} />
|
||||
Allow comments
|
||||
</label>
|
||||
<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.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} />
|
||||
Allow saves
|
||||
</label>
|
||||
<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 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_submissions} onChange={(event) => updateForm('allow_submissions', event.target.checked)} label="Allow submissions" />
|
||||
</div>
|
||||
<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={form.allow_comments} onChange={(event) => updateForm('allow_comments', event.target.checked)} label="Allow comments" />
|
||||
</div>
|
||||
<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={form.allow_saves} onChange={(event) => updateForm('allow_saves', event.target.checked)} label="Allow saves" />
|
||||
</div>
|
||||
<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={form.commercial_eligibility} onChange={(event) => updateForm('commercial_eligibility', event.target.checked)} label="Commercially eligible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.type === 'editorial' ? (
|
||||
<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.">
|
||||
<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">
|
||||
<option value="creator">Current curator</option>
|
||||
<option value="staff_account">Staff account</option>
|
||||
<option value="system">System editorial identity</option>
|
||||
</select>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
{form.editorial_owner_mode === 'staff_account' ? (
|
||||
<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} />
|
||||
</Field>
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
<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} />
|
||||
@@ -2195,14 +2133,7 @@ export default function CollectionManage() {
|
||||
|
||||
<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.">
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -2244,10 +2175,9 @@ export default function CollectionManage() {
|
||||
<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} />
|
||||
</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">
|
||||
<input type="checkbox" checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} />
|
||||
Analytics enabled
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={form.analytics_enabled} onChange={(event) => updateForm('analytics_enabled', event.target.checked)} label="Analytics enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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]" />
|
||||
<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">
|
||||
<option value="editor">Editor</option>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<NovaSelect value={inviteRole} onChange={(val) => setInviteRole(val)} searchable={false} options={[{ value: 'editor', label: 'Editor' }, { value: 'contributor', label: 'Contributor' }, { value: 'viewer', label: 'Viewer' }]} />
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
{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]" />
|
||||
) : (
|
||||
@@ -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="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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={selectedLinkedCollectionId}
|
||||
onChange={(event) => setSelectedLinkedCollectionId(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="">Select a collection</option>
|
||||
{linkedCollectionOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.title}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => setSelectedLinkedCollectionId(value)}
|
||||
options={linkedCollectionOptions.map((item) => ({ value: String(item.id), label: item.title }))}
|
||||
placeholder="Select a collection"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Entity type</label>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={selectedEntityType}
|
||||
onChange={(event) => {
|
||||
const nextType = event.target.value
|
||||
const nextOptions = Array.isArray(entityLinkOptions[nextType]) ? entityLinkOptions[nextType] : []
|
||||
setSelectedEntityType(nextType)
|
||||
setSelectedEntityId(nextOptions[0]?.id || '')
|
||||
onChange={(value) => {
|
||||
const nextOptions = Array.isArray(entityLinkOptions[value]) ? entityLinkOptions[value] : []
|
||||
setSelectedEntityType(value)
|
||||
setSelectedEntityId(String(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"
|
||||
>
|
||||
<option value="creator">Creator</option>
|
||||
<option value="artwork">Artwork</option>
|
||||
<option value="story">Story</option>
|
||||
<option value="category">Category</option>
|
||||
<option value="campaign">Campaign</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="tag">Tag or Theme</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'creator', label: 'Creator' },
|
||||
{ value: 'artwork', label: 'Artwork' },
|
||||
{ value: 'story', label: 'Story' },
|
||||
{ value: 'category', label: 'Category' },
|
||||
{ value: 'campaign', label: 'Campaign' },
|
||||
{ value: 'event', label: 'Event' },
|
||||
{ value: 'tag', label: 'Tag or Theme' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Choose entity</label>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={selectedEntityId}
|
||||
onChange={(event) => setSelectedEntityId(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="">Select an entity</option>
|
||||
{(entityLinkOptions[selectedEntityType] || []).map((item) => (
|
||||
<option key={`${selectedEntityType}-${item.id}`} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => setSelectedEntityId(value)}
|
||||
options={(entityLinkOptions[selectedEntityType] || []).map((item) => ({ value: String(item.id), label: item.label }))}
|
||||
placeholder="Select an entity"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<Field label="Moderation Status">
|
||||
<select
|
||||
<NovaSelect
|
||||
value={collectionState?.moderation_status || 'active'}
|
||||
onChange={(event) => handleModerationStatusChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="under_review">Under review</option>
|
||||
<option value="restricted">Restricted</option>
|
||||
<option value="hidden">Hidden</option>
|
||||
</select>
|
||||
onChange={(value) => handleModerationStatusChange(value)}
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'under_review', label: 'Under review' },
|
||||
{ value: 'restricted', label: 'Restricted' },
|
||||
{ value: 'hidden', label: 'Hidden' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
<input type="checkbox" checked={form.allow_comments} onChange={(event) => handleModerationToggle('allow_comments', event.target.checked)} />
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={form.allow_comments} onChange={(event) => handleModerationToggle('allow_comments', event.target.checked)} />
|
||||
</div>
|
||||
<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>
|
||||
<input type="checkbox" checked={form.allow_submissions} onChange={(event) => handleModerationToggle('allow_submissions', event.target.checked)} />
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={form.allow_submissions} onChange={(event) => handleModerationToggle('allow_submissions', event.target.checked)} />
|
||||
</div>
|
||||
<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>
|
||||
<input type="checkbox" checked={form.allow_saves} onChange={(event) => handleModerationToggle('allow_saves', event.target.checked)} />
|
||||
</label>
|
||||
<Checkbox checked={form.allow_saves} onChange={(event) => handleModerationToggle('allow_saves', event.target.checked)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react'
|
||||
import ArtworkGallery from '../../components/artwork/ArtworkGallery'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import CommentForm from '../../components/social/CommentForm'
|
||||
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">
|
||||
{canSubmit && submissionArtworkOptions?.length ? (
|
||||
<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">
|
||||
{submissionArtworkOptions.map((artwork) => (
|
||||
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<NovaSelect value={String(selectedArtworkId || '')} onChange={(val) => setSelectedArtworkId(val)} placeholder="Select artwork" options={submissionArtworkOptions.map((a) => ({ value: String(a.id), label: a.title }))} />
|
||||
<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>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import ShareToast from '../../components/ui/ShareToast'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
@@ -618,9 +620,7 @@ export default function CollectionStaffProgramming() {
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<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">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<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 }))} />
|
||||
</Field>
|
||||
<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} />
|
||||
@@ -715,9 +715,7 @@ export default function CollectionStaffProgramming() {
|
||||
</div>
|
||||
<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.">
|
||||
<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">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(selectedCollectionId || '')} onChange={(val) => setSelectedCollectionId(val)} options={collectionOptions.map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</Field>
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
Placement eligible override
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} label="Placement eligible override" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
@@ -401,13 +403,13 @@ export default function CollectionStaffSurfaces() {
|
||||
<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="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="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="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"><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="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="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>
|
||||
<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>
|
||||
@@ -423,14 +425,14 @@ export default function CollectionStaffSurfaces() {
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
|
||||
</div>
|
||||
<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="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="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="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"><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"><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="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="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>
|
||||
<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">
|
||||
@@ -459,7 +461,7 @@ export default function CollectionStaffSurfaces() {
|
||||
const checked = batchForm.collection_ids.includes(option.id)
|
||||
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]'}`}>
|
||||
<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="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>
|
||||
@@ -487,10 +489,10 @@ export default function CollectionStaffSurfaces() {
|
||||
<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>
|
||||
<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="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="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"><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>
|
||||
<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="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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
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.">
|
||||
<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
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={Boolean(form.is_active)}
|
||||
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>
|
||||
</label>
|
||||
@@ -625,22 +625,9 @@ export default function FeaturedArtworksAdmin() {
|
||||
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"
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
<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' }]} />
|
||||
<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">
|
||||
<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>
|
||||
<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)' }]} />
|
||||
<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'}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
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 } = {}) {
|
||||
return fetch(url, {
|
||||
@@ -396,16 +398,15 @@ export default function NovaCardsAdminIndex() {
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])}
|
||||
onChange={(event) => setReportDispositions((current) => ({ ...current, [report.id]: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-amber-200/20 bg-[#0d1726] px-4 py-3 text-white"
|
||||
>
|
||||
{dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => <option key={`${report.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => setReportDispositions((current) => ({ ...current, [report.id]: value }))}
|
||||
options={dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(report.target.moderation_target.available_actions || []).map((actionItem) => (
|
||||
@@ -497,40 +498,34 @@ export default function NovaCardsAdminIndex() {
|
||||
</div>
|
||||
<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">
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['draft', 'processing', 'published', 'hidden', 'rejected'].map((item) => <option key={`${card.id}-${item}`} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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 }))} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['pending', 'approved', 'flagged', 'rejected'].map((item) => <option key={`${card.id}-mod-${item}`} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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 }))} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Disposition</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={preferredDisposition(card.moderation_status, cardDispositions[card.id])}
|
||||
onChange={(event) => {
|
||||
const disposition = event.target.value
|
||||
setCardDispositions((current) => ({ ...current, [card.id]: disposition }))
|
||||
updateCard(card.id, { moderation_status: card.moderation_status, disposition })
|
||||
onChange={(val) => {
|
||||
setCardDispositions((current) => ({ ...current, [card.id]: val }))
|
||||
updateCard(card.id, { moderation_status: card.moderation_status, disposition: val })
|
||||
}}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"
|
||||
>
|
||||
{dispositionOptionsForStatus(card.moderation_status).map((option) => <option key={`${card.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<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">
|
||||
searchable={false}
|
||||
options={dispositionOptionsForStatus(card.moderation_status)}
|
||||
/>
|
||||
</div>
|
||||
<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>Featured</span>
|
||||
<input type="checkbox" checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} className="h-4 w-4" />
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} />
|
||||
</div>
|
||||
<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>
|
||||
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} className="h-4 w-4" />
|
||||
</label>
|
||||
<Checkbox checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -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.total_views_count || 0} views</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>
|
||||
<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" />
|
||||
</label>
|
||||
<Checkbox checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from '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 } = {}) {
|
||||
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.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" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
<option value="asset">asset</option>
|
||||
<option value="template">template</option>
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.type} onChange={(val) => setForm((current) => ({ ...current, type: val }))} searchable={false} options={[{ value: 'asset', label: 'asset' }, { value: 'template', label: 'template' }]} />
|
||||
</div>
|
||||
<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) => {
|
||||
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" />
|
||||
</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">
|
||||
<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>
|
||||
<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.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} label="Active" />
|
||||
<Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from '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 } = {}) {
|
||||
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" />
|
||||
<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" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['draft', 'active', 'completed', 'archived'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<NovaSelect value={form.status} onChange={(val) => setForm((current) => ({ ...current, status: val }))} searchable={false} options={['draft', 'active', 'completed', 'archived'].map((s) => ({ value: s, label: s }))} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
<option value="">No winner</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<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 }))} />
|
||||
</div>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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" />
|
||||
@@ -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" />
|
||||
</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">
|
||||
<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>
|
||||
<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.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
|
||||
<Checkbox checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} label="Featured" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from '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 } = {}) {
|
||||
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="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">
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{admins.map((admin) => <option key={admin.id} value={admin.id}>{admin.name || admin.username}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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 }))} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
<option value="public">public</option>
|
||||
<option value="private">private</option>
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} searchable={false} options={[{ value: 'public', label: 'public' }, { value: 'private', label: 'private' }]} />
|
||||
</div>
|
||||
<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" />
|
||||
<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 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">
|
||||
<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>
|
||||
<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.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official collection" />
|
||||
<Checkbox checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} label="Featured collection" />
|
||||
</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}
|
||||
</div>
|
||||
@@ -167,10 +164,7 @@ export default function NovaCardsCollectionAdmin() {
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<option value="">Select a card</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(cardId || '')} onChange={(val) => setCardId(val)} placeholder="Select a card" options={cards.map((c) => ({ value: String(c.id), label: c.title }))} />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from '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 } = {}) {
|
||||
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.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" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{fonts.map((font) => <option key={font.key} value={font.key}>{font.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{gradients.map((gradient) => <option key={gradient.key} value={gradient.key}>{gradient.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['left', 'center', 'right'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="text-sm text-slate-300">
|
||||
<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">
|
||||
{['none', 'dark-soft', 'dark-strong', 'light-soft'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<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} />
|
||||
</div>
|
||||
<label className="text-sm text-slate-300">
|
||||
<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" />
|
||||
@@ -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="flex flex-wrap gap-3">
|
||||
{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">
|
||||
<input type="checkbox" checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} className="h-4 w-4" />
|
||||
{format.label}
|
||||
</label>
|
||||
<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">
|
||||
<Checkbox checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} label={format.label} />
|
||||
</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">
|
||||
<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>
|
||||
<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.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} label="Active" />
|
||||
<Checkbox checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} label="Official" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
@@ -475,9 +476,7 @@ export default function SavedCollections() {
|
||||
</div>
|
||||
{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">
|
||||
<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">
|
||||
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
|
||||
</select>
|
||||
<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 }))} />
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import useWebShare from '../../hooks/useWebShare'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function normalizeText(value) {
|
||||
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>
|
||||
<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 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>
|
||||
<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">
|
||||
<option value="latest">Latest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<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' }]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArtworkGrid artworks={filteredArtworks} emptyLabel="No published artworks match the current filter." />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
@@ -188,18 +189,16 @@ export default function AiBiographyAdmin() {
|
||||
</label>
|
||||
|
||||
{['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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters[key] || 'all'}
|
||||
onChange={(event) => updateFilter(key, event.target.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"
|
||||
>
|
||||
{(filterOptions[key] || []).map((option) => (
|
||||
<option key={`${key}-${option.value}`} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilter(key, value)}
|
||||
className="mt-2"
|
||||
options={(filterOptions[key] || []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="lg:col-span-full flex flex-wrap gap-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import ArtworkViewer from '../../components/viewer/ArtworkViewer'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
@@ -140,31 +141,27 @@ export default function ArtworkMaturityQueue() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={aiAction}
|
||||
onChange={(event) => load(status, event.target.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"
|
||||
>
|
||||
{(filterOptions.aiAction || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => load(status, value, aiStatus)}
|
||||
className="mt-2"
|
||||
options={(filterOptions.aiAction || []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={aiStatus}
|
||||
onChange={(event) => load(status, aiAction, event.target.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"
|
||||
>
|
||||
{(filterOptions.aiStatus || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => load(status, aiAction, value)}
|
||||
className="mt-2"
|
||||
options={(filterOptions.aiStatus || []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
@@ -105,18 +106,14 @@ export default function StudioActivity() {
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search activity</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Message, actor, or module" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span>
|
||||
<select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{typeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={typeOptions} searchable={false} />
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Content type</span>
|
||||
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={moduleOptions} searchable={false} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={() => updateFilters({ q: '', type: 'all', module: 'all' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
|
||||
</div>
|
||||
|
||||
@@ -791,6 +791,7 @@ export default function StudioArtworkEdit() {
|
||||
.map((world) => ({
|
||||
world_id: Number(world.id),
|
||||
note: typeof world.note === 'string' ? world.note : '',
|
||||
source_surface: 'navigation',
|
||||
}))
|
||||
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0),
|
||||
evolution_target_artwork_id: evolutionTarget?.id || null,
|
||||
@@ -2166,18 +2167,14 @@ export default function StudioArtworkEdit() {
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Story type</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={evolutionRelationType}
|
||||
onChange={(event) => setEvolutionRelationType(event.target.value)}
|
||||
onChange={(value) => setEvolutionRelationType(value)}
|
||||
disabled={saving || !evolutionTarget}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{evolutionRelationTypes.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="mt-2"
|
||||
options={evolutionRelationTypes.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</label>
|
||||
{errors.evolution_relation_type?.[0] ? <p className="text-sm text-red-400">{errors.evolution_relation_type[0]}</p> : null}
|
||||
|
||||
@@ -2474,6 +2471,7 @@ export default function StudioArtworkEdit() {
|
||||
onNoteChange={(worldId, note) => setWorldSubmissionOptions((current) => current.map((world) => (
|
||||
Number(world.id) === Number(worldId) ? { ...world, note } : world
|
||||
)))}
|
||||
analyticsContext={{ sourceSurface: 'navigation', sourceDetail: 'studio_artwork_edit' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
function formatDate(value) {
|
||||
@@ -78,50 +79,35 @@ export default function StudioAssets() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Type</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.type || 'all'}
|
||||
onChange={(event) => updateFilters({ type: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilters({ type: value })}
|
||||
options={typeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Source</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.source || 'all'}
|
||||
onChange={(event) => updateFilters({ source: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sourceOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilters({ source: value })}
|
||||
options={sourceOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.sort || 'recent'}
|
||||
onChange={(event) => updateFilters({ sort: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilters({ sort: value })}
|
||||
options={sortOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
|
||||
@@ -3,6 +3,187 @@ import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function parseFocusDate(value) {
|
||||
if (!value) return new Date()
|
||||
|
||||
const parsed = new Date(`${value}T12:00:00`)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
function toFocusDateValue(date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function shiftFocusDate(value, view, direction) {
|
||||
const next = new Date(parseFocusDate(value))
|
||||
|
||||
if (view === 'week') {
|
||||
next.setDate(next.getDate() + (direction * 7))
|
||||
return toFocusDateValue(next)
|
||||
}
|
||||
|
||||
const originalDay = next.getDate()
|
||||
next.setDate(1)
|
||||
next.setMonth(next.getMonth() + direction)
|
||||
const lastDayOfMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()
|
||||
next.setDate(Math.min(originalDay, lastDayOfMonth))
|
||||
|
||||
return toFocusDateValue(next)
|
||||
}
|
||||
|
||||
function itemHref(item) {
|
||||
return item.edit_url || item.manage_url || item.preview_url || item.view_url || '#'
|
||||
}
|
||||
|
||||
function CalendarThumb({ item, className = 'h-full w-full', showTime = false }) {
|
||||
return (
|
||||
<a href={itemHref(item)} className={`group relative block overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80 ${className}`}>
|
||||
{item.image_url ? (
|
||||
<img src={item.image_url} alt={item.title || ''} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
|
||||
{item.module_label || 'Item'}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/70 to-transparent p-2">
|
||||
<div className="truncate text-[11px] font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-slate-300">
|
||||
<span className="truncate">{item.module_label}</span>
|
||||
{showTime && item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarInlineItem({ item, showTime = true }) {
|
||||
return (
|
||||
<a href={itemHref(item)} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-2.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/5">
|
||||
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-slate-950/80">
|
||||
{item.image_url ? (
|
||||
<img src={item.image_url} alt={item.title || ''} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-[9px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">
|
||||
{String(item.module_label || 'Item').slice(0, 3)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<span className="truncate">{item.module_label}</span>
|
||||
{showTime && item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayModal({ day, busyKey, endpoints, onAction, onClose, nowMs }) {
|
||||
if (!day) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 p-3 backdrop-blur-sm md:items-center md:p-6" role="dialog" aria-modal="true" aria-label={`Scheduled items for ${day.label || day.date}`}>
|
||||
<button type="button" aria-label="Close day details" className="absolute inset-0 cursor-default" onClick={onClose} />
|
||||
<div className="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_30%),linear-gradient(180deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.98))] shadow-2xl shadow-black/40">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4 md:px-6">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Day queue</div>
|
||||
<h3 className="mt-1 text-xl font-semibold text-white">{day.label || day.date}</h3>
|
||||
<div className="mt-1 text-sm text-slate-400">{Number(day.count || 0).toLocaleString()} scheduled item{Number(day.count || 0) === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Close</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-5 py-4 md:px-6 md:py-5">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(day.detail_items || []).map((item) => (
|
||||
<div key={item.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
<a href={itemHref(item)} className="group block">
|
||||
<div className="relative h-36 overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image_url ? (
|
||||
<img src={item.image_url} alt={item.title || ''} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(135deg,rgba(14,165,233,0.2),rgba(15,23,42,0.95))] text-sm font-semibold uppercase tracking-[0.18em] text-sky-100/80">
|
||||
{item.module_label || 'Item'}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/75 to-transparent p-3">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-slate-300">
|
||||
<span className="truncate">{item.module_label}</span>
|
||||
{item.scheduled_at ? <span className="shrink-0 text-sky-200">{formatScheduledDate(item.scheduled_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="text-xs font-medium text-sky-200">{formatReleaseCountdown(item.scheduled_at, nowMs)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{item.subtitle || item.workflow?.readiness?.label || 'Scheduled item'}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busyKey === `publish:${item.id}`} onClick={() => onAction(endpoints.publishNowPattern, item, 'publish')} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50">Publish now</button>
|
||||
<button type="button" disabled={busyKey === `unschedule:${item.id}`} onClick={() => onAction(endpoints.unschedulePattern, item, 'unschedule')} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50">Unschedule</button>
|
||||
<a href={itemHref(item)} className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/5">Open</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarMonthDay({ day, onOpenDetail }) {
|
||||
const items = day.items || []
|
||||
const overflowCount = Number(day.overflow_count || Math.max(0, Number(day.count || 0) - items.length))
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-[156px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span>
|
||||
{hasItems ? (
|
||||
<button type="button" onClick={() => onOpenDetail(day)} className="rounded-full border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">{day.count}</button>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{day.count}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasItems ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.length === 1 ? (
|
||||
<CalendarThumb item={items[0]} className="h-[92px]" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{items.map((item) => (
|
||||
<CalendarThumb key={item.id} item={item} className="h-[58px]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{overflowCount > 0 ? (
|
||||
<button type="button" onClick={() => onOpenDetail(day)} className="w-full rounded-xl border border-dashed border-white/10 px-2.5 py-1.5 text-left text-[11px] font-medium text-slate-300 transition hover:border-sky-300/20 hover:bg-sky-300/5 hover:text-sky-100">
|
||||
+{overflowCount} more scheduled
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-2xl border border-dashed border-white/10 px-3 py-6 text-center text-[11px] uppercase tracking-[0.18em] text-slate-600">
|
||||
Quiet day
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
@@ -26,10 +207,13 @@ export default function StudioCalendar() {
|
||||
const calendar = props.calendar || {}
|
||||
const filters = calendar.filters || {}
|
||||
const summary = calendar.summary || {}
|
||||
const currentView = filters.view || 'month'
|
||||
const [busyKey, setBusyKey] = useState(null)
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
const [selectedDay, setSelectedDay] = useState(null)
|
||||
|
||||
const updateFilters = (patch) => {
|
||||
setSelectedDay(null)
|
||||
const next = { ...filters, ...patch }
|
||||
trackStudioEvent('studio_scheduled_opened', {
|
||||
surface: studioSurface(),
|
||||
@@ -43,6 +227,14 @@ export default function StudioCalendar() {
|
||||
})
|
||||
}
|
||||
|
||||
const shiftCalendar = (direction) => {
|
||||
updateFilters({ focus_date: shiftFocusDate(filters.focus_date, currentView, direction) })
|
||||
}
|
||||
|
||||
const resetCalendarFocus = () => {
|
||||
updateFilters({ focus_date: toFocusDateValue(new Date()) })
|
||||
}
|
||||
|
||||
const runAction = async (pattern, item, key) => {
|
||||
const url = String(pattern || '').replace('__MODULE__', item.module).replace('__ID__', String(item.numeric_id))
|
||||
setBusyKey(`${key}:${item.id}`)
|
||||
@@ -67,6 +259,26 @@ export default function StudioCalendar() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [calendar.scheduled_items, summary.next_publish_at])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDay?.date) return
|
||||
|
||||
const nextSelectedDay = (calendar.month?.days || []).find((day) => day.date === selectedDay.date) || null
|
||||
setSelectedDay(nextSelectedDay)
|
||||
}, [calendar.month?.days, selectedDay?.date])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDay) return undefined
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSelectedDay(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedDay])
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
@@ -83,9 +295,9 @@ export default function StudioCalendar() {
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search planning queue</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><select value={filters.view || 'month'} onChange={(event) => updateFilters({ view: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.view_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Queue</span><select value={filters.status || 'scheduled'} onChange={(event) => updateFilters({ status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(calendar.status_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">View</span><NovaSelect value={filters.view || 'month'} onChange={(val) => updateFilters({ view: val })} options={calendar.view_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={calendar.module_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Queue</span><NovaSelect value={filters.status || 'scheduled'} onChange={(val) => updateFilters({ status: val })} options={calendar.status_options || []} searchable={false} /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -93,12 +305,22 @@ export default function StudioCalendar() {
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-5">
|
||||
{filters.view === 'week' ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{calendar.week?.label}</h2>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">Week planning</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => shiftCalendar(-1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Prev week</button>
|
||||
<button type="button" onClick={resetCalendarFocus} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Today</button>
|
||||
<button type="button" onClick={() => shiftCalendar(1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Next week</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
|
||||
{(calendar.week?.days || []).map((day) => (
|
||||
<div key={day.date} className="rounded-[22px] border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-sm font-semibold text-white">{day.label}</div>
|
||||
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-2xl border border-white/10 px-3 py-2 text-xs text-slate-200">{item.title}</a>) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
|
||||
<div className="mt-3 space-y-2">{day.items.length > 0 ? day.items.map((item) => <CalendarInlineItem key={item.id} item={item} />) : <div className="text-xs text-slate-500">No scheduled items</div>}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -106,13 +328,23 @@ export default function StudioCalendar() {
|
||||
) : filters.view === 'agenda' ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-white">Agenda</h2>
|
||||
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 px-3 py-2 text-sm text-slate-200"><span>{item.title}</span><span className="text-xs text-slate-500">{formatScheduledDate(item.scheduled_at)}</span></a>)}</div></div>)}</div>
|
||||
<div className="mt-4 space-y-4">{(calendar.agenda || []).map((group) => <div key={group.date} className="rounded-[22px] border border-white/10 bg-black/20 p-4"><div className="flex items-center justify-between gap-3"><div className="text-base font-semibold text-white">{group.label}</div><div className="text-xs uppercase tracking-[0.18em] text-slate-500">{group.count} items</div></div><div className="mt-3 space-y-2">{group.items.map((item) => <CalendarInlineItem key={item.id} item={item} />)}</div></div>)}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{calendar.month?.label}</h2>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">Month planning</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => shiftCalendar(-1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Prev month</button>
|
||||
<button type="button" onClick={resetCalendarFocus} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Today</button>
|
||||
<button type="button" onClick={() => shiftCalendar(1)} className="rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100">Next month</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-7 gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((label) => <div key={label} className="px-2 py-1">{label}</div>)}</div>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <div key={day.date} className={`min-h-[120px] rounded-[22px] border p-3 ${day.is_current_month ? 'border-white/10 bg-black/20' : 'border-white/5 bg-black/10'}`}><div className="flex items-center justify-between gap-2"><span className={`text-sm font-semibold ${day.is_current_month ? 'text-white' : 'text-slate-500'}`}>{day.day}</span><span className="text-[10px] uppercase tracking-[0.18em] text-slate-500">{day.count}</span></div><div className="mt-3 space-y-2">{day.items.map((item) => <a key={item.id} href={item.edit_url || item.manage_url} className="block rounded-xl border border-white/10 px-2 py-1.5 text-[11px] text-slate-200">{item.title}</a>)}</div></div>)}</div>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">{(calendar.month?.days || []).map((day) => <CalendarMonthDay key={day.date} day={day} onOpenDetail={setSelectedDay} />)}</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -134,6 +366,8 @@ export default function StudioCalendar() {
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<CalendarDayModal day={selectedDay} busyKey={busyKey} endpoints={props.endpoints} onAction={runAction} onClose={() => setSelectedDay(null)} nowMs={nowMs} />
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ import NovaCardGradientPicker from '../../components/nova-cards/NovaCardGradient
|
||||
import NovaCardFontPicker from '../../components/nova-cards/NovaCardFontPicker'
|
||||
import NovaCardAutosaveIndicator from '../../components/nova-cards/NovaCardAutosaveIndicator'
|
||||
import NovaCardPresetPicker from '../../components/nova-cards/NovaCardPresetPicker'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
const defaultMobileSteps = [
|
||||
{ key: 'format', label: 'Format', description: 'Choose the canvas shape and basic direction.' },
|
||||
@@ -948,10 +950,9 @@ export default function StudioCardEditor() {
|
||||
const currentProjectSummary = summarizeProjectSnapshot(card.project_json || {})
|
||||
|
||||
const editorTabs = [
|
||||
{ key: 'background', label: 'Background' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
{ key: 'typography', label: 'Typography' },
|
||||
{ key: 'layout', label: 'Layout' },
|
||||
{ key: 'background', label: 'Canvas' },
|
||||
{ key: 'content', label: 'Text' },
|
||||
{ key: 'style', label: 'Style' },
|
||||
{ key: 'publish', label: 'Publish' },
|
||||
]
|
||||
|
||||
@@ -1012,9 +1013,20 @@ export default function StudioCardEditor() {
|
||||
{/* Tab panels */}
|
||||
<div className="mt-2 rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
|
||||
|
||||
{/* BACKGROUND TAB */}
|
||||
{/* CANVAS TAB */}
|
||||
{activeTab === 'background' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Canvas format</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(editorOptions.formats || []).map((format) => (
|
||||
<button key={format.key} type="button" onClick={() => updateCard({ format: format.key })} className={pillClasses((card.format || 'square') === format.key)}>
|
||||
{format.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Template</div>
|
||||
<NovaCardTemplatePicker templates={editorOptions.templates || []} selectedId={card.template_id} onSelect={handleTemplateSelect} />
|
||||
@@ -1076,12 +1088,10 @@ export default function StudioCardEditor() {
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Overlay & depth</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-2 block">Overlay style</span>
|
||||
<select value={card.project_json?.background?.overlay_style || 'dark-soft'} onChange={(event) => updateCard({}, { background: { overlay_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{overlayOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.project_json?.background?.overlay_style || 'dark-soft'} onChange={(val) => updateCard({}, { background: { overlay_style: val } })} options={(overlayOptions || []).map((o) => ({ value: o.value, label: o.label }))} />
|
||||
</div>
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<span>Overlay opacity</span>
|
||||
@@ -1098,12 +1108,10 @@ export default function StudioCardEditor() {
|
||||
</div>
|
||||
<input type="range" min="0" max="32" step="4" value={card.project_json?.background?.blur_level || 0} onChange={(event) => updateCard({}, { background: { blur_level: Number(event.target.value) } })} className="w-full" />
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-2 block">Focal position</span>
|
||||
<select value={card.project_json?.background?.focal_position || 'center'} onChange={(event) => updateCard({}, { background: { focal_position: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.focal_positions || []).map((position) => <option key={position.key} value={position.key}>{position.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.project_json?.background?.focal_position || 'center'} onChange={(val) => updateCard({}, { background: { focal_position: val } })} options={(editorOptions.focal_positions || []).map((p) => ({ value: p.key, label: p.label }))} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1146,9 +1154,15 @@ export default function StudioCardEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CONTENT TAB */}
|
||||
{/* TEXT TAB */}
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Font family</div>
|
||||
<NovaCardFontPicker fonts={editorOptions.font_presets || []} selectedKey={card.project_json?.typography?.font_preset} onSelect={handleFontSelect} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<span className="mb-2 block">Title</span>
|
||||
<input value={card.title || ''} onChange={(event) => updateTextField('title', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" />
|
||||
@@ -1183,19 +1197,9 @@ export default function StudioCardEditor() {
|
||||
<button type="button" onClick={() => moveTextBlock(index, -1)} disabled={index === 0} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30">▲</button>
|
||||
<button type="button" onClick={() => moveTextBlock(index, 1)} disabled={index === textBlocks.length - 1} className="rounded border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white disabled:opacity-30">▼</button>
|
||||
</div>
|
||||
<select value={block.type || 'body'} onChange={(event) => updateTextBlock(index, { type: event.target.value })} className="rounded-xl border border-white/10 bg-[#0d1726] px-2 py-2 text-xs text-white outline-none">
|
||||
<option value="title">Title</option>
|
||||
<option value="quote">Quote</option>
|
||||
<option value="author">Author</option>
|
||||
<option value="source">Source</option>
|
||||
<option value="body">Body</option>
|
||||
<option value="caption">Caption</option>
|
||||
</select>
|
||||
<NovaSelect value={block.type || 'body'} onChange={(val) => updateTextBlock(index, { type: val })} searchable={false} options={[{ value: 'title', label: 'Title' }, { value: 'quote', label: 'Quote' }, { value: 'author', label: 'Author' }, { value: 'source', label: 'Source' }, { value: 'body', label: 'Body' }, { value: 'caption', label: 'Caption' }]} />
|
||||
<input value={block.text || ''} onChange={(event) => updateTextBlock(index, { text: event.target.value, enabled: block.type === 'title' || block.type === 'quote' ? true : Boolean(event.target.value.trim()) })} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-sm text-white outline-none" />
|
||||
<label className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<input type="checkbox" checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
||||
On
|
||||
</label>
|
||||
<Checkbox checked={block.enabled !== false} onChange={(e) => updateTextBlock(index, { enabled: e.target.checked })} label="On" />
|
||||
<button type="button" onClick={() => removeTextBlock(index)} disabled={block.type === 'title' || block.type === 'quote'} className="text-rose-300 transition hover:text-rose-200 disabled:opacity-30">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1204,16 +1208,11 @@ export default function StudioCardEditor() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TYPOGRAPHY TAB */}
|
||||
{activeTab === 'typography' && (
|
||||
<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>
|
||||
|
||||
)}
|
||||
{/* STYLE TAB */}
|
||||
{activeTab === 'style' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote size</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1318,39 +1317,20 @@ export default function StudioCardEditor() {
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quote mark & panel</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-1.5 block text-xs">Quote mark style</span>
|
||||
<select value={card.project_json?.typography?.quote_mark_preset || 'none'} onChange={(event) => updateCard({}, { typography: { quote_mark_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.quote_mark_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
<NovaSelect value={card.project_json?.typography?.quote_mark_preset || 'none'} onChange={(val) => updateCard({}, { typography: { quote_mark_preset: val } })} options={(editorOptions.quote_mark_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
|
||||
</div>
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-1.5 block text-xs">Text panel style</span>
|
||||
<select value={card.project_json?.typography?.text_panel_style || 'none'} onChange={(event) => updateCard({}, { typography: { text_panel_style: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.text_panel_styles || []).map((style) => <option key={style.key} value={style.key}>{style.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.project_json?.typography?.text_panel_style || 'none'} onChange={(val) => updateCard({}, { typography: { text_panel_style: val } })} options={(editorOptions.text_panel_styles || []).map((s) => ({ value: s.key, label: s.label }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LAYOUT TAB */}
|
||||
{activeTab === 'layout' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Format</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(editorOptions.formats || []).map((format) => (
|
||||
<button key={format.key} type="button" onClick={() => updateCard({ format: format.key })} className={pillClasses((card.format || 'square') === format.key)}>
|
||||
{format.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-2">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Layout preset</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1412,25 +1392,19 @@ export default function StudioCardEditor() {
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Frame & effects</div>
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-1.5 block">Frame</span>
|
||||
<select value={card.project_json?.frame?.preset || 'none'} onChange={(event) => updateCard({}, { frame: { preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.frame_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.project_json?.frame?.preset || 'none'} onChange={(val) => updateCard({}, { frame: { preset: val } })} options={(editorOptions.frame_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-1.5 block text-xs">Color grade</span>
|
||||
<select value={card.project_json?.effects?.color_grade || 'none'} onChange={(event) => updateCard({}, { effects: { color_grade: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.color_grade_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
<NovaSelect value={card.project_json?.effects?.color_grade || 'none'} onChange={(val) => updateCard({}, { effects: { color_grade: val } })} options={(editorOptions.color_grade_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
|
||||
</div>
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-1.5 block text-xs">Effect</span>
|
||||
<select value={card.project_json?.effects?.effect_preset || 'none'} onChange={(event) => updateCard({}, { effects: { effect_preset: event.target.value } })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{(editorOptions.effect_presets || []).map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.project_json?.effects?.effect_preset || 'none'} onChange={(val) => updateCard({}, { effects: { effect_preset: val } })} options={(editorOptions.effect_presets || []).map((p) => ({ value: p.key, label: p.label }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1490,18 +1464,16 @@ export default function StudioCardEditor() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PUBLISH TAB */}
|
||||
{activeTab === 'publish' && (
|
||||
<div className="space-y-5">
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-2 block">Category</span>
|
||||
<select value={card.category_id || ''} onChange={(event) => updateCard({ category_id: event.target.value ? Number(event.target.value) : null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Select category</option>
|
||||
{(editorOptions.categories || []).map((cat) => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={String(card.category_id || '')} onChange={(val) => updateCard({ category_id: val ? Number(val) : null })} placeholder="Select category" options={(editorOptions.categories || []).map((c) => ({ value: String(c.id), label: c.name }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-sm text-slate-300">Visibility</div>
|
||||
@@ -1528,10 +1500,10 @@ export default function StudioCardEditor() {
|
||||
{ key: 'allow_export', label: 'Allow export' },
|
||||
{ key: 'allow_background_reuse', label: 'Allow background reuse' },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<div key={key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<span>{label}</span>
|
||||
<input type="checkbox" checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} className="h-4 w-4 rounded border-white/20 bg-[#0d1726]" />
|
||||
</label>
|
||||
<Checkbox checked={key === 'allow_export' ? Boolean(card.allow_export !== false) : Boolean(card[key])} onChange={(event) => updateCard({ [key]: event.target.checked })} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1541,22 +1513,16 @@ export default function StudioCardEditor() {
|
||||
</label>
|
||||
|
||||
{advancedMode && (
|
||||
<label className="block text-sm text-slate-300">
|
||||
<div className="block text-sm text-slate-300">
|
||||
<span className="mb-2 block">Style family</span>
|
||||
<select value={card.style_family || ''} onChange={(event) => updateCard({ style_family: event.target.value || null })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">None</option>
|
||||
{(editorOptions.style_families || []).map((sf) => <option key={sf.key} value={sf.key}>{sf.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={card.style_family || ''} onChange={(val) => updateCard({ style_family: val || null })} placeholder="None" options={(editorOptions.style_families || []).map((sf) => ({ value: sf.key, label: sf.label }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Save to collection</div>
|
||||
<div className="flex gap-2">
|
||||
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Default saved cards</option>
|
||||
{collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(selectedCollectionId || '')} onChange={(val) => setSelectedCollectionId(val)} placeholder="Default saved cards" options={collections.map((c) => ({ value: String(c.id), label: c.name }))} />
|
||||
<button type="button" onClick={saveToCollection} disabled={!cardId || busy} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">Save</button>
|
||||
</div>
|
||||
<button type="button" onClick={createCollection} className="mt-2 text-sm text-slate-400 transition hover:text-white">+ Create collection</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
const reportReasons = [
|
||||
@@ -197,20 +198,15 @@ export default function StudioComments() {
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.module || 'all'}
|
||||
onChange={(event) => updateFilters({ module: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{moduleOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilters({ module: value })}
|
||||
options={moduleOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -352,20 +348,15 @@ export default function StudioComments() {
|
||||
{reportFor === comment.id && (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={reportReason}
|
||||
onChange={(event) => setReportReason(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{reportReasons.map((reason) => (
|
||||
<option key={reason.value} value={reason.value} className="bg-slate-900">
|
||||
{reason.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => setReportReason(value)}
|
||||
options={reportReasons.map((reason) => ({ value: reason.value, label: reason.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Details</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function SummaryCard({ label, value, icon }) {
|
||||
return (
|
||||
@@ -49,18 +50,14 @@ export default function StudioFollowers() {
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateQuery({ q: event.target.value, page: 1 })} placeholder="Search followers" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||
<select value={filters.sort || 'recent'} onChange={(event) => updateQuery({ sort: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{(listing.sort_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<NovaSelect value={filters.sort || 'recent'} onChange={(val) => updateQuery({ sort: val, page: 1 })} options={listing.sort_options || []} searchable={false} />
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Relationship</span>
|
||||
<select value={filters.relationship || 'all'} onChange={(event) => updateQuery({ relationship: event.target.value, page: 1 })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{(listing.relationship_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={filters.relationship || 'all'} onChange={(val) => updateQuery({ relationship: val, page: 1 })} options={listing.relationship_options || []} searchable={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioGroupAssets() {
|
||||
const { props } = usePage()
|
||||
@@ -46,18 +48,15 @@ export default function StudioGroupAssets() {
|
||||
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-6">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Asset title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" />
|
||||
<select value={form.data.category} onChange={(event) => form.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={form.data.category} onChange={(val) => form.setData('category', val)} options={props.categoryOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
<input type="file" onChange={(event) => form.setData('file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="What is this asset for?" rows={3} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured asset</label>
|
||||
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Featured asset" /></div>
|
||||
</div>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Upload asset</button>
|
||||
</form>
|
||||
@@ -73,14 +72,8 @@ export default function StudioGroupAssets() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<input value={filters.data.q} onChange={(event) => filters.setData('q', event.target.value)} placeholder="Search title, description, or filename" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={filters.data.category} onChange={(event) => filters.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="all">All categories</option>
|
||||
{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<select value={filters.data.bucket} onChange={(event) => filters.setData('bucket', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="all">All visibility levels</option>
|
||||
{(props.listing?.bucket_options || []).filter((option) => option.value !== 'all').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={filters.data.category} onChange={(val) => filters.setData('category', val)} options={[{ value: 'all', label: 'All categories' }, ...(props.categoryOptions || [])]} searchable={false} />
|
||||
<NovaSelect value={filters.data.bucket} onChange={(val) => filters.setData('bucket', val)} options={[{ value: 'all', label: 'All visibility levels' }, ...(props.listing?.bucket_options || []).filter((option) => option.value !== 'all')]} searchable={false} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioGroupChallengeEditor() {
|
||||
const { props } = usePage()
|
||||
const challenge = props.challenge || null
|
||||
const outcomeArtworkOptions = Array.isArray(challenge?.artworks) ? challenge.artworks : []
|
||||
const form = useForm({
|
||||
title: challenge?.title || '',
|
||||
summary: challenge?.summary || '',
|
||||
@@ -20,6 +22,14 @@ export default function StudioGroupChallengeEditor() {
|
||||
linked_collection_id: challenge?.linked_collection?.id || '',
|
||||
linked_project_id: challenge?.linked_project?.id || '',
|
||||
featured_artwork_id: challenge?.featured_artwork?.id || '',
|
||||
outcomes: Array.isArray(challenge?.outcomes) ? challenge.outcomes.map((outcome) => ({
|
||||
artwork_id: outcome.artwork_id || '',
|
||||
outcome_type: outcome.outcome_type || props.outcomeTypeOptions?.[0]?.value || 'winner',
|
||||
position: outcome.position || '',
|
||||
sort_order: outcome.sort_order ?? 0,
|
||||
title_override: outcome.title_override || '',
|
||||
note: outcome.note || '',
|
||||
})) : [],
|
||||
cover_file: null,
|
||||
})
|
||||
const attachForm = useForm({ artwork_id: '' })
|
||||
@@ -34,6 +44,30 @@ export default function StudioGroupChallengeEditor() {
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
const updateOutcome = (index, key, value) => {
|
||||
const next = [...(form.data.outcomes || [])]
|
||||
next[index] = { ...next[index], [key]: value }
|
||||
form.setData('outcomes', next)
|
||||
}
|
||||
|
||||
const addOutcome = () => {
|
||||
form.setData('outcomes', [
|
||||
...(form.data.outcomes || []),
|
||||
{
|
||||
artwork_id: outcomeArtworkOptions[0]?.id || '',
|
||||
outcome_type: props.outcomeTypeOptions?.[0]?.value || 'winner',
|
||||
position: '',
|
||||
sort_order: form.data.outcomes?.length || 0,
|
||||
title_override: '',
|
||||
note: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const removeOutcome = (index) => {
|
||||
form.setData('outcomes', (form.data.outcomes || []).filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
@@ -43,9 +77,9 @@ export default function StudioGroupChallengeEditor() {
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Challenge description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.participation_scope} onChange={(event) => form.setData('participation_scope', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.participationScopeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.participation_scope} onChange={(val) => form.setData('participation_scope', val)} options={props.participationScopeOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
@@ -53,25 +87,49 @@ export default function StudioGroupChallengeEditor() {
|
||||
</div>
|
||||
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={form.data.judging_mode} onChange={(event) => form.setData('judging_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No judging mode</option>
|
||||
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={form.data.judging_mode || ''} onChange={(val) => form.setData('judging_mode', val)} placeholder="No judging mode" options={(props.judgingModeOptions || []).map((o) => ({ value: o.value, label: o.label }))} searchable={false} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
Featured result rendering now comes from structured outcomes. Attach entries first, then assign winners and finalists below.
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
|
||||
{props.updateUrl ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Challenge outcomes</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">Choose from attached challenge entries only. These outcomes now drive public winners, finalists, and linked-world reward automation.</p>
|
||||
</div>
|
||||
<button type="button" onClick={addOutcome} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add outcome</button>
|
||||
</div>
|
||||
|
||||
{outcomeArtworkOptions.length === 0 ? (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-400">Attach challenge entries first. Outcomes are intentionally limited to artworks already entered into this challenge.</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{(form.data.outcomes || []).map((outcome, index) => (
|
||||
<div key={`${outcome.artwork_id || 'new'}-${index}`} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<NovaSelect value={String(outcome.artwork_id || '')} onChange={(val) => updateOutcome(index, 'artwork_id', val)} placeholder="Choose challenge entry" options={outcomeArtworkOptions.map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={outcome.outcome_type} onChange={(val) => updateOutcome(index, 'outcome_type', val)} options={props.outcomeTypeOptions || []} searchable={false} />
|
||||
<button type="button" onClick={() => removeOutcome(index)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-slate-300">Remove</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<input value={outcome.position} onChange={(event) => updateOutcome(index, 'position', event.target.value)} placeholder="Position" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={outcome.sort_order} onChange={(event) => updateOutcome(index, 'sort_order', event.target.value)} placeholder="Sort order" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={outcome.title_override} onChange={(event) => updateOutcome(index, 'title_override', event.target.value)} placeholder="Optional label override" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={outcome.note} onChange={(event) => updateOutcome(index, 'note', event.target.value)} placeholder="Optional editorial note" rows={3} className="mt-3 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save challenge</button>
|
||||
</form>
|
||||
@@ -81,10 +139,7 @@ export default function StudioGroupChallengeEditor() {
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={attachForm.data.artwork_id} onChange={(event) => attachForm.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(attachForm.data.artwork_id || '')} onChange={(val) => attachForm.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function slugifyGroupValue(value) {
|
||||
return String(value || '')
|
||||
@@ -199,18 +200,14 @@ export default function StudioGroupCreate() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<div className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Visibility</span>
|
||||
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} options={props.visibilityOptions || []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Membership policy</span>
|
||||
<select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.membership_policy} onChange={(val) => setForm((current) => ({ ...current, membership_policy: val }))} options={props.membershipPolicyOptions || []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-200">Links</span>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioGroupEventEditor() {
|
||||
const { props } = usePage()
|
||||
@@ -43,9 +45,9 @@ export default function StudioGroupEventEditor() {
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Event description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.event_type} onChange={(event) => form.setData('event_type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={form.data.event_type} onChange={(val) => form.setData('event_type', val)} options={props.typeOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
@@ -57,20 +59,11 @@ export default function StudioGroupEventEditor() {
|
||||
</div>
|
||||
<input value={form.data.external_url} onChange={(event) => form.setData('external_url', event.target.value)} placeholder="External link" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_challenge_id} onChange={(event) => form.setData('linked_challenge_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked challenge</option>
|
||||
{(props.challengeOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={String(form.data.linked_challenge_id || '')} onChange={(val) => form.setData('linked_challenge_id', val)} placeholder="No linked challenge" options={(props.challengeOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured event</label>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Featured event" /></div>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save event</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function formatInviteTimestamp(value) {
|
||||
if (!value) return null
|
||||
@@ -45,11 +46,7 @@ export default function StudioGroupInvitations() {
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]">
|
||||
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<NovaSelect value={invite.role} onChange={(val) => setInvite((current) => ({ ...current, role: val }))} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
|
||||
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={invite.expires_in_days} onChange={(event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value }))} type="number" min="1" max="30" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => router.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 })} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Send invite</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function overrideMap(member) {
|
||||
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
|
||||
@@ -86,11 +87,7 @@ export default function StudioGroupMembers() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
|
||||
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<NovaSelect value={invite.role} onChange={(val) => setInvite((current) => ({ ...current, role: val }))} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
|
||||
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
|
||||
</div>
|
||||
@@ -128,11 +125,7 @@ export default function StudioGroupMembers() {
|
||||
</div>
|
||||
<div>
|
||||
{canManageMembers && member.role !== 'owner' ? (
|
||||
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<NovaSelect value={member.role} onChange={(val) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: val })} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
|
||||
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
|
||||
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioGroupPostEditor() {
|
||||
const { props } = usePage()
|
||||
@@ -28,12 +29,10 @@ export default function StudioGroupPostEditor() {
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.data.type} onChange={(val) => form.setData('type', val)} options={Array.isArray(props.typeOptions) ? props.typeOptions : []} searchable={false} />
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function normalizeIds(values) {
|
||||
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0)
|
||||
@@ -48,35 +49,21 @@ export default function StudioGroupProjectEditor() {
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Longer project description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No lead</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
|
||||
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</div>
|
||||
<select multiple value={form.data.member_user_ids.map(String)} onChange={(event) => form.setData('member_user_ids', normalizeIds(event.target.selectedOptions))} className="min-h-40 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<NovaSelect multi value={form.data.member_user_ids} onChange={(vals) => form.setData('member_user_ids', (vals || []).map(Number).filter(Boolean))} placeholder="Select team members" options={(props.memberOptions || []).map((o) => ({ value: o.id, label: o.name || o.username }))} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_featured_artwork_id} onChange={(event) => form.setData('linked_featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.pinned_post_id} onChange={(event) => form.setData('pinned_post_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No pinned post</option>
|
||||
{(props.postOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.linked_featured_artwork_id || '')} onChange={(val) => form.setData('linked_featured_artwork_id', val)} placeholder="No featured artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={String(form.data.pinned_post_id || '')} onChange={(val) => form.setData('pinned_post_id', val)} placeholder="No pinned post" options={(props.postOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
@@ -87,7 +74,7 @@ export default function StudioGroupProjectEditor() {
|
||||
{props.statusUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); statusForm.post(props.statusUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Status</h2>
|
||||
<select value={statusForm.data.status} onChange={(event) => statusForm.setData('status', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={statusForm.data.status} onChange={(val) => statusForm.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update status</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -95,10 +82,7 @@ export default function StudioGroupProjectEditor() {
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(artworkAttach.data.artwork_id || '')} onChange={(val) => artworkAttach.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -106,10 +90,7 @@ export default function StudioGroupProjectEditor() {
|
||||
{props.attachAssetUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); assetAttach.post(props.attachAssetUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach asset</h2>
|
||||
<select value={assetAttach.data.asset_id} onChange={(event) => assetAttach.setData('asset_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose asset</option>
|
||||
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(assetAttach.data.asset_id || '')} onChange={(val) => assetAttach.setData('asset_id', val)} placeholder="Choose asset" options={(props.assetOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach asset</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -121,15 +102,10 @@ export default function StudioGroupProjectEditor() {
|
||||
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
|
||||
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No owner</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
|
||||
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function toggleItem(list, value) {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
|
||||
@@ -33,10 +35,9 @@ export default function StudioGroupRecruitment() {
|
||||
<h2 className="text-xl font-semibold text-white">Recruitment profile</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Describe what the group is looking for and how applicants should reach you.</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} />
|
||||
Recruiting now
|
||||
</label>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
|
||||
<Checkbox checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} label="Recruiting now" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
@@ -74,18 +75,14 @@ export default function StudioGroupRecruitment() {
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Application settings</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contact mode</span>
|
||||
<select value={form.data.contact_mode} onChange={(event) => form.setData('contact_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.contactModes) ? props.contactModes : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect value={form.data.contact_mode} onChange={(val) => form.setData('contact_mode', val)} options={Array.isArray(props.contactModes) ? props.contactModes : []} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</span>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []} searchable={false} />
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Public preview</p>
|
||||
<p className="mt-2">{form.data.headline || 'No headline yet.'}</p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import Checkbox from '../../components/ui/Checkbox'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function toDateTimeInput(value) {
|
||||
return value ? String(value).slice(0, 16) : ''
|
||||
@@ -50,35 +52,22 @@ export default function StudioGroupReleaseEditor() {
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Release overview" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.release_notes} onChange={(event) => form.setData('release_notes', event.target.value)} placeholder="Release notes" rows={7} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.current_stage} onChange={(event) => form.setData('current_stage', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={form.data.visibility} onChange={(val) => form.setData('visibility', val)} options={props.visibilityOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
|
||||
<NovaSelect value={form.data.current_stage} onChange={(val) => form.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
|
||||
</div>
|
||||
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No release lead</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No release lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
|
||||
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(form.data.linked_collection_id || '')} onChange={(val) => form.setData('linked_collection_id', val)} placeholder="No linked collection" options={(props.collectionOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<NovaSelect value={String(form.data.featured_artwork_id || '')} onChange={(val) => form.setData('featured_artwork_id', val)} placeholder="No featured artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature this release on the public group page" />
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
|
||||
Feature this release on the public group page
|
||||
</label>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
@@ -91,7 +80,7 @@ export default function StudioGroupReleaseEditor() {
|
||||
{props.stageUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); stageForm.post(props.stageUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Stage</h2>
|
||||
<select value={stageForm.data.current_stage} onChange={(event) => stageForm.setData('current_stage', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<NovaSelect value={stageForm.data.current_stage} onChange={(val) => stageForm.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update stage</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button> : null}
|
||||
@@ -102,10 +91,7 @@ export default function StudioGroupReleaseEditor() {
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(artworkAttach.data.artwork_id || '')} onChange={(val) => artworkAttach.setData('artwork_id', val)} placeholder="Choose artwork" options={(props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -114,10 +100,7 @@ export default function StudioGroupReleaseEditor() {
|
||||
<form onSubmit={(event) => { event.preventDefault(); contributorForm.post(props.attachContributorUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Contributor credit</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<select value={contributorForm.data.user_id} onChange={(event) => contributorForm.setData('user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose contributor</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(contributorForm.data.user_id || '')} onChange={(val) => contributorForm.setData('user_id', val)} placeholder="Choose contributor" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
|
||||
<input value={contributorForm.data.role_label} onChange={(event) => contributorForm.setData('role_label', event.target.value)} placeholder="Role label" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach contributor</button>
|
||||
</div>
|
||||
@@ -132,15 +115,10 @@ export default function StudioGroupReleaseEditor() {
|
||||
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
|
||||
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No owner</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
|
||||
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioGroupReleases() {
|
||||
const { props } = usePage()
|
||||
@@ -14,9 +15,7 @@ export default function StudioGroupReleases() {
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="text-sm text-slate-400">Track the release pipeline from draft through public launch, with milestones and contributor credits.</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={currentBucket} onChange={(event) => router.get(window.location.pathname, { bucket: event.target.value }, { preserveScroll: true, preserveState: true })} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white outline-none">
|
||||
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<NovaSelect value={currentBucket} onChange={(val) => router.get(window.location.pathname, { bucket: val }, { preserveScroll: true, preserveState: true })} options={bucketOptions} searchable={false} />
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create release</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function resolveMediaPreviewUrl(path, filesCdnUrl) {
|
||||
const trimmed = String(path || '').trim()
|
||||
@@ -145,13 +146,10 @@ export default function StudioGroupSettings() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<div className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Featured artwork</span>
|
||||
<select value={form.featured_artwork_id} onChange={(event) => setForm((current) => ({ ...current, featured_artwork_id: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Use latest published artwork</option>
|
||||
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={String(form.featured_artwork_id || '')} onChange={(val) => setForm((current) => ({ ...current, featured_artwork_id: val }))} placeholder="Use latest published artwork" options={featuredArtworkOptions.map((item) => ({ value: String(item.id), label: item.title }))} />
|
||||
</div>
|
||||
{selectedFeaturedArtwork ? (
|
||||
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
|
||||
{selectedFeaturedArtwork.thumb ? <img src={selectedFeaturedArtwork.thumb} alt={selectedFeaturedArtwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
|
||||
@@ -164,8 +162,8 @@ export default function StudioGroupSettings() {
|
||||
<p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||
<div className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} options={props.visibilityOptions || []} searchable={false} /></div>
|
||||
<div className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><NovaSelect value={form.membership_policy} onChange={(val) => setForm((current) => ({ ...current, membership_policy: val }))} options={props.membershipPolicyOptions || []} searchable={false} /></div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-200">Links</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
@@ -78,10 +79,10 @@ export default function StudioInbox() {
|
||||
<h2 className="text-lg font-semibold text-white">Filters</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Actor, title, or module" /></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.type_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read state</span><select value={filters.read_state || 'all'} onChange={(event) => updateFilters({ read_state: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.read_state_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Priority</span><select value={filters.priority || 'all'} onChange={(event) => updateFilters({ priority: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(inbox.priority_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</span><NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={inbox.type_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={inbox.module_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read state</span><NovaSelect value={filters.read_state || 'all'} onChange={(val) => updateFilters({ read_state: val })} options={inbox.read_state_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Priority</span><NovaSelect value={filters.priority || 'all'} onChange={(val) => updateFilters({ priority: val })} options={inbox.priority_options || []} searchable={false} /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { Checkbox } from '../../components/ui'
|
||||
|
||||
// ── Minimal toast system ────────────────────────────────────────────────────
|
||||
let _toastId = 0
|
||||
|
||||
function useToast() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const push = useCallback((message, type = 'info') => {
|
||||
const id = ++_toastId
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 5000)
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), [])
|
||||
|
||||
return { toasts, push, dismiss }
|
||||
}
|
||||
|
||||
function ToastStack({ toasts, onDismiss }) {
|
||||
if (!toasts.length) return null
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={[
|
||||
'flex items-start gap-3 rounded-2xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-sm',
|
||||
t.type === 'success'
|
||||
? 'border-emerald-400/30 bg-emerald-950/90 text-emerald-100'
|
||||
: t.type === 'error'
|
||||
? 'border-rose-400/30 bg-rose-950/90 text-rose-100'
|
||||
: 'border-white/15 bg-slate-900/90 text-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="mt-0.5 text-base leading-none">
|
||||
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : 'ℹ'}
|
||||
</span>
|
||||
<span className="flex-1 leading-5">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(t.id)}
|
||||
className="ml-2 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
@@ -20,7 +74,7 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-1 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
|
||||
{item.description ? <div className="mt-1 line-clamp-2 text-xs text-slate-400">{item.description}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -28,16 +82,178 @@ function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null
|
||||
return <p className="text-xs text-rose-300">{message}</p>
|
||||
}
|
||||
|
||||
function normalizeNewTagName(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80)
|
||||
}
|
||||
|
||||
function SectionCard({ eyebrow, title, description, actions, children, tone = 'default' }) {
|
||||
const toneClass = tone === 'feature'
|
||||
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
||||
: 'bg-white/[0.03]'
|
||||
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 p-5 ${toneClass}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TagPicker({ options, selectedIds, newTagNames, tagQuery, onTagQueryChange, onToggle, onCreateTag, onRemoveNewTag, manageUrl }) {
|
||||
const selectedTags = useMemo(() => options.filter((tag) => selectedIds.includes(tag.id)), [options, selectedIds])
|
||||
const normalizedQuery = useMemo(() => normalizeNewTagName(tagQuery), [tagQuery])
|
||||
const matchingExistingTag = useMemo(() => {
|
||||
if (!normalizedQuery) return null
|
||||
|
||||
const lowerQuery = normalizedQuery.toLowerCase()
|
||||
|
||||
return options.find((tag) => String(tag.name || '').toLowerCase() === lowerQuery) || null
|
||||
}, [options, normalizedQuery])
|
||||
const queryMatchesPending = useMemo(() => {
|
||||
if (!normalizedQuery) return false
|
||||
|
||||
const lowerQuery = normalizedQuery.toLowerCase()
|
||||
|
||||
return newTagNames.some((tagName) => tagName.toLowerCase() === lowerQuery)
|
||||
}, [newTagNames, normalizedQuery])
|
||||
|
||||
const availableTags = useMemo(() => {
|
||||
const query = String(tagQuery || '').trim().toLowerCase()
|
||||
|
||||
return options
|
||||
.filter((tag) => !selectedIds.includes(tag.id))
|
||||
.filter((tag) => (query === '' ? true : String(tag.name || '').toLowerCase().includes(query)))
|
||||
.slice(0, 12)
|
||||
}, [options, selectedIds, tagQuery])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected tags</div>
|
||||
<div className="mt-1 text-sm text-slate-400">Attach article topics without forcing the editor to scan a wall of checkboxes.</div>
|
||||
</div>
|
||||
{manageUrl ? <a href={manageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">Manage tags</a> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex min-h-[3.5rem] flex-wrap gap-2">
|
||||
{selectedTags.length > 0 ? selectedTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(tag.id)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-sm text-sky-50 transition hover:bg-sky-400/15"
|
||||
>
|
||||
<span>{tag.name}</span>
|
||||
<span className="text-xs text-sky-100/70">Remove</span>
|
||||
</button>
|
||||
)) : null}
|
||||
{newTagNames.map((tagName) => (
|
||||
<button
|
||||
key={tagName}
|
||||
type="button"
|
||||
onClick={() => onRemoveNewTag(tagName)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm text-emerald-50 transition hover:bg-emerald-400/15"
|
||||
>
|
||||
<span>{tagName}</span>
|
||||
<span className="rounded-full border border-emerald-200/30 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100/80">New</span>
|
||||
</button>
|
||||
))}
|
||||
{selectedTags.length === 0 && newTagNames.length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No tags selected yet.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Find tags</span>
|
||||
<input
|
||||
value={tagQuery}
|
||||
onChange={(event) => onTagQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!['Enter', ','].includes(event.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const nextQuery = normalizeNewTagName(event.currentTarget.value)
|
||||
if (!nextQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
if (matchingExistingTag && !selectedIds.includes(matchingExistingTag.id)) {
|
||||
onToggle(matchingExistingTag.id)
|
||||
onTagQueryChange('')
|
||||
return
|
||||
}
|
||||
|
||||
if (!queryMatchesPending) {
|
||||
onCreateTag(nextQuery)
|
||||
}
|
||||
|
||||
onTagQueryChange('')
|
||||
}}
|
||||
placeholder="Search existing tags or type a new one"
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{normalizedQuery && !matchingExistingTag && !queryMatchesPending ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateTag(normalizedQuery)
|
||||
onTagQueryChange('')
|
||||
}}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-50"
|
||||
>
|
||||
<span>Create tag</span>
|
||||
<span className="rounded-full border border-emerald-200/30 px-2 py-0.5 text-xs text-emerald-100">{normalizedQuery}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{availableTags.length > 0 ? availableTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(tag.id)}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
+ {tag.name}
|
||||
</button>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">No additional tags match the current search.</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">
|
||||
Press Enter or comma to queue a new tag. Pending tags are written into the news tag list when the article is saved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -67,28 +283,70 @@ function RelationCard({ relation, index, onChange, onRemove, onSearch, results,
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioNewsEditor() {
|
||||
const { props } = usePage()
|
||||
const article = props.article || {}
|
||||
const [authorResults, setAuthorResults] = useState([])
|
||||
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
||||
const [relationResults, setRelationResults] = useState({})
|
||||
function stripHtml(value) {
|
||||
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
function selectOptionsFromValues(options, emptyLabel = null) {
|
||||
const base = Array.isArray(options)
|
||||
? options.map((option) => ({
|
||||
value: option.value ?? option.id,
|
||||
label: option.label ?? option.name,
|
||||
}))
|
||||
: []
|
||||
|
||||
return emptyLabel ? [{ value: '', label: emptyLabel }, ...base] : base
|
||||
}
|
||||
|
||||
function buildSubmitPayload(data) {
|
||||
return {
|
||||
title: String(data.title || '').trim(),
|
||||
slug: String(data.slug || '').trim(),
|
||||
excerpt: String(data.excerpt || ''),
|
||||
content: String(data.content || ''),
|
||||
cover_image: String(data.cover_image || '').trim(),
|
||||
type: String(data.type || ''),
|
||||
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
|
||||
author_id: data.author_id === '' || data.author_id == null ? null : Number(data.author_id),
|
||||
editorial_status: String(data.editorial_status || ''),
|
||||
published_at: data.published_at ? String(data.published_at) : null,
|
||||
is_featured: Boolean(data.is_featured),
|
||||
is_pinned: Boolean(data.is_pinned),
|
||||
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
|
||||
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
|
||||
meta_title: String(data.meta_title || ''),
|
||||
meta_description: String(data.meta_description || ''),
|
||||
meta_keywords: String(data.meta_keywords || ''),
|
||||
canonical_url: String(data.canonical_url || '').trim(),
|
||||
og_title: String(data.og_title || ''),
|
||||
og_description: String(data.og_description || ''),
|
||||
og_image: String(data.og_image || '').trim(),
|
||||
relations: Array.isArray(data.relations)
|
||||
? data.relations.map((relation) => ({
|
||||
entity_type: String(relation.entity_type || '').trim(),
|
||||
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
|
||||
context_label: String(relation.context_label || '').trim(),
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialFormData(article, defaultAuthor, typeOptions) {
|
||||
return {
|
||||
title: article.title || '',
|
||||
slug: article.slug || '',
|
||||
excerpt: article.excerpt || '',
|
||||
content: article.content || '',
|
||||
cover_image: article.cover_image || '',
|
||||
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
|
||||
type: article.type || (typeOptions?.[0]?.value || 'announcement'),
|
||||
category_id: article.category_id || '',
|
||||
author_id: article.author_id || props.defaultAuthor?.id || '',
|
||||
author_id: article.author_id || defaultAuthor?.id || '',
|
||||
editorial_status: article.editorial_status || 'draft',
|
||||
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
|
||||
is_featured: Boolean(article.is_featured),
|
||||
is_pinned: Boolean(article.is_pinned),
|
||||
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
|
||||
new_tag_names: [],
|
||||
meta_title: article.meta_title || '',
|
||||
meta_description: article.meta_description || '',
|
||||
meta_keywords: article.meta_keywords || '',
|
||||
@@ -103,18 +361,50 @@ export default function StudioNewsEditor() {
|
||||
preview: relation.preview || null,
|
||||
query: relation.preview?.title || '',
|
||||
})) : [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
export default function StudioNewsEditor() {
|
||||
const { props } = usePage()
|
||||
const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
|
||||
const article = props.article || {}
|
||||
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions), [article, props.defaultAuthor, props.typeOptions])
|
||||
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
|
||||
const [authorResults, setAuthorResults] = useState([])
|
||||
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
||||
const [relationResults, setRelationResults] = useState({})
|
||||
const [tagQuery, setTagQuery] = useState('')
|
||||
const [coverPreviewUrl, setCoverPreviewUrl] = useState(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||||
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
||||
const lastSyncedArticleKeyRef = useRef(articleSyncKey)
|
||||
|
||||
if (props.updateUrl) {
|
||||
form.patch(props.updateUrl)
|
||||
const form = useForm(initialFormData)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
|
||||
return
|
||||
}
|
||||
|
||||
form.post(props.storeUrl)
|
||||
}
|
||||
lastSyncedArticleKeyRef.current = articleSyncKey
|
||||
form.setData(initialFormData)
|
||||
form.clearErrors()
|
||||
setSelectedAuthor(article.author || props.defaultAuthor || null)
|
||||
setAuthorQuery(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||
setRelationResults({})
|
||||
setTagQuery('')
|
||||
setCoverPreviewUrl(article.cover_url || (String(article.cover_image || '').startsWith('http') ? article.cover_image : ''))
|
||||
setStagedCoverPath('')
|
||||
}, [article, articleSyncKey, form, initialFormData, props.defaultAuthor])
|
||||
|
||||
const excerptLength = String(form.data.excerpt || '').trim().length
|
||||
const bodyWordCount = useMemo(() => {
|
||||
const plain = stripHtml(form.data.content)
|
||||
return plain === '' ? 0 : plain.split(/\s+/).length
|
||||
}, [form.data.content])
|
||||
const typeOptions = useMemo(() => selectOptionsFromValues(props.typeOptions || []), [props.typeOptions])
|
||||
const statusOptions = useMemo(() => selectOptionsFromValues(props.statusOptions || []), [props.statusOptions])
|
||||
const categoryOptions = useMemo(() => selectOptionsFromValues(props.categoryOptions || [], 'No category'), [props.categoryOptions])
|
||||
|
||||
const searchEntities = async (type, query) => {
|
||||
const url = new URL(props.entitySearchUrl, window.location.origin)
|
||||
@@ -174,47 +464,187 @@ export default function StudioNewsEditor() {
|
||||
setRelationResults((current) => ({ ...current, [index]: items }))
|
||||
}
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
const numericId = Number(tagId)
|
||||
const next = form.data.tag_ids.includes(numericId)
|
||||
? form.data.tag_ids.filter((currentId) => currentId !== numericId)
|
||||
: [...form.data.tag_ids, numericId]
|
||||
|
||||
form.setData('tag_ids', next)
|
||||
|
||||
if (!form.data.tag_ids.includes(numericId)) {
|
||||
const matchedTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => tag.id === numericId)
|
||||
if (matchedTag) {
|
||||
const lowerName = String(matchedTag.name || '').toLowerCase()
|
||||
form.setData('new_tag_names', form.data.new_tag_names.filter((tagName) => tagName.toLowerCase() !== lowerName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addNewTagName = (rawValue) => {
|
||||
const nextTagName = normalizeNewTagName(rawValue)
|
||||
if (!nextTagName) return
|
||||
|
||||
const lowerName = nextTagName.toLowerCase()
|
||||
const matchingExistingTag = (Array.isArray(props.tagOptions) ? props.tagOptions : []).find((tag) => String(tag.name || '').toLowerCase() === lowerName)
|
||||
|
||||
if (matchingExistingTag) {
|
||||
if (!form.data.tag_ids.includes(matchingExistingTag.id)) {
|
||||
form.setData('tag_ids', [...form.data.tag_ids, matchingExistingTag.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (form.data.new_tag_names.some((tagName) => tagName.toLowerCase() === lowerName)) {
|
||||
return
|
||||
}
|
||||
|
||||
form.setData('new_tag_names', [...form.data.new_tag_names, nextTagName])
|
||||
}
|
||||
|
||||
const removeNewTagName = (tagName) => {
|
||||
form.setData('new_tag_names', form.data.new_tag_names.filter((currentTagName) => currentTagName !== tagName))
|
||||
}
|
||||
|
||||
const handleManualCoverChange = (nextValue) => {
|
||||
form.setData('cover_image', nextValue)
|
||||
|
||||
if (stagedCoverPath && nextValue !== stagedCoverPath) {
|
||||
setStagedCoverPath('')
|
||||
}
|
||||
|
||||
if (!nextValue) {
|
||||
setCoverPreviewUrl('')
|
||||
return
|
||||
}
|
||||
|
||||
if (String(nextValue).startsWith('http://') || String(nextValue).startsWith('https://')) {
|
||||
setCoverPreviewUrl(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setCoverPreviewUrl(`${props.coverCdnBaseUrl}/${String(nextValue).replace(/^\/+/, '')}`)
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
preserveState: false,
|
||||
onSuccess: () => {
|
||||
setStagedCoverPath('')
|
||||
pushToast('Article saved successfully.', 'success')
|
||||
},
|
||||
onError: (errors) => {
|
||||
const errorMessages = Object.values(errors)
|
||||
const first = errorMessages[0] || 'The article could not be saved.'
|
||||
const extra = errorMessages.length > 1 ? ` (${errorMessages.length - 1} more field${errorMessages.length > 2 ? 's' : ''})` : ''
|
||||
pushToast(first + extra, 'error')
|
||||
},
|
||||
}
|
||||
|
||||
form.transform((data) => buildSubmitPayload(data))
|
||||
|
||||
if (props.updateUrl) {
|
||||
form.patch(props.updateUrl, options)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
const deleteArticle = () => {
|
||||
if (!props.destroyUrl) return
|
||||
if (!window.confirm('Move this article to trash? This uses soft delete so the record stays in the database.')) return
|
||||
|
||||
router.delete(props.destroyUrl, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4">
|
||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,0.85fr)]">
|
||||
<div className="space-y-6">
|
||||
<SectionCard
|
||||
eyebrow="Story workspace"
|
||||
title={article.id ? 'Shape the full newsroom story before it goes live.' : 'Create a newsroom story that reads like an editorial feature, not a raw database form.'}
|
||||
description="The cover, excerpt, body, tags, and related entities are all tuned for homepage spotlight, archive browsing, and article detail pages."
|
||||
tone="feature"
|
||||
>
|
||||
<div className="grid gap-5">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} 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)} placeholder="Headline that can carry the article alone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Aim for a clear editorial headline that still makes sense in cards, notifications, and social previews.</span>
|
||||
<FieldError message={form.errors.title} />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
|
||||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover image URL or path</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
|
||||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Leave blank to generate from the title, or set a durable URL manually when the story needs a stable public address.</span>
|
||||
<FieldError message={form.errors.slug} />
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</span>
|
||||
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
|
||||
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
<span>Excerpt</span>
|
||||
<span className="text-slate-500">{excerptLength}/800</span>
|
||||
</span>
|
||||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={6} placeholder="Write the concise summary used in listing cards, metadata, and archive previews." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Lead with the update, why it matters, and the audience hook. Two to four punchy sentences usually land better than one dense paragraph.</span>
|
||||
<FieldError message={form.errors.excerpt} />
|
||||
</label>
|
||||
|
||||
<div 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="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Cover image"
|
||||
slot="cover"
|
||||
value={form.data.cover_image}
|
||||
previewUrl={coverPreviewUrl}
|
||||
emptyLabel="Drop a cover image"
|
||||
helperText="Upload the hero image directly to object storage. A wide landscape image works best for cards, preview surfaces, and social sharing."
|
||||
uploadUrl={props.coverUploadUrl}
|
||||
deleteUrl={props.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedCoverPath(path || '')
|
||||
form.setData('cover_image', path || '')
|
||||
setCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.cover_image} />
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported legacy stories, or when you already have the exact asset URL you want to use.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard eyebrow="Full message" title="Body editor" description="Write the main article in a richer editing surface so the content reads like a polished story, not pasted plain text." actions={<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>}>
|
||||
<div className="grid gap-3 text-sm text-slate-300">
|
||||
<RichTextEditor
|
||||
content={form.data.content}
|
||||
onChange={(nextValue) => form.setData('content', nextValue)}
|
||||
placeholder="Open with the update, add context, use links, pull quotes, headings, and imagery where the story needs structure."
|
||||
error={form.errors.content}
|
||||
minHeight={24}
|
||||
autofocus={false}
|
||||
/>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
||||
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard eyebrow="Context links" title="Related entities" description="Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page." actions={<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}>
|
||||
<div className="grid gap-4">
|
||||
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
|
||||
<RelationCard
|
||||
key={`${relation.entity_type}-${index}`}
|
||||
@@ -226,43 +656,33 @@ export default function StudioNewsEditor() {
|
||||
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 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>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Publishing</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<div className="space-y-6">
|
||||
<SectionCard eyebrow="Editorial controls" title="Publishing" description="Set ownership, placement, timing, and surface behavior before the article leaves draft.">
|
||||
<div className="grid gap-4">
|
||||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
|
||||
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No category</option>
|
||||
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" error={form.errors.type} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => form.setData('category_id', String(nextValue || ''))} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Workflow status</span>
|
||||
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||||
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<FieldError message={form.errors.published_at} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -283,51 +703,39 @@ export default function StudioNewsEditor() {
|
||||
setAuthorQuery(item.title)
|
||||
form.setData('author_id', item.id)
|
||||
}} emptyLabel="Search to choose an author profile." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
|
||||
const checked = form.data.tag_ids.includes(tag.id)
|
||||
|
||||
return (
|
||||
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked) {
|
||||
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
|
||||
return
|
||||
}
|
||||
|
||||
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
|
||||
}}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FieldError message={form.errors.author_id} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
|
||||
Feature on newsroom surfaces
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
|
||||
Pin to the top of the newsroom
|
||||
</label>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on newsroom surfaces" size={20} variant="accent" />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">SEO & social</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<SectionCard eyebrow="Taxonomy" title="Tags" description="Search and apply tags quickly instead of scanning a wall of checkboxes.">
|
||||
<TagPicker
|
||||
options={Array.isArray(props.tagOptions) ? props.tagOptions : []}
|
||||
selectedIds={form.data.tag_ids}
|
||||
newTagNames={form.data.new_tag_names}
|
||||
tagQuery={tagQuery}
|
||||
onTagQueryChange={setTagQuery}
|
||||
onToggle={toggleTag}
|
||||
onCreateTag={addNewTagName}
|
||||
onRemoveNewTag={removeNewTagName}
|
||||
manageUrl={props.tagsUrl}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<FieldError message={form.errors.tag_ids || form.errors.new_tag_names} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard eyebrow="Metadata" title="SEO and social" description="Keep search and sharing fields aligned with the main editorial package.">
|
||||
<div className="grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
|
||||
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
@@ -359,18 +767,20 @@ export default function StudioNewsEditor() {
|
||||
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<SectionCard eyebrow="Actions" title="Save and publish" description="Use the primary action for create or update, then promote, archive, or trash the article from the same control rail.">
|
||||
<div className="grid gap-3">
|
||||
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save article</button>
|
||||
{Object.keys(form.errors || {}).length > 0 ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">The article was not saved. Fix the highlighted fields and try again.</div> : null}
|
||||
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">{form.processing ? 'Saving article…' : 'Save article'}</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
|
||||
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Archive article</button> : null}
|
||||
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
|
||||
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
|
||||
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive article</button> : null}
|
||||
{props.destroyUrl ? <button type="button" onClick={deleteArticle} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Move to trash</button> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Draft'
|
||||
@@ -36,6 +37,15 @@ export default function StudioNewsIndex() {
|
||||
const filters = props.listing?.filters || {}
|
||||
const meta = props.listing?.meta || {}
|
||||
|
||||
const deleteItem = (item) => {
|
||||
if (!item?.delete_url) return
|
||||
if (!window.confirm(`Move "${item.title}" to trash?`)) return
|
||||
|
||||
router.delete(item.delete_url, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilter = (next) => {
|
||||
router.get('/studio/news', {
|
||||
...filters,
|
||||
@@ -89,45 +99,36 @@ export default function StudioNewsIndex() {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.status || ''}
|
||||
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
onChange={(value) => updateFilter({ status: value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
|
||||
placeholder="All statuses"
|
||||
options={(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.type || ''}
|
||||
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
onChange={(value) => updateFilter({ type: value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
|
||||
placeholder="All types"
|
||||
options={(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={filters.category_id || ''}
|
||||
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(value) => updateFilter({ category_id: value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
|
||||
placeholder="All categories"
|
||||
options={(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ({ value: String(option.id), label: option.name }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -154,6 +155,7 @@ export default function StudioNewsIndex() {
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<a href={item.edit_url} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Edit</a>
|
||||
<a href={item.editorial_status === 'published' ? item.public_url : item.preview_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{item.editorial_status === 'published' ? 'View' : 'Preview'}</a>
|
||||
<button type="button" onClick={() => deleteItem(item)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Trash</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
const shortcutOptions = [
|
||||
{ value: '/dashboard/profile', label: 'Dashboard profile' },
|
||||
@@ -172,48 +173,30 @@ export default function StudioPreferences() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default content view</span>
|
||||
<select value={form.default_content_view} onChange={(event) => setForm((current) => ({ ...current, default_content_view: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="grid" className="bg-slate-900">Grid</option>
|
||||
<option value="list" className="bg-slate-900">List</option>
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.default_content_view} onChange={(val) => setForm((current) => ({ ...current, default_content_view: val }))} searchable={false} options={[{ value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} />
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Analytics date range</span>
|
||||
<select value={form.analytics_range_days} onChange={(event) => setForm((current) => ({ ...current, analytics_range_days: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{[7, 14, 30, 60, 90].map((days) => (
|
||||
<option key={days} value={days} className="bg-slate-900">Last {days} days</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.analytics_range_days} onChange={(val) => setForm((current) => ({ ...current, analytics_range_days: Number(val) }))} searchable={false} options={[7, 14, 30, 60, 90].map((days) => ({ value: days, label: `Last ${days} days` }))} />
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
|
||||
<div className="space-y-2 text-sm text-slate-300 md:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Draft behavior</span>
|
||||
<select value={form.draft_behavior} onChange={(event) => setForm((current) => ({ ...current, draft_behavior: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="resume-last" className="bg-slate-900">Resume the last draft I edited</option>
|
||||
<option value="open-drafts" className="bg-slate-900">Open the drafts library first</option>
|
||||
<option value="focus-published" className="bg-slate-900">Open published content first</option>
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.draft_behavior} onChange={(val) => setForm((current) => ({ ...current, draft_behavior: val }))} searchable={false} options={[{ value: 'resume-last', label: 'Resume the last draft I edited' }, { value: 'open-drafts', label: 'Open the drafts library first' }, { value: 'focus-published', label: 'Open published content first' }]} />
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Default landing page</span>
|
||||
<select value={form.default_landing_page} onChange={(event) => setForm((current) => ({ ...current, default_landing_page: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
{landingOptions.map(([value, label]) => (
|
||||
<option key={value} value={value} className="bg-slate-900">{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.default_landing_page} onChange={(val) => setForm((current) => ({ ...current, default_landing_page: val }))} searchable={false} options={landingOptions.map(([value, label]) => ({ value, label }))} />
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Card density</span>
|
||||
<select value={form.card_density} onChange={(event) => setForm((current) => ({ ...current, card_density: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<option value="comfortable" className="bg-slate-900">Comfortable</option>
|
||||
<option value="compact" className="bg-slate-900">Compact</option>
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={form.card_density} onChange={(val) => setForm((current) => ({ ...current, card_density: val }))} searchable={false} options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]} />
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300 md:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Scheduling timezone</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
async function requestJson(url, method = 'POST') {
|
||||
const response = await fetch(url, {
|
||||
@@ -142,18 +143,14 @@ export default function StudioScheduled() {
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search scheduled work</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Title or module" />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span>
|
||||
<select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{(listing.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={listing.module_options || []} searchable={false} />
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Date range</span>
|
||||
<select value={filters.range || 'upcoming'} onChange={(event) => updateFilters({ range: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
|
||||
{rangeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={filters.range || 'upcoming'} onChange={(val) => updateFilters({ range: val })} options={rangeOptions} searchable={false} />
|
||||
</div>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Start date</span>
|
||||
<input type="date" value={filters.start_date || ''} onChange={(event) => updateFilters({ range: 'custom', start_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioSearch() {
|
||||
const { props } = usePage()
|
||||
@@ -22,8 +23,8 @@ export default function StudioSearch() {
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<label className="space-y-2 text-sm text-slate-300 xl:col-span-3"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search Studio</span><input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Search content, comments, inbox, or assets" /></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Surface</span><select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.type_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<label className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><select value={filters.module || 'all'} onChange={(event) => updateFilters({ module: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">{(search.module_options || []).map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}</select></label>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Surface</span><NovaSelect value={filters.type || 'all'} onChange={(val) => updateFilters({ type: val })} options={search.type_options || []} searchable={false} /></div>
|
||||
<div className="space-y-2 text-sm text-slate-300"><span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Module</span><NovaSelect value={filters.module || 'all'} onChange={(val) => updateFilters({ module: val })} options={search.module_options || []} searchable={false} /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
921
resources/js/Pages/Studio/StudioUploadQueue.jsx
Normal file
921
resources/js/Pages/Studio/StudioUploadQueue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import WorldStatusBadge from '../../components/worlds/WorldStatusBadge'
|
||||
import WorldAnalyticsPortfolioPanel from '../../components/worlds/editor/analytics/WorldAnalyticsPortfolioPanel'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function StudioWorldsIndex() {
|
||||
const { props } = usePage()
|
||||
@@ -15,26 +18,22 @@ export default function StudioWorldsIndex() {
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6">
|
||||
<WorldAnalyticsPortfolioPanel analytics={props.analytics} />
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_auto] lg:items-end">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={filters.q || ''} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Search title, slug, or summary" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
|
||||
<select value={filters.status || ''} onChange={(event) => updateFilter('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">All statuses</option>
|
||||
{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect value={filters.status || ''} onChange={(val) => updateFilter('status', val)} options={[{ value: '', label: 'All statuses' }, ...(props.statusOptions || [])]} searchable={false} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={filters.type || ''} onChange={(event) => updateFilter('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">All types</option>
|
||||
{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<NovaSelect value={filters.type || ''} onChange={(val) => updateFilter('type', val)} options={[{ value: '', label: 'All types' }, ...(props.typeOptions || [])]} searchable={false} />
|
||||
</div>
|
||||
<a href={props.createUrl} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus" />New world</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -43,16 +42,18 @@ export default function StudioWorldsIndex() {
|
||||
{items.length > 0 ? items.map((world) => (
|
||||
<a key={world.id} href={world.edit_url} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 transition hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.status}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1">{world.type}</span>
|
||||
{world.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-amber-100">Featured</span> : null}
|
||||
<WorldStatusBadge badge={{ label: world.status, tone: 'slate' }} />
|
||||
<WorldStatusBadge badge={{ label: world.type, tone: 'slate' }} />
|
||||
{(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => <WorldStatusBadge key={`${world.id}-${badge.label}`} badge={badge} />)}
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">{world.title}</h2>
|
||||
<div className="mt-2 text-sm text-slate-500">/{world.slug}</div>
|
||||
{world.summary ? <p className="mt-4 text-sm leading-6 text-slate-300">{world.summary}</p> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-4 text-sm text-slate-400">
|
||||
{world.timeframe_label ? <span>{world.timeframe_label}</span> : null}
|
||||
{world.promotion_window_label ? <span>{world.promotion_window_label}</span> : null}
|
||||
<span>{world.relation_count} relations</span>
|
||||
{world.live_submission_count > 0 ? <span>{world.live_submission_count} live submissions</span> : null}
|
||||
{world.theme_key ? <span>{world.theme_key}</span> : null}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-sm font-semibold">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
|
||||
/**
|
||||
* Modal for choosing a category in bulk.
|
||||
@@ -49,25 +50,17 @@ export default function BulkCategoryModal({ open, categories = [], onClose, onCo
|
||||
{/* Category select */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.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"
|
||||
>
|
||||
<option value="" className="bg-nova-900">Select a category…</option>
|
||||
{categories.map((ct) => (
|
||||
<optgroup key={ct.id} label={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"> {ch.name}</option>
|
||||
onChange={(value) => setSelectedId(value)}
|
||||
placeholder="Select a category…"
|
||||
options={categories.flatMap((ct) => (
|
||||
(ct.categories || []).flatMap((cat) => ([
|
||||
{ value: String(cat.id), label: cat.name, group: ct.name },
|
||||
...((cat.children || []).map((child) => ({ value: String(child.id), label: child.name, group: ct.name }))),
|
||||
]))
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
|
||||
function jumpToSection(targetId) {
|
||||
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' }) {
|
||||
const [selectedSection, setSelectedSection] = useState(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">
|
||||
<label htmlFor="groups-help-nav" className="sr-only">{selectLabel}</label>
|
||||
<select
|
||||
<NovaSelect
|
||||
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"
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
jumpToSection(event.target.value)
|
||||
event.target.value = ''
|
||||
className="w-full"
|
||||
value={selectedSection}
|
||||
placeholder="Jump to a section"
|
||||
options={sections.map((section) => ({ value: section.id, label: section.label }))}
|
||||
onChange={(value) => {
|
||||
jumpToSection(value)
|
||||
setSelectedSection(null)
|
||||
}}
|
||||
>
|
||||
<option value="">Jump to a section</option>
|
||||
{sections.map((section) => (
|
||||
<option key={section.id} value={section.id}>{section.label}</option>
|
||||
))}
|
||||
</select>
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav aria-label={ariaLabel} className="hidden lg:block lg:sticky lg:top-24">
|
||||
|
||||
@@ -13,7 +13,7 @@ import { common, createLowlight } from 'lowlight';
|
||||
import tippy from 'tippy.js';
|
||||
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
|
||||
import TurnstileField from '../security/TurnstileField';
|
||||
import Select from '../ui/Select';
|
||||
import NovaSelect from '../ui/NovaSelect';
|
||||
|
||||
type StoryType = {
|
||||
slug: string;
|
||||
@@ -446,9 +446,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const [artworkModalOpen, setArtworkModalOpen] = useState(false);
|
||||
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
|
||||
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 [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [generalError, setGeneralError] = useState('');
|
||||
@@ -456,6 +453,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const [readMinutes, setReadMinutes] = useState(1);
|
||||
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
|
||||
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({
|
||||
required: false,
|
||||
token: '',
|
||||
@@ -661,6 +662,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
@@ -685,10 +688,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
DownloadAssetBlock,
|
||||
createSlashCommandExtension(insertActions),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
content: initialStory.content || EMPTY_DOC,
|
||||
editorProps: {
|
||||
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) => {
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
@@ -733,7 +737,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
if (!editor) return;
|
||||
|
||||
const updatePreview = () => {
|
||||
setLivePreviewHtml(editor.getHTML());
|
||||
const text = editor.getText().replace(/\s+/g, ' ').trim();
|
||||
const words = text === '' ? 0 : text.split(' ').length;
|
||||
setWordCount(words);
|
||||
@@ -804,6 +807,45 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
};
|
||||
}, [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(() => ({
|
||||
story_id: storyId,
|
||||
title,
|
||||
@@ -967,176 +1009,79 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.24em] text-white/45">
|
||||
<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>
|
||||
<span>{wordCount.toLocaleString()} words</span>
|
||||
<span>{readMinutes} min read</span>
|
||||
<span>{saveStatus}</span>
|
||||
<div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
|
||||
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
|
||||
<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="flex items-center gap-4">
|
||||
<a href="/studio/stories" className="flex items-center gap-1.5 text-sm text-white/50 transition-colors hover:text-white/90">
|
||||
<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>
|
||||
Stories
|
||||
</a>
|
||||
<span className="h-4 w-px bg-white/10" />
|
||||
<span className="hidden text-sm text-white/65 sm:inline">{saveStatus}</span>
|
||||
</div>
|
||||
<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 className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="space-y-6">
|
||||
<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 ? (
|
||||
<div className="relative h-56 overflow-hidden border-b border-white/10">
|
||||
<img src={coverImage} alt="Story cover" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent" />
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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` }}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="Story settings"
|
||||
className="rounded-full p-2 text-white/50 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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 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 type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
|
||||
<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 className="px-6 py-8 md:px-10 md:py-10">
|
||||
<EditorContent editor={editor} />
|
||||
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
|
||||
</div>
|
||||
|
||||
{showLivePreview && (
|
||||
<div className="border-t border-white/10 bg-white/[0.02] px-6 py-6 md:px-10">
|
||||
<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 }} />
|
||||
{/* ── Writing canvas ───────────────────────────────────────────────── */}
|
||||
<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)]">
|
||||
{coverImage ? (
|
||||
<div className="group relative overflow-hidden rounded-t-2xl">
|
||||
<img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-3 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverImageInputRef.current?.click()}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-24 self-start">
|
||||
<div className="px-6 pb-24 pt-10 md:px-14 md:pt-14">
|
||||
{/* Error / captcha banner */}
|
||||
{(generalError || captchaState.required) && (
|
||||
<section className="rounded-[1.5rem] border border-amber-400/20 bg-amber-500/10 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-amber-100/70">Action needed</p>
|
||||
<p className="mt-3 text-sm text-amber-50">{generalError || captchaState.message || 'Complete the captcha challenge to continue.'}</p>
|
||||
<div className="mb-8 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<p className="text-sm text-amber-200">{generalError || captchaState.message || 'Complete the captcha to continue.'}</p>
|
||||
{captchaState.required && captchaState.siteKey ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="mt-3">
|
||||
<TurnstileField
|
||||
key={`story-editor-captcha-${captchaState.nonce}`}
|
||||
provider={captchaState.provider}
|
||||
@@ -1147,126 +1092,328 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Publish checklist</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{readinessChecks.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-white/48">{item.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Cover image upload shortcut */}
|
||||
{!coverImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverImageInputRef.current?.click()}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
Add a cover image
|
||||
</button>
|
||||
)}
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Story settings</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Story type</label>
|
||||
<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">
|
||||
{storyTypes.map((type) => (
|
||||
<option key={type.slug} value={type.slug}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Title */}
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
setTitle(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="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>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Tags</label>
|
||||
<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" />
|
||||
{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>}
|
||||
{/* Excerpt / subtitle */}
|
||||
<div className="mb-10 border-b border-white/[0.07] pb-8">
|
||||
<textarea
|
||||
value={excerpt}
|
||||
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>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Workflow status</label>
|
||||
<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">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_review">Pending Review</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{/* Body editor — the ref is on the wrapper so we can measure its left edge */}
|
||||
<div className="relative" ref={editorContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Schedule publish</label>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Cover</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<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>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">SEO & social</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<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" />
|
||||
<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 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" />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Footer actions */}
|
||||
<div className="mt-16 flex flex-wrap items-center gap-3 border-t border-white/[0.07] pt-8 text-sm">
|
||||
{storyId && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{storyId && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistStory('submit_review')}
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
Submit for review
|
||||
</button>
|
||||
{mode === 'edit' && storyId && (
|
||||
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => {
|
||||
if (!window.confirm('Delete this story?')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}>
|
||||
<form
|
||||
method="POST"
|
||||
action={`/creator/stories/${storyId}`}
|
||||
onSubmit={(e) => { if (!window.confirm('Delete this story permanently?')) e.preventDefault(); }}
|
||||
className="ml-auto"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
|
||||
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
|
||||
|
||||
{artworkModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">Embed Artwork</h3>
|
||||
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200">Close</button>
|
||||
</div>
|
||||
<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" />
|
||||
<div className="grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2">
|
||||
{artworkResults.map((item) => (
|
||||
<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">
|
||||
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-28 w-full rounded-lg object-cover" />}
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="text-xs text-gray-400">#{item.id}</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
|
||||
{plusButtonState.visible && (
|
||||
<div className="fixed z-40" style={{ top: `${plusButtonState.top}px`, left: `${plusButtonState.left}px` }}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPlusMenuOpen((v) => !v); }}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full border transition ${
|
||||
plusMenuOpen
|
||||
? 'border-sky-400/60 bg-sky-500/20 text-sky-300 shadow-[0_0_12px_rgba(14,165,233,0.35)]'
|
||||
: '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'
|
||||
}`}
|
||||
title="Add a block (or type / for commands)"
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform duration-200 ${plusMenuOpen ? 'rotate-45' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Insert block dropdown */}
|
||||
{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">
|
||||
{([
|
||||
{ label: 'Upload photo', icon: '🖼', key: 'uploadImage' },
|
||||
{ label: 'Image URL', icon: '🔗', key: 'image' },
|
||||
{ label: 'Artwork embed', icon: '🎨', key: 'artwork' },
|
||||
{ label: 'Video (YouTube…)', icon: '▶', key: 'video' },
|
||||
{ label: 'Gallery', icon: '⊞', key: 'gallery' },
|
||||
{ label: 'Blockquote', icon: '❝', key: 'quote' },
|
||||
{ label: 'Code block', icon: '⌨', key: 'code' },
|
||||
{ label: 'Download link', icon: '↓', key: 'download' },
|
||||
{ label: 'Divider', icon: '—', key: 'divider' },
|
||||
] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
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>
|
||||
))}
|
||||
</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 “{artworkQuery}”</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
|
||||
const TYPE_LABELS = {
|
||||
style: 'Style',
|
||||
@@ -208,15 +209,12 @@ export default function NovaCardPresetPicker({
|
||||
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"
|
||||
/>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={captureType}
|
||||
onChange={(e) => setCaptureType(e.target.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"
|
||||
>
|
||||
{typeKeys.map((type) => (
|
||||
<option key={type} value={type}>{TYPE_LABELS[type]}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => setCaptureType(value)}
|
||||
options={typeKeys.map((type) => ({ value: type, label: TYPE_LABELS[type] }))}
|
||||
searchable={false}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -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 `options` array of { value, label } and optional `placeholder`
|
||||
*
|
||||
@@ -13,82 +14,46 @@ import React, { forwardRef } from 'react'
|
||||
* @prop {string} error - validation error
|
||||
* @prop {string} hint - helper text
|
||||
* @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(
|
||||
{ label, error, hint, required, options, placeholder, size = 'md', id, className = '', children, style, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
function Select({ label, error, hint, required, options, placeholder, size = 'md', id, className = '', children, ...rest }) {
|
||||
void size
|
||||
|
||||
const sizeClass = {
|
||||
sm: 'py-1.5 text-xs',
|
||||
md: 'py-2.5 text-sm',
|
||||
lg: 'py-3 text-base',
|
||||
}[size] ?? 'py-2.5 text-sm'
|
||||
const normalizedOptions = options || Children.toArray(children).flatMap((child) => {
|
||||
if (!React.isValidElement(child)) return []
|
||||
|
||||
const inputClass = [
|
||||
'block w-full rounded-xl border bg-white/[0.06] text-white',
|
||||
'pl-3.5 pr-9',
|
||||
'appearance-none cursor-pointer',
|
||||
'bg-no-repeat bg-right',
|
||||
'transition-all duration-150',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||
error
|
||||
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40'
|
||||
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
sizeClass,
|
||||
className,
|
||||
].join(' ')
|
||||
if (child.type === 'optgroup') {
|
||||
const groupLabel = child.props.label
|
||||
return Children.toArray(child.props.children)
|
||||
.filter((optionChild) => React.isValidElement(optionChild))
|
||||
.map((optionChild) => ({
|
||||
value: optionChild.props.value,
|
||||
label: optionChild.props.children,
|
||||
group: groupLabel,
|
||||
disabled: optionChild.props.disabled,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-white/85">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
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>
|
||||
)
|
||||
return [{
|
||||
value: child.props.value,
|
||||
label: child.props.children,
|
||||
disabled: child.props.disabled,
|
||||
}]
|
||||
})
|
||||
|
||||
return (
|
||||
<NovaSelect
|
||||
id={id}
|
||||
label={label}
|
||||
error={error}
|
||||
hint={hint}
|
||||
required={required}
|
||||
options={normalizedOptions}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Select
|
||||
|
||||
@@ -15,7 +15,7 @@ import React from 'react'
|
||||
* @param {string} [props.error] Validation error message
|
||||
* @param {function} props.onRootChange Called with (rootId: 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
|
||||
*/
|
||||
export default function CategorySelector({
|
||||
@@ -99,45 +99,6 @@ export default function CategorySelector({
|
||||
</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 && (
|
||||
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import TagInput from '../tags/TagInput'
|
||||
import ScreenshotUploader from './ScreenshotUploader'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
|
||||
const STEP_PRELOAD = 1
|
||||
const STEP_DETAILS = 2
|
||||
@@ -515,18 +516,12 @@ export default function UploadWizard({
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-white/80">Category</label>
|
||||
<select
|
||||
<NovaSelect
|
||||
value={details.category_id}
|
||||
onChange={(event) => setDetails((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => setDetails((prev) => ({ ...prev, category_id: value }))}
|
||||
options={categoryOptions.map((option) => ({ value: String(option.id), label: option.label }))}
|
||||
placeholder="Select category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user