Replace native selects with NovaSelect

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

View File

@@ -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">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
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 }))),
]))
))}
</select>
/>
</div>
{/* Actions */}

View File

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

View File

@@ -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>
<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 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>
<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"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</button>
<button
type="button"
onClick={() => persistStory('save_draft')}
disabled={isSubmitting}
className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-1.5 text-sm text-white/80 transition hover:bg-white/[0.10] disabled:opacity-50"
>
Save
</button>
<button
type="button"
onClick={() => persistStory('publish_now')}
disabled={isSubmitting}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_2px_12px_rgba(14,165,233,0.45)] transition hover:bg-sky-400 disabled:opacity-50"
>
Publish
</button>
</div>
</div>
<div 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` }}
{/* ── 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"
>
<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>
</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}
<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>
</div>
) : null}
{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 }} />
</div>
)}
</section>
</div>
<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>
</div>
{/* 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>}
</div>
{/* 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>
</div>
{/* 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>
{/* 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-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-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={(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-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>
</div>
</div>
<div className="flex flex-wrap gap-3">
{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>
)}
{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>
)}
{mode === 'edit' && storyId && (
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => {
if (!window.confirm('Delete this story?')) {
event.preventDefault();
}
}}>
<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>
</form>
)}
</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>
<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>
{/* 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 &ldquo;{artworkQuery}&rdquo;</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>
);
}

View File

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

View File

@@ -1,9 +1,10 @@
import React, { forwardRef } from 'react'
import React, { Children } from 'react'
import NovaSelect from './NovaSelect'
/**
* Nova Select styled native <select>
* Legacy Select wrapper.
*
* Accepts the same options API as a plain <select>:
* Accepts the same options API as a plain select:
* - Pass children (<option>, <optgroup>) directly, OR
* - Pass `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 [{
value: child.props.value,
label: child.props.children,
disabled: child.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>
<NovaSelect
id={id}
label={label}
error={error}
hint={hint}
required={required}
options={normalizedOptions}
placeholder={placeholder}
className={className}
{...rest}
/>
)
})
}
export default Select

View File

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

View File

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

View File

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

View File

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