import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' 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 DateTimePicker from '../../components/ui/DateTimePicker' 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 (
{message}
} function normalizeNewTagName(value) { return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 80) } function normalizeNewsTagKey(value) { return normalizeNewTagName(value).toLowerCase() } function parseNewsTagList(input) { return String(input || '') .split(/[\n,]+/) .map((item) => normalizeNewTagName(item)) .filter(Boolean) } function analyzePastedNewsTags(rawText, selectedKeys) { const parts = parseNewsTagList(rawText) const tagsToAdd = [] const skippedDuplicates = [] const skippedInvalid = [] for (const part of parts) { const normalized = normalizeNewTagName(part) const key = normalizeNewsTagKey(normalized) if (!normalized || !key) { skippedInvalid.push(String(part || '').trim()) continue } if (selectedKeys.includes(key) || tagsToAdd.some((item) => item.key === key)) { skippedDuplicates.push(normalized) continue } tagsToAdd.push({ key, name: normalized }) } return { parsedCount: parts.length, tagsToAdd, skippedDuplicates, skippedInvalid, } } 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 ({eyebrow}
: null}{description}
: null}Tag Import
Parsed {preview.parsedCount} item{preview.parsedCount === 1 ? '' : 's'} from your paste. Confirm before adding them to the article.
Tags to add
This is sample news content written in HTML.
You can replace it with your own editorial copy.
', cover_image: 'sample-news-cover.webp', type: 'Announcement', category_id: 1, category: 'General', category_slug: 'general', editorial_status: 'draft', published_at: '2026-05-03 09:00:00', author_id: 1, author_name: 'Sample Author', is_featured: false, is_pinned: false, comments_enabled: true, tags: [ { name: 'Sample Tag', slug: 'sample-tag' }, { name: 'News Import', slug: 'news-import' }, ], tag_names: ['Sample Tag', 'News Import'], tag_ids: [], relations: { related_articles: [], related_artworks: [], related_users: [], source_urls: ['https://example.com/sample-news-source'], }, meta_title: 'Sample News Title - Skinbase Example', meta_description: 'This is a sample news meta description for the structured import example.', meta_keywords: 'sample news, structured import, editorial example', canonical_url: 'https://skinbase.org/news/sample-news-title', og_title: 'Sample News Title', og_description: 'This is a sample news OG description for the structured import example.', og_image: 'sample-news-cover.webp', } const newsJsonSchemaSummary = `You are generating a Skinbase news article JSON object. Return only valid JSON. No markdown, no commentary, no code fences. Required fields: - title: string - content: HTML string Recommended fields: - slug: SEO slug - excerpt: short summary - cover_image: image path or URL - type: one of the editor's news types - category_id: preferred category id - editorial_status: draft|review|scheduled|published|archived - published_at: YYYY-MM-DD HH:MM:SS - author_id or author_name - comments_enabled: boolean - is_featured: boolean - is_pinned: boolean - meta_title, meta_description, meta_keywords - canonical_url - og_title, og_description, og_image - tags: array of strings or objects with name/title/label/slug - tag_names: array of strings - tag_ids: array of ids if you already know them - relations: array of objects with entity_type, entity_id, context_label Rules: - Use HTML paragraphs in content. - Keep excerpt concise. - Prefer category_id when you know it; otherwise include category/category_slug for matching. - If a tag is an object, keep the name field readable. - If source URLs are available, include them in a relations-related field or source notes field. ` const aiPromptExamples = [ { title: 'Blog-to-news generator', prompt: `${newsJsonSchemaSummary} Transform the following article into a news payload for the editor. - Preserve the factual meaning and the editorial tone. - Choose a concise title and an SEO-friendly slug. - Write content as HTML paragraphs. - Include 8 to 14 highly relevant tags. - Include category_id when possible, otherwise use category_slug or category to help matching. - Fill meta_title, meta_description, canonical_url, og_title, og_description, and og_image when available. - Make comments_enabled true unless the source clearly says otherwise. Input article text: {{ARTICLE_TEXT}}`, }, { title: 'Release announcement prompt', prompt: `${newsJsonSchemaSummary} Create a structured release announcement JSON object. - Use a direct product/news style headline. - Keep the excerpt short and easy to scan. - Write the content as 3 to 6 HTML paragraphs. - Include a realistic published_at timestamp in local time. - Set editorial_status to published if the article is already live, otherwise draft. - Set comments_enabled to true unless the release is sensitive or comments should be disabled. - Add 8 to 12 tags. Release notes: {{RELEASE_NOTES}}`, }, { title: 'Migration import prompt', prompt: `${newsJsonSchemaSummary} Convert the source article into Skinbase news JSON. - Preserve the factual content and keep the article structure readable. - Return only JSON. - Normalize tags into an array of objects with name and slug. - If the source contains article links, place them in a source_urls field inside the relations object. - If the source provides an author, category, or publish date, map those into the matching editor fields. - Use sensible defaults for any missing metadata. Source article: {{SOURCE_ARTICLE}}`, }, ] function tabButtonClass(active) { return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}` } const copyText = async (text, label) => { try { await navigator.clipboard.writeText(String(text)) setCopyFeedback(`${label} copied`) window.setTimeout(() => setCopyFeedback(''), 1800) } catch (copyError) { setCopyFeedback('Copy failed') window.setTimeout(() => setCopyFeedback(''), 1800) } } useEffect(() => { if (!open) return undefined const handleKeyDown = (event) => { if (event.key === 'Escape') { onClose?.() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [onClose, open]) if (!open) return null return createPortal(Structured import
Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.
`title`, `slug`, `excerpt`, `content`, `cover_image`
`type`, `category_id`, `category`, `category_slug`
`editorial_status`, `published_at`, `author_id`, `author_name`
`is_featured`, `is_pinned`, `comments_enabled`
`tags`, `tag_names`, `tag_ids`, `relations`
`new_tag_names` is capped at {newTagLimit} items per article.
`meta_title`, `meta_description`, `meta_keywords`, `canonical_url`, `og_title`, `og_description`, `og_image`
{JSON.stringify(structureExample, null, 2)}
Tags can be an array of strings or objects with `name`, `title`, `label`, or `slug`.
`content` accepts HTML. Keep paragraph tags and inline formatting in the JSON string.
`relations` may contain structured links and source URLs, but only direct entity fields are currently applied automatically.
title - article headline. If `slug` is omitted the editor can derive it from the title.
excerpt - short summary shown in listings and metadata.
content - HTML body, usually paragraph tags plus basic formatting.
category_id - preferred category selector value. `category_slug` and `category` are accepted for matching, but `category_id` is the reliable one.
tags - array of strings or objects. Existing tags are matched by name; new ones are staged automatically.
new_tag_names - capped at {newTagLimit} items per article. Use existing tags where possible to stay within the limit.
meta_keywords - max 255 characters. Keep it concise or the save will fail validation.
Boolean fields accept `true`/`false`, `1`/`0`, `yes`/`no`, and `on`/`off`.
Date values should be in `YYYY-MM-DD HH:MM:SS` format for scheduled stories.
Keep `new_tag_names` at or below {newTagLimit} items, which matches the editor validation.
Unknown keys are ignored, so you can paste a broader object safely.
{example.prompt}
Tell the model to return JSON only, with no explanation text.
Ask for `tags` as an array of objects when you want the most compatible import shape.
Include `source_urls` or reference links in the source instruction if you want them copied into the story notes.
Keep the draft flow simple: write the story in one place, handle publishing in one place, and keep promotion metadata nearby instead of buried below the fold.