developing #10

Merged
GHMiranda merged 64 commits from developing into master 2026-01-25 00:37:11 +00:00
3 changed files with 448 additions and 15 deletions
Showing only changes of commit a3e1d1cea2 - Show all commits

View File

@ -201,7 +201,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorProps) {
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
@ -431,6 +431,7 @@ function FlowchartEditorInner({ initialData, needsMigration }: FlowchartEditorPr
</div>
{showSettings && (
<ProjectSettingsModal
projectId={projectId}
characters={characters}
variables={variables}
onCharactersChange={setCharacters}

View File

@ -0,0 +1,386 @@
'use client'
import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid'
import { createClient } from '@/lib/supabase/client'
import type { Character, Variable } from '@/types/flowchart'
type ImportMode = 'characters' | 'variables'
type ProjectListItem = {
id: string
name: string
}
type ImportFromProjectModalProps = {
mode: ImportMode
currentProjectId: string
existingCharacters: Character[]
existingVariables: Variable[]
onImportCharacters: (characters: Character[]) => void
onImportVariables: (variables: Variable[]) => void
onClose: () => void
}
export default function ImportFromProjectModal({
mode,
currentProjectId,
existingCharacters,
existingVariables,
onImportCharacters,
onImportVariables,
onClose,
}: ImportFromProjectModalProps) {
const [projects, setProjects] = useState<ProjectListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [sourceCharacters, setSourceCharacters] = useState<Character[]>([])
const [sourceVariables, setSourceVariables] = useState<Variable[]>([])
const [loadingSource, setLoadingSource] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [warnings, setWarnings] = useState<string[]>([])
// Load user's projects on mount
useEffect(() => {
async function fetchProjects() {
const supabase = createClient()
const { data, error: fetchError } = await supabase
.from('projects')
.select('id, name')
.neq('id', currentProjectId)
.order('name')
if (fetchError) {
setError('Failed to load projects')
setLoading(false)
return
}
setProjects(data || [])
setLoading(false)
}
fetchProjects()
}, [currentProjectId])
// Load source project's characters/variables when a project is selected
const handleSelectProject = async (projectId: string) => {
setSelectedProjectId(projectId)
setLoadingSource(true)
setWarnings([])
setSelectedIds(new Set())
const supabase = createClient()
const { data, error: fetchError } = await supabase
.from('projects')
.select('flowchart_data')
.eq('id', projectId)
.single()
if (fetchError || !data) {
setError('Failed to load project data')
setLoadingSource(false)
return
}
const flowchartData = data.flowchart_data || {}
const chars: Character[] = flowchartData.characters || []
const vars: Variable[] = flowchartData.variables || []
setSourceCharacters(chars)
setSourceVariables(vars)
setLoadingSource(false)
// Select all by default
const items = mode === 'characters' ? chars : vars
setSelectedIds(new Set(items.map((item) => item.id)))
}
const handleToggleItem = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleSelectAll = () => {
const items = mode === 'characters' ? sourceCharacters : sourceVariables
setSelectedIds(new Set(items.map((item) => item.id)))
}
const handleSelectNone = () => {
setSelectedIds(new Set())
}
const handleImport = () => {
const importWarnings: string[] = []
if (mode === 'characters') {
const selectedCharacters = sourceCharacters.filter((c) => selectedIds.has(c.id))
const existingNames = new Set(existingCharacters.map((c) => c.name.toLowerCase()))
const toImport: Character[] = []
for (const char of selectedCharacters) {
if (existingNames.has(char.name.toLowerCase())) {
importWarnings.push(`Skipped "${char.name}" (already exists)`)
} else {
// Create new ID to avoid conflicts
toImport.push({ ...char, id: nanoid() })
existingNames.add(char.name.toLowerCase())
}
}
if (importWarnings.length > 0) {
setWarnings(importWarnings)
}
if (toImport.length > 0) {
onImportCharacters(toImport)
}
if (importWarnings.length === 0) {
onClose()
}
} else {
const selectedVariables = sourceVariables.filter((v) => selectedIds.has(v.id))
const existingNames = new Set(existingVariables.map((v) => v.name.toLowerCase()))
const toImport: Variable[] = []
for (const variable of selectedVariables) {
if (existingNames.has(variable.name.toLowerCase())) {
importWarnings.push(`Skipped "${variable.name}" (already exists)`)
} else {
// Create new ID to avoid conflicts
toImport.push({ ...variable, id: nanoid() })
existingNames.add(variable.name.toLowerCase())
}
}
if (importWarnings.length > 0) {
setWarnings(importWarnings)
}
if (toImport.length > 0) {
onImportVariables(toImport)
}
if (importWarnings.length === 0) {
onClose()
}
}
}
const items = mode === 'characters' ? sourceCharacters : sourceVariables
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
Import {mode === 'characters' ? 'Characters' : 'Variables'} from Project
</h3>
<button
onClick={onClose}
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading && (
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
Loading projects...
</p>
)}
{error && (
<p className="py-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{!loading && !error && !selectedProjectId && (
<>
{projects.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No other projects found.
</p>
) : (
<div className="space-y-1">
<p className="mb-3 text-sm text-zinc-600 dark:text-zinc-400">
Select a project to import from:
</p>
{projects.map((project) => (
<button
key={project.id}
onClick={() => handleSelectProject(project.id)}
className="flex w-full items-center rounded-lg border border-zinc-200 px-4 py-3 text-left text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-700/50"
>
<svg className="mr-3 h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{project.name}
</button>
))}
</div>
)}
</>
)}
{loadingSource && (
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
Loading {mode}...
</p>
)}
{selectedProjectId && !loadingSource && (
<>
{/* Back button */}
<button
onClick={() => { setSelectedProjectId(null); setWarnings([]) }}
className="mb-3 flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to projects
</button>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
This project has no {mode} defined.
</p>
) : (
<>
{/* Select all/none controls */}
<div className="mb-3 flex items-center gap-3">
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{selectedIds.size} of {items.length} selected
</span>
<button
onClick={handleSelectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select all
</button>
<button
onClick={handleSelectNone}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select none
</button>
</div>
{/* Item list with checkboxes */}
<div className="space-y-1">
{mode === 'characters'
? sourceCharacters.map((char) => (
<label
key={char.id}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
>
<input
type="checkbox"
checked={selectedIds.has(char.id)}
onChange={() => handleToggleItem(char.id)}
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
/>
<div
className="h-3.5 w-3.5 rounded-full"
style={{ backgroundColor: char.color }}
/>
<span className="text-sm text-zinc-900 dark:text-zinc-50">
{char.name}
</span>
{char.description && (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
- {char.description}
</span>
)}
</label>
))
: sourceVariables.map((variable) => (
<label
key={variable.id}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
>
<input
type="checkbox"
checked={selectedIds.has(variable.id)}
onChange={() => handleToggleItem(variable.id)}
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
/>
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
variable.type === 'numeric'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: variable.type === 'string'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{variable.type}
</span>
<span className="text-sm text-zinc-900 dark:text-zinc-50">
{variable.name}
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
(initial: {String(variable.initialValue)})
</span>
</label>
))}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div className="mt-3 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
<p className="mb-1 text-xs font-medium text-orange-700 dark:text-orange-300">
Import warnings:
</p>
{warnings.map((warning, i) => (
<p key={i} className="text-xs text-orange-600 dark:text-orange-400">
{warning}
</p>
))}
</div>
)}
</>
)}
</>
)}
</div>
{/* Footer with import button */}
{selectedProjectId && !loadingSource && items.length > 0 && (
<div className="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-3 dark:border-zinc-700">
<button
onClick={onClose}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={selectedIds.size === 0}
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Import {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -3,10 +3,14 @@
import { useState } from 'react'
import { nanoid } from 'nanoid'
import type { Character, Variable } from '@/types/flowchart'
import ImportFromProjectModal from './ImportFromProjectModal'
type Tab = 'characters' | 'variables'
type ImportModalState = { open: boolean; mode: 'characters' | 'variables' }
type ProjectSettingsModalProps = {
projectId: string
characters: Character[]
variables: Variable[]
onCharactersChange: (characters: Character[]) => void
@ -26,6 +30,7 @@ function randomHexColor(): string {
}
export default function ProjectSettingsModal({
projectId,
characters,
variables,
onCharactersChange,
@ -35,6 +40,15 @@ export default function ProjectSettingsModal({
getVariableUsageCount,
}: ProjectSettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>('characters')
const [importModal, setImportModal] = useState<ImportModalState>({ open: false, mode: 'characters' })
const handleImportCharacters = (imported: Character[]) => {
onCharactersChange([...characters, ...imported])
}
const handleImportVariables = (imported: Variable[]) => {
onVariablesChange([...variables, ...imported])
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@ -90,6 +104,7 @@ export default function ProjectSettingsModal({
characters={characters}
onChange={onCharactersChange}
getUsageCount={getCharacterUsageCount}
onImport={() => setImportModal({ open: true, mode: 'characters' })}
/>
)}
{activeTab === 'variables' && (
@ -97,10 +112,23 @@ export default function ProjectSettingsModal({
variables={variables}
onChange={onVariablesChange}
getUsageCount={getVariableUsageCount}
onImport={() => setImportModal({ open: true, mode: 'variables' })}
/>
)}
</div>
</div>
{importModal.open && (
<ImportFromProjectModal
mode={importModal.mode}
currentProjectId={projectId}
existingCharacters={characters}
existingVariables={variables}
onImportCharacters={handleImportCharacters}
onImportVariables={handleImportVariables}
onClose={() => setImportModal({ open: false, mode: importModal.mode })}
/>
)}
</div>
)
}
@ -110,6 +138,7 @@ type CharactersTabProps = {
characters: Character[]
onChange: (characters: Character[]) => void
getUsageCount: (characterId: string) => number
onImport: () => void
}
type CharacterFormData = {
@ -118,7 +147,7 @@ type CharacterFormData = {
description: string
}
function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) {
function CharactersTab({ characters, onChange, getUsageCount, onImport }: CharactersTabProps) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
@ -207,12 +236,20 @@ function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabPro
Define characters that can be referenced in dialogue nodes.
</p>
{!isAdding && !editingId && (
<button
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Add Character
</button>
<div className="flex items-center gap-2">
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Import from project
</button>
<button
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Add Character
</button>
</div>
)}
</div>
@ -378,6 +415,7 @@ type VariablesTabProps = {
variables: Variable[]
onChange: (variables: Variable[]) => void
getUsageCount: (variableId: string) => number
onImport: () => void
}
type VariableType = 'numeric' | 'string' | 'boolean'
@ -406,7 +444,7 @@ function parseInitialValue(type: VariableType, raw: string): number | string | b
}
}
function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps) {
function VariablesTab({ variables, onChange, getUsageCount, onImport }: VariablesTabProps) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
@ -503,12 +541,20 @@ function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps)
Define variables that can be referenced in variable nodes and edge conditions.
</p>
{!isAdding && !editingId && (
<button
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Add Variable
</button>
<div className="flex items-center gap-2">
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Import from project
</button>
<button
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Add Variable
</button>
</div>
)}
</div>