Files
SkinbaseNova/resources/js/components/worlds/editor/WorldRelationPickerModal.jsx
2026-04-18 17:02:56 +02:00

184 lines
10 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react'
import Modal from '../../ui/Modal'
import { Checkbox, NovaSelect } from '../../ui'
function relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions) {
const section = sectionOptions.find((option) => option.value === sectionKey)
return section?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork'
}
function emptyRelation(sectionOptions, relationTypeOptions) {
const sectionKey = sectionOptions?.[0]?.value || 'featured_artworks'
const relationType = relationTypeForSection(sectionKey, sectionOptions, relationTypeOptions)
return {
section_key: sectionKey,
related_type: relationType,
related_id: '',
context_label: '',
sort_order: 0,
is_featured: false,
preview: null,
query: '',
}
}
function SearchResultList({ items, loading, selectedId, onSelect }) {
if (loading) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching campaign entities</div>
}
if (!Array.isArray(items) || items.length === 0) {
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by title, slug, creator, or project name to attach curated content.</div>
}
return (
<div className="grid gap-3">
{items.map((item) => (
<button key={`${item.entity_type}-${item.id}`} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-shapes" /></div> : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
</div>
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
</div>
</button>
))}
</div>
)
}
export default function WorldRelationPickerModal({ open, onClose, onSave, initialRelation, sectionOptions, relationTypeOptions, searchEntities }) {
const [draft, setDraft] = useState(() => emptyRelation(sectionOptions, relationTypeOptions))
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
const nextDraft = initialRelation || emptyRelation(sectionOptions, relationTypeOptions)
setDraft({
...nextDraft,
query: nextDraft.query || nextDraft.preview?.title || '',
})
setResults([])
setLoading(false)
}, [open, initialRelation, sectionOptions, relationTypeOptions])
const selectedSection = useMemo(() => sectionOptions.find((option) => option.value === draft.section_key), [sectionOptions, draft.section_key])
const availableRelationTypes = useMemo(() => relationTypeOptions.filter((option) => !selectedSection?.relation_types?.length || selectedSection.relation_types.includes(option.value)), [relationTypeOptions, selectedSection])
const selectedPreview = useMemo(() => {
if (draft.preview) return draft.preview
return results.find((item) => String(item.id) === String(draft.related_id)) || null
}, [draft.preview, draft.related_id, results])
useEffect(() => {
if (!open || availableRelationTypes.length === 0) return
if (availableRelationTypes.some((option) => option.value === draft.related_type)) return
setDraft((current) => ({
...current,
related_type: availableRelationTypes[0].value,
related_id: '',
preview: null,
}))
}, [open, availableRelationTypes, draft.related_type])
useEffect(() => {
if (!open || !draft.related_type) {
setResults([])
setLoading(false)
return undefined
}
let cancelled = false
const timeoutId = window.setTimeout(async () => {
setLoading(true)
try {
const items = await searchEntities(draft.related_type, draft.query || '')
if (!cancelled) {
setResults(Array.isArray(items) ? items : [])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}, draft.query ? 220 : 0)
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [open, draft.related_type, draft.query, searchEntities])
const actionLabel = initialRelation?.related_id ? 'Save relation' : 'Attach relation'
const canSubmit = Boolean(draft.related_id)
const nextRelation = selectedPreview ? { ...draft, preview: selectedPreview } : draft
const footer = (
<>
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
<button type="button" onClick={() => onSave(nextRelation)} disabled={!canSubmit} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{actionLabel}</button>
</>
)
return (
<Modal open={open} onClose={onClose} title="Attach curated relation" size="2xl" footer={footer}>
<div className="grid gap-5 overflow-x-hidden">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.35fr)] lg:items-end">
<NovaSelect label="Section" value={draft.section_key || null} onChange={(nextValue) => setDraft((current) => {
const nextSectionKey = String(nextValue || '')
return {
...current,
section_key: nextSectionKey,
related_type: relationTypeForSection(nextSectionKey, sectionOptions, relationTypeOptions),
related_id: '',
preview: null,
}
})} options={sectionOptions} searchable={false} className="bg-black/20" />
<NovaSelect label="Entity type" value={draft.related_type || null} onChange={(nextValue) => setDraft((current) => ({ ...current, related_type: String(nextValue || ''), related_id: '', preview: null }))} options={availableRelationTypes} searchable={false} className="bg-black/20" />
<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>
<div className="flex min-w-0 flex-wrap gap-2 sm:flex-nowrap">
<input value={draft.query || ''} onChange={(event) => setDraft((current) => ({ ...current, query: event.target.value }))} placeholder="Search title, slug, group, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="shrink-0 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-slate-300">Auto</div>
</div>
</label>
</div>
<SearchResultList items={results} loading={loading} selectedId={draft.related_id} onSelect={(item) => setDraft((current) => ({ ...current, related_id: item.id, preview: item, query: item.title }))} />
{selectedPreview ? (
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
<div className="mt-2 break-words text-xs text-emerald-100/80">Section: {selectedSection?.label || draft.section_key} · {draft.is_featured ? 'Featured relation' : 'Standard relation'}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem] lg:grid-cols-[minmax(0,1fr)_10rem_minmax(0,15rem)] md: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">Context label</span>
<input value={draft.context_label || ''} onChange={(event) => setDraft((current) => ({ ...current, context_label: event.target.value }))} placeholder="Featured release, Join this challenge, Meet the creator" 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">Sort order</span>
<input type="number" min="0" value={draft.sort_order} onChange={(event) => setDraft((current) => ({ ...current, sort_order: Number(event.target.value) || 0 }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:col-span-2 lg:col-span-1">
<Checkbox checked={Boolean(draft.is_featured)} onChange={(event) => setDraft((current) => ({ ...current, is_featured: event.target.checked }))} label="Featured relation" size={20} variant="accent" />
</div>
</div>
</div>
</Modal>
)
}