developing #10

Merged
GHMiranda merged 64 commits from developing into master 2026-01-25 00:37:11 +00:00
5 changed files with 221 additions and 2 deletions
Showing only changes of commit 2e313a0264 - Show all commits

View File

@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
@ -27,10 +27,12 @@ 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 { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
type FlowchartEditorProps = {
projectId: string
userId: string
initialData: FlowchartData
needsMigration?: boolean
}
@ -202,7 +204,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
@ -232,6 +234,22 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const realtimeRef = useRef<RealtimeConnection | null>(null)
// Connect to Supabase Realtime channel on mount, disconnect on unmount
useEffect(() => {
const connection = new RealtimeConnection(projectId, userId, {
onConnectionStateChange: setConnectionState,
})
realtimeRef.current = connection
connection.connect()
return () => {
connection.disconnect()
realtimeRef.current = null
}
}, [projectId, userId])
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
@ -511,6 +529,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
onExport={handleExport}
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
connectionState={connectionState}
/>
<div className="flex-1">
<ReactFlow

View File

@ -73,6 +73,7 @@ export default async function EditorPage({ params }: PageProps) {
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
userId={user.id}
initialData={flowchartData}
needsMigration={needsMigration}
/>

View File

@ -1,5 +1,7 @@
'use client'
import type { ConnectionState } from '@/lib/collaboration/realtime'
type ToolbarProps = {
onAddDialogue: () => void
onAddChoice: () => void
@ -8,6 +10,21 @@ type ToolbarProps = {
onExport: () => void
onImport: () => void
onProjectSettings: () => void
connectionState?: ConnectionState
}
const connectionLabel: Record<ConnectionState, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
}
const connectionColor: Record<ConnectionState, string> = {
connecting: 'bg-yellow-400',
connected: 'bg-green-400',
disconnected: 'bg-red-400',
reconnecting: 'bg-yellow-400',
}
export default function Toolbar({
@ -18,6 +35,7 @@ export default function Toolbar({
onExport,
onImport,
onProjectSettings,
connectionState,
}: ToolbarProps) {
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">
@ -46,6 +64,14 @@ export default function Toolbar({
</div>
<div className="flex items-center gap-2">
{connectionState && (
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{connectionLabel[connectionState]}
</span>
</div>
)}
<button
onClick={onProjectSettings}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 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"

View File

@ -0,0 +1,167 @@
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (presences: Record<string, unknown[]>) => void
}
const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
export class RealtimeConnection {
private channel: RealtimeChannel | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
private projectId: string
private userId: string
private callbacks: RealtimeCallbacks
private isDestroyed = false
private supabase = createClient()
constructor(projectId: string, userId: string, callbacks: RealtimeCallbacks) {
this.projectId = projectId
this.userId = userId
this.callbacks = callbacks
}
async connect(): Promise<void> {
if (this.isDestroyed) return
this.callbacks.onConnectionStateChange('connecting')
this.channel = this.supabase.channel(`project:${this.projectId}`, {
config: { presence: { key: this.userId } },
})
this.channel
.on('presence', { event: 'sync' }, () => {
if (this.channel) {
const state = this.channel.presenceState()
this.callbacks.onPresenceSync?.(state)
}
})
.subscribe(async (status) => {
if (this.isDestroyed) return
if (status === 'SUBSCRIBED') {
this.reconnectAttempts = 0
this.callbacks.onConnectionStateChange('connected')
this.startHeartbeat()
await this.createSession()
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else if (status === 'CLOSED') {
this.callbacks.onConnectionStateChange('disconnected')
}
})
}
async disconnect(): Promise<void> {
this.isDestroyed = true
this.stopHeartbeat()
this.clearReconnectTimer()
if (this.channel) {
await this.deleteSession()
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.callbacks.onConnectionStateChange('disconnected')
}
getChannel(): RealtimeChannel | null {
return this.channel
}
private async createSession(): Promise<void> {
try {
await this.supabase.from('collaboration_sessions').upsert(
{
project_id: this.projectId,
user_id: this.userId,
cursor_position: null,
selected_node_id: null,
connected_at: new Date().toISOString(),
last_heartbeat: new Date().toISOString(),
},
{ onConflict: 'project_id,user_id' }
)
} catch {
// Fire-and-forget: session creation failure shouldn't block editing
}
}
private async deleteSession(): Promise<void> {
try {
await this.supabase
.from('collaboration_sessions')
.delete()
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
} catch {
// Fire-and-forget: cleanup failure is non-critical
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed) {
this.stopHeartbeat()
return
}
try {
await this.supabase
.from('collaboration_sessions')
.update({ last_heartbeat: new Date().toISOString() })
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
} catch {
// Heartbeat failure is non-critical
}
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private scheduleReconnect(): void {
if (this.isDestroyed) return
this.clearReconnectTimer()
const delay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts),
RECONNECT_MAX_DELAY_MS
)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(async () => {
if (this.isDestroyed) return
this.callbacks.onConnectionStateChange('reconnecting')
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
await this.connect()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}

View File

@ -0,0 +1,6 @@
-- Add unique constraint on collaboration_sessions for (project_id, user_id)
-- This ensures a user can only have one active session per project,
-- and allows upsert operations for session management.
ALTER TABLE collaboration_sessions
ADD CONSTRAINT collaboration_sessions_project_user_unique
UNIQUE (project_id, user_id);