From fe198fb1450c77cf63e77b38eb077d0f5cccae47 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Tue, 21 Jul 2026 17:54:36 -0700 Subject: [PATCH] refactor: split ArcCanvas.jsx into per-component files (#2835) --- .changelog/NEXT.md | 1 + client/src/components/pipeline/ArcCanvas.jsx | 2781 +---------------- .../pipeline/arcCanvas/AddSeasonRow.jsx | 69 + .../pipeline/arcCanvas/ArcContent.jsx | 175 ++ .../pipeline/arcCanvas/ArcHeader.jsx | 388 +++ .../pipeline/arcCanvas/ArcRoadmapChart.jsx | 35 + .../arcCanvas/CompletenessResults.jsx | 70 + .../pipeline/arcCanvas/CoverArt.jsx | 44 + .../arcCanvas/DeriveFromManuscriptPreview.jsx | 142 + .../arcCanvas/EditorialRoadmapPanel.jsx | 121 + .../pipeline/arcCanvas/FieldLockToggle.jsx | 46 + .../pipeline/arcCanvas/IssueRow.jsx | 114 + .../pipeline/arcCanvas/RoadmapMetric.jsx | 9 + .../pipeline/arcCanvas/SeasonActions.jsx | 222 ++ .../pipeline/arcCanvas/SeasonEditor.jsx | 125 + .../pipeline/arcCanvas/SeasonRow.jsx | 363 +++ .../pipeline/arcCanvas/ThemeChips.jsx | 162 + .../pipeline/arcCanvas/TickingClockCard.jsx | 33 + .../pipeline/arcCanvas/TickingClockEditor.jsx | 153 + .../pipeline/arcCanvas/UngroupedIssues.jsx | 25 + .../pipeline/arcCanvas/VerifyResults.jsx | 68 + .../pipeline/arcCanvas/VerifyScopeHint.jsx | 22 + .../pipeline/arcCanvas/VerifyScopeTooltip.jsx | 54 + .../arcCanvas/VolumeCoverEditorBox.jsx | 57 + .../arcCanvas/VolumeCoverLiveUpdates.jsx | 73 + .../arcCanvas/VolumeCoverSlotWatcher.jsx | 14 + .../pipeline/arcCanvas/VolumeCoverThumb.jsx | 26 + .../pipeline/arcCanvas/VolumeCoversPanel.jsx | 180 ++ .../pipeline/arcCanvas/VolumeNavigator.jsx | 51 + .../components/pipeline/arcCanvas/shared.js | 8 + 30 files changed, 2864 insertions(+), 2767 deletions(-) create mode 100644 client/src/components/pipeline/arcCanvas/AddSeasonRow.jsx create mode 100644 client/src/components/pipeline/arcCanvas/ArcContent.jsx create mode 100644 client/src/components/pipeline/arcCanvas/ArcHeader.jsx create mode 100644 client/src/components/pipeline/arcCanvas/ArcRoadmapChart.jsx create mode 100644 client/src/components/pipeline/arcCanvas/CompletenessResults.jsx create mode 100644 client/src/components/pipeline/arcCanvas/CoverArt.jsx create mode 100644 client/src/components/pipeline/arcCanvas/DeriveFromManuscriptPreview.jsx create mode 100644 client/src/components/pipeline/arcCanvas/EditorialRoadmapPanel.jsx create mode 100644 client/src/components/pipeline/arcCanvas/FieldLockToggle.jsx create mode 100644 client/src/components/pipeline/arcCanvas/IssueRow.jsx create mode 100644 client/src/components/pipeline/arcCanvas/RoadmapMetric.jsx create mode 100644 client/src/components/pipeline/arcCanvas/SeasonActions.jsx create mode 100644 client/src/components/pipeline/arcCanvas/SeasonEditor.jsx create mode 100644 client/src/components/pipeline/arcCanvas/SeasonRow.jsx create mode 100644 client/src/components/pipeline/arcCanvas/ThemeChips.jsx create mode 100644 client/src/components/pipeline/arcCanvas/TickingClockCard.jsx create mode 100644 client/src/components/pipeline/arcCanvas/TickingClockEditor.jsx create mode 100644 client/src/components/pipeline/arcCanvas/UngroupedIssues.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VerifyResults.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VerifyScopeHint.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VerifyScopeTooltip.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeCoverEditorBox.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeCoverLiveUpdates.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeCoverSlotWatcher.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeCoverThumb.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeCoversPanel.jsx create mode 100644 client/src/components/pipeline/arcCanvas/VolumeNavigator.jsx create mode 100644 client/src/components/pipeline/arcCanvas/shared.js diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index e9905d0c3..1a44e5fa0 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -35,3 +35,4 @@ ## Internal - **[issue-2832] Split the boot-schema DDL into per-domain modules** — the ~1265-line inline `CREATE TABLE`/`CREATE INDEX`/trigger block in `ensureSchemaImpl()` (`server/lib/db.js`) is extracted into per-domain modules under `server/lib/db/schema/` (catalog, media, universes, writers-room, pipeline, privacy, tribe, audit, …), each exporting a statement array; `ensureSchemaImpl` is now a thin composer. Statement text and order are byte-identical, so the composed schema is unchanged. +- **[issue-2835] Split the ~2875-line `ArcCanvas.jsx` into per-component files** — each of the 32 subcomponents/helpers (ArcHeader, EditorialRoadmapPanel, SeasonRow, IssueRow, VolumeCoversPanel, …) now lives in its own file under `client/src/components/pipeline/arcCanvas/`, with the shared severity-color map in `arcCanvas/shared.js`. `ArcCanvas.jsx` (default export) is now a thin 122-line composer and still re-exports `ArcRoadmapChart` so its public import path is unchanged. Pure mechanical, behavior-preserving refactor — no rendered-output change. diff --git a/client/src/components/pipeline/ArcCanvas.jsx b/client/src/components/pipeline/ArcCanvas.jsx index 6770cc74f..456784061 100644 --- a/client/src/components/pipeline/ArcCanvas.jsx +++ b/client/src/components/pipeline/ArcCanvas.jsx @@ -21,240 +21,23 @@ * The LLM passes (arc/generate, episodes/generate, verify) hit the Phase 3 * routes; mutations are reflected in local state so the canvas stays * responsive without a refetch. + * + * This module is the page entry (default export ArcCanvas). Every subcomponent + * lives in its own file under ./arcCanvas/ — see that directory for the split. + * ArcRoadmapChart is re-exported here so its existing public import path + * (`{ ArcRoadmapChart } from '.../ArcCanvas'`, used by PipelineSeriesRoadmap) + * keeps working. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { Link } from 'react-router-dom'; -import { - Plus, Trash2, Loader2, Sparkles, ShieldCheck, ChevronRight, ChevronDown, - ChevronsUpDown, AlertCircle, Wand2, Info, ListChecks, X, Lock, Unlock, - ImageIcon, Layers, FileDown, BookOpen, ChartSpline, FileSearch, BookText, FileText, - Clock, -} from 'lucide-react'; -import toast from '../ui/Toast'; -import ConfirmButtonPair from '../ui/ConfirmButtonPair'; -import { timeAgo } from '../../utils/formatters'; -import { useLockToggle } from '../../hooks/useLockToggle'; -import MediaImage from '../MediaImage'; -import { - createPipelineIssue, deletePipelineIssue, updatePipelineIssue, - createPipelineSeason, updatePipelineSeason, deletePipelineSeason, - generatePipelineArcOverview, generatePipelineSeasonEpisodes, verifyPipelineArc, - verifyPipelineVolume, - resolvePipelineArcIssues, - derivePipelineArcFromManuscript, commitPipelineArcFromManuscript, - analyzePipelineManuscriptCompleteness, - listPipelineIssues, updatePipelineSeries, setPipelineArcFieldLock, - startPipelineVolumeBeats, cancelPipelineVolumeBeats, - generatePipelineVolumeCover, generatePipelineVolumeBackCover, - generatePipelineVolumeCoverConcepts, - pipelineVolumePdfUrl, pipelineVolumeBeatsSseUrl, -} from '../../services/api'; -import useMediaJobProgress from '../../hooks/useMediaJobProgress'; -import { usePipelineProgress } from '../../hooks/usePipelineProgress'; -import { useSeriesEditorial } from '../../hooks/useSeriesEditorial'; -import { dominant } from '../../lib/editorialRoadmap'; -import SeriesLlmPicker from './SeriesLlmPicker'; -import { ArcShapePicker, ArcShapeSparkline, getStoryShape } from './StoryShapes'; - -const ISSUE_STATUS_COLORS = { - draft: 'text-gray-400 bg-gray-700/30', - running: 'text-port-accent bg-port-accent/10', - 'needs-review': 'text-port-warning bg-port-warning/10', - shipped: 'text-port-success bg-port-success/10', -}; - -const SEVERITY_COLORS = { - high: 'text-port-error border-port-error/40 bg-port-error/10', - medium: 'text-port-warning border-port-warning/40 bg-port-warning/10', - low: 'text-gray-400 border-gray-500/30 bg-gray-700/20', -}; - -const THEME_COLORS = [ - 'border-sky-400/40 bg-sky-500/10 text-sky-200', - 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200', - 'border-amber-400/40 bg-amber-500/10 text-amber-200', - 'border-rose-400/40 bg-rose-500/10 text-rose-200', - 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200', - 'border-fuchsia-400/40 bg-fuchsia-500/10 text-fuchsia-200', - 'border-lime-400/40 bg-lime-500/10 text-lime-200', - 'border-orange-400/40 bg-orange-500/10 text-orange-200', -]; - -// What each verify pass actually checks. Surfaced as a `
` next to -// the button so the editor knows what they're getting (and what they're NOT -// getting) before they trust the green check. -const VERIFY_ARC_SCOPE = { - depth: 'Synopsis-level only across every volume. Beats and scripts are NOT read — use Validate volume for that.', - checks: [ - 'Character contradictions across volumes (dead character speaks; protagonist state breaks at a volume boundary)', - 'Dropped subplots (an early endingHook never paid off later)', - 'Episode-count vs. arc-weight mismatch per volume', - 'Unresolved finale hooks (logline / protagonist arc / themes)', - 'Arc-role imbalance (missing or duplicate pilot / finale)', - 'Theme drift (a theme is named in the arc but appears in no synopsis)', - 'World entity drift (refs to nonexistent factions / characters / locations, or unused major entities)', - ], -}; - -const VERIFY_VOLUME_SCOPE = { - depth: 'One volume in depth — reads beat sheets (stages.idea.output) for issues that have them, falls back to synopsis depth for un-expanded issues. Boundary checks against the immediate-neighbor volumes only.', - checks: [ - 'Volume-internal arc shape (does the volume read as a complete sub-arc; does the final issue pay off the endingHook)', - 'Within-volume continuity (a character / object / beat that disappears mid-volume without resolution)', - 'Beat-level escalation (issues with beats only) — adjacent issues that plateau or contradict each other', - 'Promise drift (the volume logline / synopsis makes a promise no issue delivers — or vice versa)', - 'Boundary continuity (volume opening picks up the prior endingHook; volume endingHook seeds the next volume)', - 'Cast economy (a one-beat introduction never seen again, or a major bible character the volume never uses)', - 'Volume-scope world-entity drift', - 'Length-vs-weight mismatch obvious in isolation', - ], -}; - -function VerifyScopeHint({ scope }) { - return ( -
- - What this checks - -
-

{scope.depth}

-
    - {scope.checks.map((c) => ( -
  • {c}
  • - ))} -
-
-
- ); -} - -// Hover-revealed tooltip variant — wraps its trigger and renders the popover -// through a portal to with fixed positioning, anchored below-right of -// the trigger. The portal escapes the horizontally-scrolling button row's -// `overflow` clipping and any ancestor stacking context (a plain -// `absolute`/`z-index` popover got clipped and painted under the nav). The -// `id` is exposed so the trigger can wire `aria-describedby` for screen readers. -function VerifyScopeTooltip({ scope, id, children }) { - const anchorRef = useRef(null); - const [pos, setPos] = useState(null); - - const show = useCallback(() => { - const el = anchorRef.current; - if (!el) return; - const r = el.getBoundingClientRect(); - setPos({ top: r.bottom + 8, right: Math.max(8, window.innerWidth - r.right) }); - }, []); - const hide = useCallback(() => setPos(null), []); - - return ( -
- {children} - {pos && createPortal( - , - document.body, - )} -
- ); -} - -function pickRenderedFilename(record) { - if (!record) return null; - return record.finalImage?.filename - || record.proofImage?.filename - || (typeof record.filename === 'string' && record.filename ? record.filename : null); -} - -function pickCoverJobId(record) { - if (!record) return null; - return record.finalImage?.jobId - || record.proofImage?.jobId - || record.imageJobId - || null; -} - -function issueCoverRecord(issue) { - return issue?.stages?.comicPages?.cover || null; -} - -function updateVolumeCoverSlot(season, coverKey, slotKey, filename) { - if (!season || !filename) return season; - const coverRecord = season[coverKey] || {}; - const slot = coverRecord[slotKey] || {}; - if (slot.filename === filename) return season; - return { - ...season, - [coverKey]: { - ...coverRecord, - [slotKey]: { - ...slot, - filename, - }, - }, - }; -} - -function updateSeriesVolumeCoverSlot(series, seasonId, coverKey, slotKey, filename) { - if (!series || !seasonId || !filename) return series; - let changed = false; - const nextSeasons = (series.seasons || []).map((season) => { - if (season.id !== seasonId) return season; - const nextSeason = updateVolumeCoverSlot(season, coverKey, slotKey, filename); - if (nextSeason !== season) changed = true; - return nextSeason; - }); - return changed ? { ...series, seasons: nextSeasons } : series; -} +import { useEffect, useMemo, useState } from 'react'; +import ArcHeader from './arcCanvas/ArcHeader.jsx'; +import EditorialRoadmapPanel from './arcCanvas/EditorialRoadmapPanel.jsx'; +import VolumeNavigator from './arcCanvas/VolumeNavigator.jsx'; +import SeasonRow from './arcCanvas/SeasonRow.jsx'; +import UngroupedIssues from './arcCanvas/UngroupedIssues.jsx'; +import AddSeasonRow from './arcCanvas/AddSeasonRow.jsx'; -function CoverArt({ record, label, className = '', placeholderClassName = '' }) { - const filename = pickRenderedFilename(record); - const inFlight = !filename && !!pickCoverJobId(record); - const hasConcept = !!(record?.script || '').trim(); - - if (filename) { - return ( - - ); - } - - return ( -
- - - {inFlight ? 'Rendering' : hasConcept ? 'Cover queued' : 'No cover'} - -
- ); -} +export { default as ArcRoadmapChart } from './arcCanvas/ArcRoadmapChart.jsx'; export default function ArcCanvas({ series, issues, onSeriesUpdate, onIssuesUpdate, onFlushPending }) { const seasons = useMemo(() => series.seasons || [], [series.seasons]); @@ -337,2539 +120,3 @@ export default function ArcCanvas({ series, issues, onSeriesUpdate, onIssuesUpda ); } - -function VolumeNavigator({ seasons, issuesBySeason, activeSeasonId, onSelect }) { - return ( -
-
-
-

Volumes

-

{seasons.length} volume{seasons.length === 1 ? '' : 's'} in this arc

-
-
-
- {seasons.map((season) => { - const active = season.id === activeSeasonId; - const issueCount = issuesBySeason.get(season.id)?.length || 0; - return ( - - ); - })} -
-
- ); -} - -function EditorialRoadmapPanel({ series, seasons, issues }) { - const seasonCount = seasons.length; - const issueCount = issues.length; - const { - aggregate, loading, running, starting, - startAnalysis, cancelAnalysis, coverage, analyzedPoints, progressText, - } = useSeriesEditorial(series.id); - - const hasData = analyzedPoints.length > 0; - const plotVals = analyzedPoints.map((p) => p.plot); - const avgPlot = plotVals.length ? Math.round(plotVals.reduce((a, b) => a + b, 0) / plotVals.length) : null; - const peakPlot = plotVals.length ? Math.max(...plotVals) : null; - const peakAt = peakPlot != null ? analyzedPoints.find((p) => p.plot === peakPlot)?.label : ''; - const protagonist = aggregate?.protagonist || null; - const supportingCount = aggregate?.supportingArcs?.length || 0; - const readerEmotion = dominant(analyzedPoints.map((p) => p.primaryEmotion)); - - return ( -
-
-
-

- Editorial roadmap - -

-

- {seasonCount} volume{seasonCount === 1 ? '' : 's'}, {issueCount} issue{issueCount === 1 ? '' : 's'} - {' · '} - - {coverage.analyzed}/{coverage.total} analyzed{coverage.stale ? ` · ${coverage.stale} stale` : ''} - -

-
- -
- -
- {loading ? ( -
- Loading roadmap… -
- ) : hasData ? ( - - ) : ( -
-

- {coverage.withContent === 0 - ? 'No drafted content yet — write or generate prose/scripts, then run analysis.' - : 'Not analyzed yet. Run the reader analysis to map plot, character, and reader emotion from the actual content.'} -

-
- )} -
- -
- - - -
- -
- {running ? ( - - ) : ( - - )} - - View reader map → - -
-
- ); -} - -function RoadmapMetric({ label, value, sub, tone, title }) { - return ( -
-
{label}
-
{value}
- {sub ?
{sub}
: null} -
- ); -} - -// Real roadmap chart. `points` are the analyzed issues only, each carrying a -// `frac` (0..1) x-position within the full ordered issue list so spacing -// reflects arc position; unanalyzed gaps are skipped (the line bridges them). -export function ArcRoadmapChart({ points }) { - const width = 320; - const height = 132; - const toPolyline = (key) => points.map((point) => { - const x = point.frac * width; - const y = height - ((point[key] / 100) * height); - return `${x.toFixed(1)},${y.toFixed(1)}`; - }).join(' '); - - return ( -
- - {[0.25, 0.5, 0.75].map((n) => ( - - ))} - - - - {points.map((point, idx) => ( - - {point.label}: {point.title}{point.primaryEmotion ? ` — reader feels ${point.primaryEmotion}` : ''}{point.stale ? ' (stale — content changed since analysis)' : ''} - - ))} - -
- Plot - Character - Reader -
-
- ); -} - -// ---- Arc header (logline, themes, action buttons) ---- - -function ArcHeader({ series, onSeriesUpdate, onIssuesUpdate, onFlushPending }) { - const arc = series.arc; - const arcLocked = !!series.locked?.arc; - const [running, setRunning] = useState(null); // 'generate' | 'verify' | 'resolve' | 'derive' | 'derive-commit' | 'completeness' | null - const [verifyIssues, setVerifyIssues] = useState(null); - // Derive-from-manuscript preview (null until the LLM proposal lands) and the - // categorized "finish the draft" findings. - const [derivePreview, setDerivePreview] = useState(null); - const [completeness, setCompleteness] = useState(null); - // Which finding indexes have an in-flight per-finding resolve. Lets the row - // show its own spinner without blocking the rest of the page. - const [resolvingIdx, setResolvingIdx] = useState(new Set()); - const [confirmingRegen, setConfirmingRegen] = useState(false); - - // Switching series must not leak the prior series' advisory results. These - // are all ephemeral (never persisted) — reset on id change. `running` is - // intentionally excluded: it's bound to an in-flight call whose - // setRunning(null) must still fire on completion. - useEffect(() => { - setVerifyIssues(null); - setDerivePreview(null); - setCompleteness(null); - setConfirmingRegen(false); - setResolvingIdx(new Set()); - }, [series.id]); - - const { busy: lockBusy, toggle: toggleArcLock } = useLockToggle({ - patchFn: (next) => updatePipelineSeries(series.id, { - locked: { ...(series.locked || {}), arc: next }, - }, { silent: true }), - onSuccess: (updated, next) => { - onSeriesUpdate(updated); - if (next) setConfirmingRegen(false); - }, - lockedMessage: 'Arc locked — regeneration and auto-resolve are now blocked', - unlockedMessage: 'Arc unlocked', - errorMessage: 'Failed to update lock', - }); - - const llmOverride = useMemo(() => ({ - providerOverride: series.llm?.provider || undefined, - modelOverride: series.llm?.model || undefined, - }), [series.llm?.provider, series.llm?.model]); - - // Persist pending bible edits BEFORE the LLM call reads from the server, - // so typing "32" into the issue count and clicking Regenerate runs against - // the on-screen value, not the previously-saved one. - const withFlush = async (fn) => { - if (onFlushPending) await onFlushPending(); - return fn(); - }; - - const runGenerate = async () => { - setConfirmingRegen(false); - setRunning('generate'); - const result = await withFlush(() => - generatePipelineArcOverview(series.id, { commit: true, ...llmOverride }, { silent: true }).catch((err) => { - toast.error(err.message || 'Failed to generate arc'); - return null; - }), - ); - setRunning(null); - if (!result) return; - onSeriesUpdate(result.series); - toast.success('Arc generated and saved'); - }; - - const runVerify = async () => { - setRunning('verify'); - const result = await withFlush(() => - verifyPipelineArc(series.id, llmOverride, { silent: true }).catch((err) => { - toast.error(err.message || 'Failed to verify arc'); - return null; - }), - ); - setRunning(null); - if (!result) return; - setVerifyIssues(result.issues || []); - if ((result.issues || []).length === 0) { - toast.success('Arc verified — no issues found'); - } else { - toast.error(`Arc verification surfaced ${result.issues.length} issue${result.issues.length === 1 ? '' : 's'}`); - } - }; - - // Auto-resolve. `findings` undefined = resolve all currently-displayed - // findings (server re-verifies first if findings comes through empty). - const runResolve = async (findingsSubset) => { - setRunning('resolve'); - const result = await withFlush(() => - resolvePipelineArcIssues(series.id, { - findings: findingsSubset, - ...llmOverride, - }, { silent: true }).catch((err) => { - toast.error(err.message || 'Auto-resolve failed'); - return null; - }), - ); - setRunning(null); - if (!result) return null; - if (result.series) onSeriesUpdate(result.series); - if (onIssuesUpdate) { - // Server may reassign child issues' seasonId when resolve replaces - // season records — refresh so the tree reflects the moves. - const refreshed = await listPipelineIssues(series.id).catch(() => null); - if (refreshed) onIssuesUpdate(refreshed); - } - if (result.applied) { - toast.success('Arc updated to resolve findings — re-verify when ready'); - } else { - toast.success(result.notes || 'Nothing to resolve'); - } - return result; - }; - - const resolveAll = async () => { - const result = await runResolve(verifyIssues || []); - if (result?.applied) setVerifyIssues(null); - }; - - const resolveOne = async (idx, finding) => { - setResolvingIdx((prev) => new Set(prev).add(idx)); - const result = await runResolve([finding]); - setResolvingIdx((prev) => { - const next = new Set(prev); - next.delete(idx); - return next; - }); - if (result?.applied) { - setVerifyIssues((prev) => (prev || []).filter((_, i) => i !== idx)); - } - }; - - // Back-derive arc + bible + a single-volume restructure from the issue - // manuscripts. Read-only — opens the preview the user reviews/edits before - // committing. - const runDerive = async () => { - setRunning('derive'); - const result = await withFlush(() => - derivePipelineArcFromManuscript(series.id, llmOverride, { silent: true }).catch((err) => { - toast.error(err.message || 'Failed to derive from manuscript'); - return null; - }), - ); - setRunning(null); - if (!result) return; - setDerivePreview(result); - }; - - // Apply the (edited) derive preview. No LLM re-run — the confirmed proposal - // is sent verbatim. Refreshes series + issues so the volume collapse + issue - // reassignment + bible fill show immediately. - const runDeriveCommit = async (proposal) => { - setRunning('derive-commit'); - const result = await commitPipelineArcFromManuscript(series.id, proposal, { silent: true }).catch((err) => { - toast.error(err.message || 'Failed to apply'); - return null; - }); - setRunning(null); - if (!result) return; - if (result.series) onSeriesUpdate(result.series); - if (onIssuesUpdate) { - const refreshed = await listPipelineIssues(series.id).catch(() => null); - if (refreshed) onIssuesUpdate(refreshed); - } - setDerivePreview(null); - toast.success(`Arc, bible, and a single volume of ${result.issueCount} issue${result.issueCount === 1 ? '' : 's'} derived from the manuscript`); - }; - - // "Finish the draft" — manuscript-completeness editor pass. Advisory; no - // auto-resolve (the suggestions guide manual authoring). - const runCompleteness = async () => { - setRunning('completeness'); - const result = await withFlush(() => - analyzePipelineManuscriptCompleteness(series.id, llmOverride, { silent: true }).catch((err) => { - toast.error(err.message || 'Failed to analyze manuscript'); - return null; - }), - ); - setRunning(null); - if (!result) return; - setCompleteness(result.issues || []); - if ((result.issues || []).length === 0) { - toast.success('Manuscript looks complete — no gaps found'); - } else { - toast(`Found ${result.issues.length} suggestion${result.issues.length === 1 ? '' : 's'} to finish the draft`); - } - }; - - // A picked `shape` alone counts as an "arc" record (it's an explicit - // narrative-design decision the sanitizer preserves), but it isn't a - // generated arc — the LLM hasn't written anything yet. Use the - // text-content check to drive the "Generate" vs "Regenerate" affordances - // so the user isn't told to regenerate something that doesn't exist yet. - const hasGeneratedArc = !!( - arc && (arc.logline || arc.summary || arc.protagonistArc || arc.themes?.length) - ); - const generateBtnLabel = hasGeneratedArc ? 'Regenerate arc' : 'Generate arc'; - // First-time Generate has nothing to overwrite, so skip the confirm prompt. - const handleGenerateClick = () => { - if (arcLocked) return; - if (hasGeneratedArc) setConfirmingRegen(true); - else runGenerate(); - }; - - return ( -
-
-

Series arc

-
- - {hasGeneratedArc ? ( - - ) : null} - - {hasGeneratedArc ? ( - - - - ) : null} - - -
-
- - {confirmingRegen ? ( -
-

Regenerate the entire arc?

-

- This overwrites the arc logline, summary, protagonist arc, themes, and every volume / season outline. - Click Lock arc above first to preserve your approved version — once locked, regeneration and auto-resolve are blocked until you unlock. -

-
- - -
-
- ) : null} - - {arc ? ( - - ) : ( -

- No arc yet — describe the series in the bible, then click Generate arc to have an LLM propose a multi-volume spine + volume breakdown. -

- )} - - {verifyIssues && verifyIssues.length > 0 ? ( - setVerifyIssues(null)} - onResolveAll={arcLocked ? null : resolveAll} - onResolveOne={arcLocked ? null : resolveOne} - resolvingAll={running === 'resolve' && resolvingIdx.size === 0} - resolvingIdx={resolvingIdx} - lockedNote={arcLocked ? 'Arc is locked — unlock above to enable auto-resolve.' : null} - /> - ) : null} - - {derivePreview ? ( - setDerivePreview(null)} - onConfirm={runDeriveCommit} - /> - ) : null} - - {completeness ? ( - setCompleteness(null)} - /> - ) : null} -
- ); -} - -// Field-count guard for derived synopsis textareas — mirrors the server caps so -// the user isn't surprised by a 400 on commit. -const DERIVE_SYNOPSIS_MAX = 8000; -const DERIVE_TITLE_MAX = 300; - -// Review/edit panel for the derive-from-manuscript proposal. The arc + bible -// fields and the single-volume title/synopsis are editable; each existing issue -// gets an editable title + synopsis (pre-filled from the derived seasons). On -// confirm the edited proposal is sent — the LLM is NOT re-run. -function DeriveFromManuscriptPreview({ preview, committing, onCancel, onConfirm }) { - const [arc, setArc] = useState(() => ({ - logline: preview.arc?.logline || '', - summary: preview.arc?.summary || '', - protagonistArc: preview.arc?.protagonistArc || '', - themes: Array.isArray(preview.arc?.themes) ? preview.arc.themes : [], - shape: preview.arc?.shape ?? null, - })); - const [bible, setBible] = useState(() => ({ - logline: preview.bible?.logline || '', - premise: preview.bible?.premise || '', - issueCountTarget: preview.bible?.issueCountTarget ?? (preview.issues?.length || 0), - })); - const [volume, setVolume] = useState(() => ({ - title: preview.volume?.title || '', - logline: preview.volume?.logline || '', - synopsis: preview.volume?.synopsis || '', - })); - // Per-issue editable rows. Default synopsis = the derived suggestion, falling - // back to whatever synopsis the issue already carried. - const [issues, setIssues] = useState(() => - (preview.issues || []).map((iss) => ({ - id: iss.id, - number: iss.number, - title: iss.title || '', - synopsis: iss.synopsisSuggestion || iss.currentSynopsis || '', - ideaLocked: !!iss.ideaLocked, - })), - ); - - const setIssueField = (id, field, value) => - setIssues((prev) => prev.map((it) => (it.id === id ? { ...it, [field]: value } : it))); - - const confirm = () => { - onConfirm({ - arc, - bible, - volume, - issues: issues.map((it) => ({ - id: it.id, - title: it.title.slice(0, DERIVE_TITLE_MAX), - synopsis: it.ideaLocked ? '' : it.synopsis.slice(0, DERIVE_SYNOPSIS_MAX), - })), - }); - }; - - const inputCls = 'w-full bg-port-bg border border-port-border rounded px-2 py-1 text-sm text-white focus:border-port-accent/50 outline-none'; - - return ( -
-
-

- - Derived from manuscript — review before applying -

- -
-

- Applying collapses the series into one volume holding all {issues.length} issue{issues.length === 1 ? '' : 's'} as chapters/acts, - fills the bible, and seeds each issue's synopsis. Your verbatim issue scripts are not changed. -

- -
- - -
-