diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..ee0c8f6 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -22,7 +22,8 @@ import Toolbar from '@/components/editor/Toolbar' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' -import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart' type FlowchartEditorProps = { projectId: string @@ -76,6 +77,30 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { toReactFlowEdges(initialData.edges) ) + const [characters, setCharacters] = useState(initialData.characters) + const [variables, setVariables] = useState(initialData.variables) + const [showSettings, setShowSettings] = useState(false) + + const getCharacterUsageCount = useCallback( + (characterId: string) => { + return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length + }, + [nodes] + ) + + const getVariableUsageCount = useCallback( + (variableId: string) => { + const nodeCount = nodes.filter( + (n) => n.type === 'variable' && n.data?.variableId === variableId + ).length + const edgeCount = edges.filter( + (e) => e.data?.condition?.variableId === variableId + ).length + return nodeCount + edgeCount + }, + [nodes, edges] + ) + const onConnect = useCallback( (params: Connection) => { if (!params.source || !params.target) return @@ -177,6 +202,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onSave={handleSave} onExport={handleExport} onImport={handleImport} + onProjectSettings={() => setShowSettings(true)} />
+ {showSettings && ( + setShowSettings(false)} + getCharacterUsageCount={getCharacterUsageCount} + getVariableUsageCount={getVariableUsageCount} + /> + )} ) } diff --git a/src/components/editor/ProjectSettingsModal.tsx b/src/components/editor/ProjectSettingsModal.tsx new file mode 100644 index 0000000..e8b5360 --- /dev/null +++ b/src/components/editor/ProjectSettingsModal.tsx @@ -0,0 +1,421 @@ +'use client' + +import { useState } from 'react' +import { nanoid } from 'nanoid' +import type { Character, Variable } from '@/types/flowchart' + +type Tab = 'characters' | 'variables' + +type ProjectSettingsModalProps = { + characters: Character[] + variables: Variable[] + onCharactersChange: (characters: Character[]) => void + onVariablesChange: (variables: Variable[]) => void + onClose: () => void + getCharacterUsageCount: (characterId: string) => number + getVariableUsageCount: (variableId: string) => number +} + +function randomHexColor(): string { + const colors = [ + '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', + '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', + '#ec4899', '#f43f5e', + ] + return colors[Math.floor(Math.random() * colors.length)] +} + +export default function ProjectSettingsModal({ + characters, + variables, + onCharactersChange, + onVariablesChange, + onClose, + getCharacterUsageCount, + getVariableUsageCount, +}: ProjectSettingsModalProps) { + const [activeTab, setActiveTab] = useState('characters') + + return ( +
+ + ) +} + +// Characters Tab +type CharactersTabProps = { + characters: Character[] + onChange: (characters: Character[]) => void + getUsageCount: (characterId: string) => number +} + +type CharacterFormData = { + name: string + color: string + description: string +} + +function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) { + const [isAdding, setIsAdding] = useState(false) + const [editingId, setEditingId] = useState(null) + const [formData, setFormData] = useState({ name: '', color: randomHexColor(), description: '' }) + const [formError, setFormError] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const resetForm = () => { + setFormData({ name: '', color: randomHexColor(), description: '' }) + setFormError(null) + } + + const validateName = (name: string, excludeId?: string): boolean => { + if (!name.trim()) { + setFormError('Name is required') + return false + } + const duplicate = characters.find( + (c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId + ) + if (duplicate) { + setFormError('A character with this name already exists') + return false + } + setFormError(null) + return true + } + + const handleAdd = () => { + if (!validateName(formData.name)) return + const newCharacter: Character = { + id: nanoid(), + name: formData.name.trim(), + color: formData.color, + description: formData.description.trim() || undefined, + } + onChange([...characters, newCharacter]) + setIsAdding(false) + resetForm() + } + + const handleEdit = (character: Character) => { + setEditingId(character.id) + setFormData({ + name: character.name, + color: character.color, + description: character.description || '', + }) + setFormError(null) + setIsAdding(false) + } + + const handleSaveEdit = () => { + if (!editingId) return + if (!validateName(formData.name, editingId)) return + onChange( + characters.map((c) => + c.id === editingId + ? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined } + : c + ) + ) + setEditingId(null) + resetForm() + } + + const handleDelete = (id: string) => { + const usageCount = getUsageCount(id) + if (usageCount > 0 && deleteConfirm !== id) { + setDeleteConfirm(id) + return + } + onChange(characters.filter((c) => c.id !== id)) + setDeleteConfirm(null) + } + + const handleCancelForm = () => { + setIsAdding(false) + setEditingId(null) + resetForm() + } + + return ( +
+
+

+ Define characters that can be referenced in dialogue nodes. +

+ {!isAdding && !editingId && ( + + )} +
+ + {/* Character List */} +
+ {characters.map((character) => ( +
+ {editingId === character.id ? ( + + ) : ( +
+
+
+
+ + {character.name} + + {character.description && ( +

+ {character.description} +

+ )} +
+
+
+ {deleteConfirm === character.id && ( + + Used in {getUsageCount(character.id)} node(s). Delete anyway? + + )} + + +
+
+ )} +
+ ))} + + {characters.length === 0 && !isAdding && ( +

+ No characters defined yet. Click "Add Character" to create one. +

+ )} +
+ + {/* Add Form */} + {isAdding && ( +
+ +
+ )} +
+ ) +} + +// Character Form +type CharacterFormProps = { + formData: CharacterFormData + formError: string | null + onChange: (data: CharacterFormData) => void + onSave: () => void + onCancel: () => void + saveLabel: string +} + +function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) { + return ( +
+
+
+
+ + onChange({ ...formData, color: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600" + /> +
+
+ + onChange({ ...formData, name: e.target.value })} + placeholder="Character name" + autoFocus + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+
+
+ + onChange({ ...formData, description: e.target.value })} + placeholder="Optional description" + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + /> +
+ {formError && ( +

{formError}

+ )} +
+ + +
+
+
+ ) +} + +// Variables Tab (placeholder for US-057) +type VariablesTabProps = { + variables: Variable[] + onChange: (variables: Variable[]) => void + getUsageCount: (variableId: string) => number +} + +function VariablesTab({ variables }: VariablesTabProps) { + return ( +
+

+ Define variables that can be referenced in variable nodes and edge conditions. +

+ {variables.length === 0 ? ( +

+ No variables defined yet. Variable management will be available in a future update. +

+ ) : ( +
+ {variables.map((variable) => ( +
+
+ + {variable.type} + +
+ + {variable.name} + + {variable.description && ( +

+ {variable.description} +

+ )} +
+
+ + Initial: {String(variable.initialValue)} + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 08f0243..81c2c45 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -7,6 +7,7 @@ type ToolbarProps = { onSave: () => void onExport: () => void onImport: () => void + onProjectSettings: () => void } export default function Toolbar({ @@ -16,6 +17,7 @@ export default function Toolbar({ onSave, onExport, onImport, + onProjectSettings, }: ToolbarProps) { return (
@@ -44,6 +46,12 @@ export default function Toolbar({
+