import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components' import { useDebouncedSave } from '../hooks/useDebouncedSave' import ArtPicker from '../ArtPicker' import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor' export default function ScenarioEditor() { const { id } = useParams() const navigate = useNavigate() const [scenario, setScenario] = useState(null) const [status, setStatus] = useState('') // Raw text buffer for the stat_schema JSON editor + a live parse error. const [schemaText, setSchemaText] = useState('') const [schemaError, setSchemaError] = useState('') // Last successfully-parsed schema (drives the form editor + preview). const [parsedSchema, setParsedSchema] = useState(null) const [schemaView, setSchemaView] = useState('form') // 'form' | 'json' const toast = useToast() const debounceSave = useDebouncedSave() useEffect(() => { api.getScenario(id).then((s) => { setScenario(s) setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '') setParsedSchema(s.stat_schema || null) }).catch(() => navigate('/scenarios')) }, [id, navigate]) const setField = (field, value) => { const next = { ...scenario, [field]: value } setScenario(next) debounceSave(field, async () => { await api.updateScenario(id, { [field]: value }) setStatus('Saved') setTimeout(() => setStatus(''), 1500) }) } // Several fields at once, in one PATCH — the art picker sets `image` and // `icon` together (choosing one clears the other, and they must not race). const setFields = (patch) => { setScenario((prev) => ({ ...prev, ...patch })) debounceSave('art', async () => { await api.updateScenario(id, patch) setStatus('Saved') setTimeout(() => setStatus(''), 1500) }) } // JSON view: typing only updates the text buffer; nothing is parsed or saved // until the user clicks Save (so a half-typed edit doesn't spam errors). const onJsonText = (text) => { setSchemaText(text) if (schemaError) setSchemaError('') } // Parse the JSON box on demand: apply + save if valid, else show the error. const commitJson = () => { const trimmed = schemaText.trim() if (!trimmed) { setSchemaError('') setParsedSchema(null) saveSchema(null) return } let parsed try { parsed = JSON.parse(trimmed) } catch (err) { setSchemaError(`Invalid JSON: ${err.message}`) return } if (typeof parsed !== 'object' || Array.isArray(parsed)) { setSchemaError('The schema must be a JSON object (e.g. { "player": { … } }).') return } setSchemaError('') setParsedSchema(parsed) setSchemaText(JSON.stringify(parsed, null, 2)) // normalize / pretty-print saveSchema(parsed) } const saveSchema = async (parsed) => { await api.updateScenario(id, { stat_schema: parsed }) setScenario((s) => ({ ...s, stat_schema: parsed })) setStatus('Saved') setTimeout(() => setStatus(''), 1500) } // Edits from the form editor: keep the JSON text in sync and save. An empty // schema clears the RPG layer (null). const applySchema = (next) => { const cleaned = next && Object.keys(next).length ? next : null setParsedSchema(cleaned) setSchemaText(cleaned ? JSON.stringify(cleaned, null, 2) : '') setSchemaError('') debounceSave('stat_schema', () => saveSchema(cleaned)) } // NPCs live in the same stat_schema, but are edited in their own page // section — so this is just the `npcs` slice of the same save path. const setNpcs = (npcs) => { const next = { ...(parsedSchema || {}) } if (npcs && Object.keys(npcs).length) next.npcs = npcs else delete next.npcs applySchema(next) } const addCard = async () => { const card = await api.createStoryCard({ scenario_id: Number(id) }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] }) } const updateCard = (card) => { setScenario({ ...scenario, story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)), }) debounceSave(`card-${card.id}`, () => { api.updateStoryCard(card.id, { name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes, }) }) } const deleteCard = async (cardId) => { await api.deleteStoryCard(cardId) setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) }) } const exportCards = async () => { const cards = await api.exportStoryCards({ scenario_id: id }) downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`) } const importCards = async () => { try { const parsed = await pickJSONFile() const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error') const created = await api.importStoryCards({ scenario_id: Number(id), cards }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] }) } catch (err) { toast(err.message, 'error') } } const exportScenario = async () => { downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`) } const deleteScenario = async () => { if (!confirm('Delete this scenario permanently?')) return await api.deleteScenario(id) navigate('/scenarios') } const play = async () => { const adv = await api.createAdventure({ scenario_id: Number(id) }) navigate(`/play/${adv.id}`) } if (!scenario) return null // Shared demo scenarios (Phase 8) are visible to everyone but owned by no // one; the backend rejects edits, so present them read-only. const readOnly = !!scenario.is_public return (