ralph/collaboration-and-character-variables #8
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
|
@ -27,20 +27,42 @@ import { createClient } from '@/lib/supabase/client'
|
||||||
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
||||||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||||
|
import ConditionalEdge from '@/components/editor/edges/ConditionalEdge'
|
||||||
|
import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
|
||||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||||
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
||||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||||
import Toast from '@/components/Toast'
|
|
||||||
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
|
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
import { CRDTManager } from '@/lib/collaboration/crdt'
|
import { CRDTManager } from '@/lib/collaboration/crdt'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
import ShareModal from '@/components/editor/ShareModal'
|
import ShareModal from '@/components/editor/ShareModal'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
// LocalStorage key prefix for draft saves
|
||||||
|
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
|
||||||
|
|
||||||
|
// Debounce delay in ms
|
||||||
|
const AUTOSAVE_DEBOUNCE_MS = 1000
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
type: ContextMenuType
|
||||||
|
nodeId?: string
|
||||||
|
edgeId?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
type ConditionEditorState = {
|
||||||
|
edgeId: string
|
||||||
|
condition?: Condition
|
||||||
|
} | null
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
projectName: string
|
projectName: string
|
||||||
|
userId: string
|
||||||
|
userDisplayName: string
|
||||||
|
isOwner: boolean
|
||||||
initialData: FlowchartData
|
initialData: FlowchartData
|
||||||
needsMigration?: boolean
|
needsMigration?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +93,205 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert React Flow Node type back to our FlowchartNode type
|
||||||
|
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type as 'dialogue' | 'choice' | 'variable',
|
||||||
|
position: node.position,
|
||||||
|
data: node.data,
|
||||||
|
})) as FlowchartNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert React Flow Edge type back to our FlowchartEdge type
|
||||||
|
function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] {
|
||||||
|
return edges.map((edge) => ({
|
||||||
|
id: edge.id,
|
||||||
|
source: edge.source,
|
||||||
|
sourceHandle: edge.sourceHandle ?? undefined,
|
||||||
|
target: edge.target,
|
||||||
|
targetHandle: edge.targetHandle ?? undefined,
|
||||||
|
data: edge.data,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get LocalStorage key for a project
|
||||||
|
function getDraftKey(projectId: string): string {
|
||||||
|
return `${DRAFT_KEY_PREFIX}${projectId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save draft to LocalStorage
|
||||||
|
function saveDraft(projectId: string, data: FlowchartData): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getDraftKey(projectId), JSON.stringify(data))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save draft to LocalStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load draft from LocalStorage
|
||||||
|
function loadDraft(projectId: string): FlowchartData | null {
|
||||||
|
try {
|
||||||
|
const draft = localStorage.getItem(getDraftKey(projectId))
|
||||||
|
if (!draft) return null
|
||||||
|
return JSON.parse(draft) as FlowchartData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load draft from LocalStorage:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear draft from LocalStorage
|
||||||
|
export function clearDraft(projectId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(getDraftKey(projectId))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear draft from LocalStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two FlowchartData objects for equality
|
||||||
|
function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate imported flowchart data structure
|
||||||
|
function isValidFlowchartData(data: unknown): data is FlowchartData {
|
||||||
|
if (!data || typeof data !== 'object') return false
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
if (!Array.isArray(obj.nodes)) return false
|
||||||
|
if (!Array.isArray(obj.edges)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first node (node with no incoming edges)
|
||||||
|
function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null {
|
||||||
|
const targetIds = new Set(edges.map((e) => e.target))
|
||||||
|
const startNodes = nodes.filter((n) => !targetIds.has(n.id))
|
||||||
|
return startNodes[0] || nodes[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get outgoing edge from a node
|
||||||
|
function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined {
|
||||||
|
return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all outgoing edges from a node
|
||||||
|
function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] {
|
||||||
|
return edges.filter((e) => e.source === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flowchart to Ren'Py JSON format
|
||||||
|
function convertToRenpyFormat(
|
||||||
|
nodes: FlowchartNode[],
|
||||||
|
edges: FlowchartEdge[],
|
||||||
|
projectName: string
|
||||||
|
): { projectName: string; exportedAt: string; sections: Record<string, unknown[]> } {
|
||||||
|
const nodeMap = new Map<string, FlowchartNode>(nodes.map((n) => [n.id, n]))
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const sections: Record<string, unknown[]> = {}
|
||||||
|
let currentSectionName = 'start'
|
||||||
|
let currentSection: unknown[] = []
|
||||||
|
|
||||||
|
const nodeLabels = new Map<string, string>()
|
||||||
|
let labelCounter = 0
|
||||||
|
|
||||||
|
function getNodeLabel(nodeId: string): string {
|
||||||
|
if (!nodeLabels.has(nodeId)) {
|
||||||
|
const node = nodeMap.get(nodeId)
|
||||||
|
if (node?.type === 'dialogue' && node.data.speaker) {
|
||||||
|
nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`)
|
||||||
|
} else {
|
||||||
|
nodeLabels.set(nodeId, `section_${labelCounter++}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodeLabels.get(nodeId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function processNode(nodeId: string): void {
|
||||||
|
if (visited.has(nodeId)) return
|
||||||
|
visited.add(nodeId)
|
||||||
|
|
||||||
|
const node = nodeMap.get(nodeId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
if (node.type === 'dialogue') {
|
||||||
|
const outgoingEdge = getOutgoingEdge(nodeId, edges)
|
||||||
|
const renpyNode: Record<string, unknown> = {
|
||||||
|
type: 'dialogue',
|
||||||
|
speaker: node.data.speaker || '',
|
||||||
|
text: node.data.text,
|
||||||
|
}
|
||||||
|
if (outgoingEdge) {
|
||||||
|
renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target
|
||||||
|
if (outgoingEdge.data?.condition) {
|
||||||
|
renpyNode.condition = outgoingEdge.data.condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSection.push(renpyNode)
|
||||||
|
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
|
||||||
|
processNode(outgoingEdge.target)
|
||||||
|
}
|
||||||
|
} else if (node.type === 'choice') {
|
||||||
|
const outEdges = getOutgoingEdges(nodeId, edges)
|
||||||
|
const choices = outEdges.map((edge) => {
|
||||||
|
const choiceData: Record<string, unknown> = {
|
||||||
|
label: edge.sourceHandle || 'Choice',
|
||||||
|
next: edge.target,
|
||||||
|
}
|
||||||
|
if (edge.data?.condition) {
|
||||||
|
choiceData.condition = edge.data.condition
|
||||||
|
}
|
||||||
|
return choiceData
|
||||||
|
})
|
||||||
|
currentSection.push({
|
||||||
|
type: 'menu',
|
||||||
|
prompt: node.data.prompt || '',
|
||||||
|
choices,
|
||||||
|
})
|
||||||
|
// Process each choice target as a new section
|
||||||
|
outEdges.forEach((edge) => {
|
||||||
|
if (!visited.has(edge.target)) {
|
||||||
|
sections[currentSectionName] = currentSection
|
||||||
|
currentSectionName = getNodeLabel(edge.target)
|
||||||
|
currentSection = []
|
||||||
|
processNode(edge.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (node.type === 'variable') {
|
||||||
|
const outgoingEdge = getOutgoingEdge(nodeId, edges)
|
||||||
|
const renpyNode: Record<string, unknown> = {
|
||||||
|
type: 'variable',
|
||||||
|
name: node.data.variableName || '',
|
||||||
|
operation: node.data.operation || 'set',
|
||||||
|
value: node.data.value ?? 0,
|
||||||
|
}
|
||||||
|
if (outgoingEdge) {
|
||||||
|
renpyNode.next = visited.has(outgoingEdge.target) ? getNodeLabel(outgoingEdge.target) : outgoingEdge.target
|
||||||
|
if (outgoingEdge.data?.condition) {
|
||||||
|
renpyNode.condition = outgoingEdge.data.condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSection.push(renpyNode)
|
||||||
|
if (outgoingEdge && !visited.has(outgoingEdge.target)) {
|
||||||
|
processNode(outgoingEdge.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = findFirstNode(nodes, edges)
|
||||||
|
if (startNode) {
|
||||||
|
processNode(startNode.id)
|
||||||
|
}
|
||||||
|
sections[currentSectionName] = currentSection
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectName,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const RANDOM_COLORS = [
|
const RANDOM_COLORS = [
|
||||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
|
@ -212,7 +433,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner component that uses useReactFlow hook
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -450,6 +671,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
const currentData: FlowchartData = {
|
const currentData: FlowchartData = {
|
||||||
nodes: fromReactFlowNodes(nodes),
|
nodes: fromReactFlowNodes(nodes),
|
||||||
edges: fromReactFlowEdges(edges),
|
edges: fromReactFlowEdges(edges),
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
}
|
}
|
||||||
saveDraft(projectId, currentData)
|
saveDraft(projectId, currentData)
|
||||||
}, AUTOSAVE_DEBOUNCE_MS)
|
}, AUTOSAVE_DEBOUNCE_MS)
|
||||||
|
|
@ -460,16 +683,18 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [nodes, edges, projectId, draftState.showPrompt])
|
}, [nodes, edges, characters, variables, projectId, draftState.showPrompt])
|
||||||
|
|
||||||
// Calculate dirty state by comparing current data with last saved data
|
// Calculate dirty state by comparing current data with last saved data
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
const currentData: FlowchartData = {
|
const currentData: FlowchartData = {
|
||||||
nodes: fromReactFlowNodes(nodes),
|
nodes: fromReactFlowNodes(nodes),
|
||||||
edges: fromReactFlowEdges(edges),
|
edges: fromReactFlowEdges(edges),
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
}
|
}
|
||||||
return !flowchartDataEquals(currentData, lastSavedDataRef.current)
|
return !flowchartDataEquals(currentData, lastSavedDataRef.current)
|
||||||
}, [nodes, edges])
|
}, [nodes, edges, characters, variables])
|
||||||
|
|
||||||
// Browser beforeunload warning when dirty
|
// Browser beforeunload warning when dirty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -589,6 +814,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
const flowchartData: FlowchartData = {
|
const flowchartData: FlowchartData = {
|
||||||
nodes: fromReactFlowNodes(nodes),
|
nodes: fromReactFlowNodes(nodes),
|
||||||
edges: fromReactFlowEdges(edges),
|
edges: fromReactFlowEdges(edges),
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
|
|
@ -620,7 +847,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}, [isSaving, nodes, edges, projectId])
|
}, [isSaving, nodes, edges, characters, variables, projectId])
|
||||||
|
|
||||||
// Keep ref updated with latest handleSave
|
// Keep ref updated with latest handleSave
|
||||||
handleSaveRef.current = handleSave
|
handleSaveRef.current = handleSave
|
||||||
|
|
@ -630,6 +857,8 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
const flowchartData: FlowchartData = {
|
const flowchartData: FlowchartData = {
|
||||||
nodes: fromReactFlowNodes(nodes),
|
nodes: fromReactFlowNodes(nodes),
|
||||||
edges: fromReactFlowEdges(edges),
|
edges: fromReactFlowEdges(edges),
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pretty-printed JSON
|
// Create pretty-printed JSON
|
||||||
|
|
@ -651,7 +880,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
// Cleanup
|
// Cleanup
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [nodes, edges, projectName])
|
}, [nodes, edges, characters, variables, projectName])
|
||||||
|
|
||||||
const handleExportRenpy = useCallback(() => {
|
const handleExportRenpy = useCallback(() => {
|
||||||
// Convert React Flow state to our flowchart types
|
// Convert React Flow state to our flowchart types
|
||||||
|
|
@ -692,14 +921,27 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
|
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
|
||||||
}, [nodes, edges, projectName])
|
}, [nodes, edges, projectName])
|
||||||
|
|
||||||
|
const handleExportAnyway = useCallback(() => {
|
||||||
|
setValidationIssues(null)
|
||||||
|
setWarningNodeIds(new Set())
|
||||||
|
handleExportRenpy()
|
||||||
|
}, [handleExportRenpy])
|
||||||
|
|
||||||
|
const handleExportCancel = useCallback(() => {
|
||||||
|
setValidationIssues(null)
|
||||||
|
setWarningNodeIds(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Check if current flowchart has unsaved changes
|
// Check if current flowchart has unsaved changes
|
||||||
const hasUnsavedChanges = useCallback(() => {
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
const currentData: FlowchartData = {
|
const currentData: FlowchartData = {
|
||||||
nodes: fromReactFlowNodes(nodes),
|
nodes: fromReactFlowNodes(nodes),
|
||||||
edges: fromReactFlowEdges(edges),
|
edges: fromReactFlowEdges(edges),
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
}
|
}
|
||||||
return !flowchartDataEquals(currentData, initialData)
|
return !flowchartDataEquals(currentData, initialData)
|
||||||
}, [nodes, edges, initialData])
|
}, [nodes, edges, characters, variables, initialData])
|
||||||
|
|
||||||
// Load imported data into React Flow
|
// Load imported data into React Flow
|
||||||
const loadImportedData = useCallback(
|
const loadImportedData = useCallback(
|
||||||
|
|
@ -804,6 +1046,134 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
[setEdges]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Context menu handlers
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPaneContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'canvas',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNodeContextMenu: NodeMouseHandler = useCallback(
|
||||||
|
(event, node) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'node',
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
|
||||||
|
(event, edge) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
type: 'edge',
|
||||||
|
edgeId: edge.id,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddNodeAtPosition = useCallback(
|
||||||
|
(type: 'dialogue' | 'choice' | 'variable') => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
const position = screenToFlowPosition({
|
||||||
|
x: contextMenu.x,
|
||||||
|
y: contextMenu.y,
|
||||||
|
})
|
||||||
|
|
||||||
|
let newNode: Node
|
||||||
|
if (type === 'dialogue') {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'dialogue',
|
||||||
|
position,
|
||||||
|
data: { speaker: '', text: '' },
|
||||||
|
}
|
||||||
|
} else if (type === 'choice') {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'choice',
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
prompt: '',
|
||||||
|
options: [
|
||||||
|
{ id: nanoid(), label: '' },
|
||||||
|
{ id: nanoid(), label: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newNode = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'variable',
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
variableName: '',
|
||||||
|
operation: 'set',
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes((nodes) => [...nodes, newNode])
|
||||||
|
setContextMenu(null)
|
||||||
|
},
|
||||||
|
[contextMenu, screenToFlowPosition, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteNode = useCallback(() => {
|
||||||
|
if (!contextMenu?.nodeId) return
|
||||||
|
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, setNodes])
|
||||||
|
|
||||||
|
const handleDeleteEdge = useCallback(() => {
|
||||||
|
if (!contextMenu?.edgeId) return
|
||||||
|
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, setEdges])
|
||||||
|
|
||||||
|
const openConditionEditor = useCallback(
|
||||||
|
(edgeId: string) => {
|
||||||
|
const edge = edges.find((e) => e.id === edgeId)
|
||||||
|
if (!edge) return
|
||||||
|
setConditionEditor({
|
||||||
|
edgeId,
|
||||||
|
condition: edge.data?.condition,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[edges]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddCondition = useCallback(() => {
|
||||||
|
if (!contextMenu?.edgeId) return
|
||||||
|
openConditionEditor(contextMenu.edgeId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, openConditionEditor])
|
||||||
|
|
||||||
|
const onEdgeDoubleClick = useCallback(
|
||||||
|
(_event: React.MouseEvent, edge: Edge) => {
|
||||||
|
openConditionEditor(edge.id)
|
||||||
|
},
|
||||||
|
[openConditionEditor]
|
||||||
|
)
|
||||||
|
|
||||||
// Apply warning styles to nodes with undefined references
|
// Apply warning styles to nodes with undefined references
|
||||||
const styledNodes = useMemo(
|
const styledNodes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -825,13 +1195,15 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorProvider value={editorContextValue}>
|
<EditorProvider value={editorContextValue}>
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-screen w-full flex-col">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
onAddDialogue={handleAddDialogue}
|
onAddDialogue={handleAddDialogue}
|
||||||
onAddChoice={handleAddChoice}
|
onAddChoice={handleAddChoice}
|
||||||
onAddVariable={handleAddVariable}
|
onAddVariable={handleAddVariable}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
|
onExportRenpy={handleExportRenpy}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onProjectSettings={() => setShowSettings(true)}
|
onProjectSettings={() => setShowSettings(true)}
|
||||||
onShare={() => setShowShare(true)}
|
onShare={() => setShowShare(true)}
|
||||||
|
|
@ -843,11 +1215,16 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
nodes={styledNodes}
|
nodes={styledNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onEdgesDelete={onEdgesDelete}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={onEdgeClick}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onEdgeContextMenu={onEdgeContextMenu}
|
||||||
|
onEdgeDoubleClick={onEdgeDoubleClick}
|
||||||
deleteKeyCode={['Delete', 'Backspace']}
|
deleteKeyCode={['Delete', 'Backspace']}
|
||||||
fitView
|
fitView
|
||||||
>
|
>
|
||||||
|
|
@ -855,6 +1232,21 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
|
||||||
<Controls position="bottom-right" />
|
<Controls position="bottom-right" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
type={contextMenu.type}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
|
||||||
|
onAddChoice={() => handleAddNodeAtPosition('choice')}
|
||||||
|
onAddVariable={() => handleAddNodeAtPosition('variable')}
|
||||||
|
onDelete={
|
||||||
|
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
|
||||||
|
}
|
||||||
|
onAddCondition={handleAddCondition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<ProjectSettingsModal
|
<ProjectSettingsModal
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
import PresenceAvatars from './PresenceAvatars'
|
import PresenceAvatars from './PresenceAvatars'
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ type ToolbarProps = {
|
||||||
onAddChoice: () => void
|
onAddChoice: () => void
|
||||||
onAddVariable: () => void
|
onAddVariable: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
|
isSaving?: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
onExportRenpy: () => void
|
onExportRenpy: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
|
|
@ -36,6 +38,7 @@ export default function Toolbar({
|
||||||
onAddChoice,
|
onAddChoice,
|
||||||
onAddVariable,
|
onAddVariable,
|
||||||
onSave,
|
onSave,
|
||||||
|
isSaving,
|
||||||
onExport,
|
onExport,
|
||||||
onExportRenpy,
|
onExportRenpy,
|
||||||
onImport,
|
onImport,
|
||||||
|
|
@ -47,6 +50,15 @@ export default function Toolbar({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="mr-2 flex items-center gap-1 text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
|
title="Back to Dashboard"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||||
Add Node:
|
Add Node:
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,11 @@ const MAX_OPTIONS = 6
|
||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { variables } = useEditorContext() // Puxa as variáveis globais para validar condições
|
||||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// --- Handlers de Atualização ---
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
|
|
@ -59,26 +62,43 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateOptionCondition = useCallback(
|
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
|
||||||
(optionId: string, condition: Condition | undefined) => {
|
setNodes((nodes) =>
|
||||||
setNodes((nodes) =>
|
nodes.map((node) =>
|
||||||
nodes.map((node) =>
|
node.id === id
|
||||||
node.id === id
|
? {
|
||||||
? {
|
...node,
|
||||||
...node,
|
data: {
|
||||||
data: {
|
...node.data,
|
||||||
...node.data,
|
options: node.data.options.map((opt: ChoiceOption) =>
|
||||||
options: node.data.options.map((opt: ChoiceOption) =>
|
opt.id === optionId ? { ...opt, condition } : opt
|
||||||
opt.id === optionId ? { ...opt, condition } : opt
|
),
|
||||||
),
|
},
|
||||||
},
|
}
|
||||||
}
|
: node
|
||||||
: node
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
)
|
||||||
[id, setNodes]
|
setEditingConditionOptionId(null)
|
||||||
)
|
}, [id, setNodes])
|
||||||
|
|
||||||
|
const handleRemoveCondition = useCallback((optionId: string) => {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
options: node.data.options.map((opt: ChoiceOption) =>
|
||||||
|
opt.id === optionId ? { ...opt, condition: undefined } : opt
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setEditingConditionOptionId(null)
|
||||||
|
}, [id, setNodes])
|
||||||
|
|
||||||
const addOption = useCallback(() => {
|
const addOption = useCallback(() => {
|
||||||
if (data.options.length >= MAX_OPTIONS) return
|
if (data.options.length >= MAX_OPTIONS) return
|
||||||
|
|
@ -89,10 +109,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
options: [
|
options: [...node.data.options, { id: nanoid(), label: '' }],
|
||||||
...node.data.options,
|
|
||||||
{ id: nanoid(), label: '' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: node
|
: node
|
||||||
|
|
@ -110,9 +127,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
options: node.data.options.filter(
|
options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
|
||||||
(opt: ChoiceOption) => opt.id !== optionId
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: node
|
: node
|
||||||
|
|
@ -122,6 +137,8 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[id, data.options.length, setNodes]
|
[id, data.options.length, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Auxiliares ---
|
||||||
|
|
||||||
const editingOption = useMemo(() => {
|
const editingOption = useMemo(() => {
|
||||||
if (!editingConditionOptionId) return null
|
if (!editingConditionOptionId) return null
|
||||||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||||
|
|
@ -136,186 +153,102 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900">
|
||||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||||
<Handle
|
|
||||||
type="target"
|
<div className="mb-2 flex items-center justify-between">
|
||||||
position={Position.Top}
|
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
|
||||||
id="input"
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
|
||||||
Choice
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={data.prompt || ''}
|
|
||||||
onChange={updatePrompt}
|
|
||||||
placeholder="What do you choose?"
|
|
||||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.options.map((option, index) => (
|
|
||||||
<div key={option.id}>
|
|
||||||
<div className="relative flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={option.label}
|
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
|
||||||
placeholder={`Option ${index + 1}`}
|
|
||||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingConditionOptionId(option.id)}
|
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
|
||||||
option.condition?.variableId
|
|
||||||
? hasInvalidConditionReference(option)
|
|
||||||
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
|
||||||
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
|
||||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(option.id)}
|
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
|
||||||
title="Remove option"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={`option-${index}`}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
style={{
|
|
||||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{option.condition?.variableId && (
|
|
||||||
<div className={`mt-0.5 ml-1 text-[10px] ${
|
|
||||||
hasInvalidConditionReference(option)
|
|
||||||
? 'text-orange-500 dark:text-orange-400'
|
|
||||||
: 'text-zinc-500 dark:text-zinc-400'
|
|
||||||
}`}>
|
|
||||||
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addOption}
|
|
||||||
disabled={data.options.length >= MAX_OPTIONS}
|
|
||||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
|
||||||
title="Add option"
|
|
||||||
>
|
|
||||||
+ Add Option
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={data.prompt || ''}
|
value={data.prompt || ''}
|
||||||
onChange={updatePrompt}
|
onChange={updatePrompt}
|
||||||
placeholder="What do you choose?"
|
placeholder="Dialogue prompt..."
|
||||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
className="mb-3 w-full rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{data.options.map((option, index) => (
|
{data.options.map((option, index) => (
|
||||||
<div key={option.id}>
|
<div key={option.id} className="group relative">
|
||||||
<div className="relative flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={option.label}
|
value={option.label}
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={`Option ${index + 1}`}
|
||||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
className="flex-1 rounded border border-zinc-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Botão de Condição */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingConditionOptionId(option.id)}
|
onClick={() => setEditingConditionOptionId(option.id)}
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
className={`p-1 rounded transition-colors ${
|
||||||
option.condition
|
option.condition
|
||||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
? hasInvalidConditionReference(option)
|
||||||
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
|
||||||
|
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||||
}`}
|
}`}
|
||||||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
|
||||||
>
|
>
|
||||||
{option.condition ? (
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
</svg>
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(option.id)}
|
onClick={() => removeOption(option.id)}
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
className="text-zinc-400 hover:text-red-500"
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
|
||||||
title="Remove option"
|
|
||||||
>
|
>
|
||||||
×
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={`option-${index}`}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
style={{
|
|
||||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visualização da Condição */}
|
||||||
{option.condition && (
|
{option.condition && (
|
||||||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
|
||||||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
|
||||||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
}`}>
|
||||||
</span>
|
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||||
|
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={option.id}
|
||||||
|
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
|
||||||
|
className="!bg-green-500"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={addOption}
|
onClick={addOption}
|
||||||
disabled={data.options.length >= MAX_OPTIONS}
|
disabled={data.options.length >= MAX_OPTIONS}
|
||||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||||
title="Add option"
|
|
||||||
>
|
>
|
||||||
+ Add Option
|
+ Add Option
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Modal de Edição de Condição */}
|
||||||
{editingOption && (
|
{editingOption && (
|
||||||
<OptionConditionEditor
|
<OptionConditionEditor
|
||||||
optionId={editingOption.id}
|
|
||||||
optionLabel={editingOption.label}
|
|
||||||
condition={editingOption.condition}
|
condition={editingOption.condition}
|
||||||
onSave={handleSaveCondition}
|
onChange={(cond) => {
|
||||||
onRemove={handleRemoveCondition}
|
if (cond) {
|
||||||
onCancel={() => setEditingConditionOptionId(null)}
|
handleSaveCondition(editingOption.id, cond)
|
||||||
|
} else {
|
||||||
|
handleRemoveCondition(editingOption.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setEditingConditionOptionId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -27,16 +27,14 @@ export class CRDTManager {
|
||||||
this.edgesMap = this.doc.getMap('edges')
|
this.edgesMap = this.doc.getMap('edges')
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
|
|
||||||
// Listen for remote Yjs document changes
|
// Schedule persistence on local Yjs document changes
|
||||||
this.nodesMap.observe(() => {
|
this.nodesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote) return
|
||||||
this.notifyNodesChange()
|
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.edgesMap.observe(() => {
|
this.edgesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote) return
|
||||||
this.notifyEdgesChange()
|
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
-- Migration: Fix infinite recursion in RLS policies
|
||||||
|
-- Problem: Self-referencing policies and circular dependencies between
|
||||||
|
-- projects <-> project_collaborators cause infinite recursion.
|
||||||
|
-- Solution: Use SECURITY DEFINER functions to bypass RLS for permission checks.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- HELPER FUNCTIONS (SECURITY DEFINER bypasses RLS)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Check if the current user is an admin
|
||||||
|
CREATE OR REPLACE FUNCTION is_admin()
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND is_admin = true
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is the owner of a project
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_owner(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM projects
|
||||||
|
WHERE id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is a collaborator on a project (any role)
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_collaborator(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM project_collaborators
|
||||||
|
WHERE project_id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is an editor or owner collaborator on a project
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_editor(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM project_collaborators
|
||||||
|
WHERE project_id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
AND role IN ('owner', 'editor')
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROFILES POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the problematic admin policy (self-references profiles table)
|
||||||
|
DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles;
|
||||||
|
|
||||||
|
-- Recreate using the helper function
|
||||||
|
CREATE POLICY "Admins can view all profiles"
|
||||||
|
ON profiles
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_admin());
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROJECT_COLLABORATORS POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the problematic SELECT policy (self-references project_collaborators)
|
||||||
|
DROP POLICY IF EXISTS "Users can view collaborators for their projects" ON project_collaborators;
|
||||||
|
|
||||||
|
-- Recreate without self-reference: user is either the collaborator row's user,
|
||||||
|
-- the project owner, or already a collaborator on that project
|
||||||
|
CREATE POLICY "Users can view collaborators for their projects"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
auth.uid() = user_id
|
||||||
|
OR is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Drop and recreate INSERT/UPDATE/DELETE policies to use helper functions
|
||||||
|
DROP POLICY IF EXISTS "Owners can add collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can add collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (is_project_owner(project_id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Owners can update collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can update collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_project_owner(project_id))
|
||||||
|
WITH CHECK (is_project_owner(project_id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Owners can remove collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can remove collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR DELETE
|
||||||
|
USING (is_project_owner(project_id));
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX COLLABORATION_SESSIONS POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the SELECT policy that cross-references both projects and project_collaborators
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view sessions" ON collaboration_sessions;
|
||||||
|
|
||||||
|
CREATE POLICY "Collaborators can view sessions"
|
||||||
|
ON collaboration_sessions
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX AUDIT_TRAIL POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view audit trail" ON audit_trail;
|
||||||
|
CREATE POLICY "Collaborators can view audit trail"
|
||||||
|
ON audit_trail
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can write audit entries" ON audit_trail;
|
||||||
|
CREATE POLICY "Collaborators can write audit entries"
|
||||||
|
ON audit_trail
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
auth.uid() = user_id
|
||||||
|
AND (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_editor(project_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROJECTS POLICIES (collaborator access)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the policies that query project_collaborators (creating circular deps)
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view shared projects" ON projects;
|
||||||
|
CREATE POLICY "Collaborators can view shared projects"
|
||||||
|
ON projects
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_project_collaborator(id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can update shared projects" ON projects;
|
||||||
|
CREATE POLICY "Collaborators can update shared projects"
|
||||||
|
ON projects
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_project_editor(id))
|
||||||
|
WITH CHECK (is_project_editor(id));
|
||||||
Loading…
Reference in New Issue