ralph/collaboration-and-character-variables #7

Merged
GHMiranda merged 14 commits from ralph/collaboration-and-character-variables into developing 2026-01-23 18:59:46 +00:00
3 changed files with 253 additions and 2 deletions
Showing only changes of commit 0d72471f8f - Show all commits

View File

@ -24,6 +24,7 @@ import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
@ -229,6 +230,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const [showSettings, setShowSettings] = useState(false)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
@ -360,8 +363,92 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
// TODO: Implement in US-034
}, [])
const performExport = useCallback(() => {
// TODO: Actual export logic in US-035
setValidationIssues(null)
setWarningNodeIds(new Set())
}, [])
const handleExport = useCallback(() => {
// TODO: Implement in US-035
const issues: ValidationIssue[] = []
const characterIds = new Set(characters.map((c) => c.id))
const variableIds = new Set(variables.map((v) => v.id))
// Scan nodes for undefined references
nodes.forEach((node) => {
if (node.type === 'dialogue' && node.data?.characterId) {
if (!characterIds.has(node.data.characterId)) {
issues.push({
nodeId: node.id,
nodeType: 'dialogue',
contentSnippet: node.data.text
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
: 'Empty dialogue',
undefinedReference: node.data.characterId,
referenceType: 'character',
})
}
}
if (node.type === 'variable' && node.data?.variableId) {
if (!variableIds.has(node.data.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'variable',
contentSnippet: node.data.variableName
? `Variable: ${node.data.variableName}`
: 'Variable node',
undefinedReference: node.data.variableId,
referenceType: 'variable',
})
}
}
if (node.type === 'choice' && node.data?.options) {
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'choice',
contentSnippet: opt.label
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
: 'Choice option',
undefinedReference: opt.condition.variableId,
referenceType: 'variable',
})
}
})
}
})
// Scan edges for undefined variable references in conditions
edges.forEach((edge) => {
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
issues.push({
nodeId: edge.id,
nodeType: 'edge',
contentSnippet: edge.data.condition.variableName
? `Condition on: ${edge.data.condition.variableName}`
: 'Edge condition',
undefinedReference: edge.data.condition.variableId,
referenceType: 'variable',
})
}
})
if (issues.length === 0) {
performExport()
} else {
setValidationIssues(issues)
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
}
}, [nodes, edges, characters, variables, performExport])
const handleExportAnyway = useCallback(() => {
performExport()
}, [performExport])
const handleExportCancel = useCallback(() => {
setValidationIssues(null)
setWarningNodeIds(new Set())
}, [])
const handleImport = useCallback(() => {
@ -394,6 +481,19 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
[setEdges]
)
// Apply warning styles to nodes with undefined references
const styledNodes = useMemo(
() =>
warningNodeIds.size === 0
? nodes
: nodes.map((node) =>
warningNodeIds.has(node.id)
? { ...node, className: 'export-warning-node' }
: node
),
[nodes, warningNodeIds]
)
// Get the selected edge's condition data
const selectedEdge = useMemo(
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
@ -414,7 +514,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
/>
<div className="flex-1">
<ReactFlow
nodes={nodes}
nodes={styledNodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
@ -449,6 +549,13 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
onClose={() => setSelectedEdgeId(null)}
/>
)}
{validationIssues && (
<ExportValidationModal
issues={validationIssues}
onExportAnyway={handleExportAnyway}
onCancel={handleExportCancel}
/>
)}
{toastMessage && (
<Toast
message={toastMessage}

View File

@ -24,3 +24,16 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Export validation warning highlighting for React Flow nodes */
.react-flow__node.export-warning-node {
outline: 3px solid #f97316;
outline-offset: 2px;
border-radius: 6px;
animation: pulse-warning 1.5s ease-in-out infinite;
}
@keyframes pulse-warning {
0%, 100% { outline-color: #f97316; }
50% { outline-color: #fb923c; }
}

View File

@ -0,0 +1,131 @@
'use client'
export type ValidationIssue = {
nodeId: string
nodeType: 'dialogue' | 'choice' | 'variable' | 'edge'
contentSnippet: string
undefinedReference: string
referenceType: 'character' | 'variable'
}
type ExportValidationModalProps = {
issues: ValidationIssue[]
onExportAnyway: () => void
onCancel: () => void
}
export default function ExportValidationModal({
issues,
onExportAnyway,
onCancel,
}: ExportValidationModalProps) {
const characterIssues = issues.filter((i) => i.referenceType === 'character')
const variableIssues = issues.filter((i) => i.referenceType === 'variable')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800">
<div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<div className="flex items-center gap-2">
<svg
className="h-5 w-5 text-orange-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Export Validation Issues
</h2>
</div>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist.
</p>
</div>
<div className="max-h-[50vh] overflow-y-auto px-6 py-4">
{characterIssues.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Undefined Characters
</h3>
<ul className="space-y-2">
{characterIssues.map((issue, idx) => (
<li
key={`char-${idx}`}
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{issue.nodeType}
</span>
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
{issue.contentSnippet}
</p>
</div>
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
ID: {issue.undefinedReference.slice(0, 8)}...
</span>
</div>
</li>
))}
</ul>
</div>
)}
{variableIssues.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Undefined Variables
</h3>
<ul className="space-y-2">
{variableIssues.map((issue, idx) => (
<li
key={`var-${idx}`}
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{issue.nodeType}
</span>
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
{issue.contentSnippet}
</p>
</div>
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
ID: {issue.undefinedReference.slice(0, 8)}...
</span>
</div>
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
<button
onClick={onCancel}
className="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Cancel
</button>
<button
onClick={onExportAnyway}
className="rounded bg-orange-500 px-4 py-2 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Export anyway
</button>
</div>
</div>
</div>
)
}