/* Campaign settings: the things a campaign is, rather than the things it did. * * `BROWSER-UX-SPEC.md` §39 lists Campaign Settings, Export and Delete Campaign * as campaign-level actions, and this is where they live — inside the campaign, * which is the only place they mean anything. * * It replaced the inherited "Plot" panel, which held the campaign's copy of a * scenario's text alongside a story-card table, an "Update from scenario" * dialog and an art picker. The fields worth keeping were the ones that * actually reach the prompt; the rest was AI Dungeon's world-info editor, which * the M7 knowledge library supersedes for v1. * * Canon gets its own editor here because it is the highest authority in the * campaign and the thing a reader most often needs to correct *after* the story * has tried to contradict it. It is written one rule per line and stored in the * same document the prompt builder and the state validator already read. */ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../../../api' import { downloadJSON, useToast } from '../../../components' import { ConfirmDialog } from '../../../Dialog' import { classifyError } from '../../../errors' export function CampaignSettingsPanel({ adventure, setAdventure, onError, moments = 0 }) { const navigate = useNavigate() const toast = useToast() const [title, setTitle] = useState(adventure.title || '') const [instructions, setInstructions] = useState(adventure.ai_instructions || '') const [canon, setCanon] = useState((adventure.canon_rules || []).join('\n')) const [persona, setPersona] = useState(adventure.persona_name || '') const [personaDesc, setPersonaDesc] = useState(adventure.persona_desc || '') const [saving, setSaving] = useState(false) const [dirty, setDirty] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) // Re-seed if the campaign is replaced under us (an import, a restore). useEffect(() => { setTitle(adventure.title || '') setInstructions(adventure.ai_instructions || '') setCanon((adventure.canon_rules || []).join('\n')) setPersona(adventure.persona_name || '') setPersonaDesc(adventure.persona_desc || '') setDirty(false) }, [adventure.id]) // eslint-disable-line react-hooks/exhaustive-deps const edit = (setter) => (event) => { setter(event.target.value) setDirty(true) } // Saved on an explicit press rather than on a debounce timer. A debounced // save writes on every pause in typing, which is a request per few keystrokes // against a field a reader edits for a minute at a time — the request-per- // keystroke class of defect §38 looks for, arrived at politely. const save = async () => { setSaving(true) try { const updated = await api.updateAdventure(adventure.id, { title: title.trim() || 'Untitled campaign', ai_instructions: instructions, canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean), persona_name: persona.trim(), persona_desc: personaDesc, }) setAdventure((prev) => ({ ...prev, ...updated })) setDirty(false) toast('Campaign settings saved.') } catch (err) { onError(err.message) } finally { setSaving(false) } } const exportCampaign = async () => { try { const bundle = await api.exportAdventure(adventure.id) const safe = (adventure.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60) downloadJSON(bundle, `${safe}.json`) toast('Campaign exported.') } catch (err) { onError(classifyError(err.message).detail) } } const remove = async () => { try { await api.deleteAdventure(adventure.id) navigate('/') } catch (err) { onError(classifyError(err.message).detail) } } return (