developing #10

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

View File

@ -27,13 +27,14 @@ 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 Toast from '@/components/Toast'
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime' import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
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'
type FlowchartEditorProps = { type FlowchartEditorProps = {
projectId: string projectId: string
userId: string userId: string
userDisplayName: string
isOwner: boolean isOwner: boolean
initialData: FlowchartData initialData: FlowchartData
needsMigration?: boolean needsMigration?: boolean
@ -206,7 +207,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
} }
// Inner component that uses useReactFlow hook // Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMigration }: FlowchartEditorProps) { function FlowchartEditorInner({ projectId, 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(
() => ({ () => ({
@ -238,12 +239,14 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null) const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set()) const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected') const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const realtimeRef = useRef<RealtimeConnection | null>(null) const realtimeRef = useRef<RealtimeConnection | null>(null)
// Connect to Supabase Realtime channel on mount, disconnect on unmount // Connect to Supabase Realtime channel on mount, disconnect on unmount
useEffect(() => { useEffect(() => {
const connection = new RealtimeConnection(projectId, userId, { const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
onConnectionStateChange: setConnectionState, onConnectionStateChange: setConnectionState,
onPresenceSync: setPresenceUsers,
}) })
realtimeRef.current = connection realtimeRef.current = connection
connection.connect() connection.connect()
@ -252,7 +255,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
connection.disconnect() connection.disconnect()
realtimeRef.current = null realtimeRef.current = null
} }
}, [projectId, userId]) }, [projectId, userId, userDisplayName])
const handleAddCharacter = useCallback( const handleAddCharacter = useCallback(
(name: string, color: string): string => { (name: string, color: string): string => {
@ -534,6 +537,7 @@ function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMi
onProjectSettings={() => setShowSettings(true)} onProjectSettings={() => setShowSettings(true)}
onShare={() => setShowShare(true)} onShare={() => setShowShare(true)}
connectionState={connectionState} connectionState={connectionState}
presenceUsers={presenceUsers}
/> />
<div className="flex-1"> <div className="flex-1">
<ReactFlow <ReactFlow

View File

@ -20,6 +20,15 @@ export default async function EditorPage({ params }: PageProps) {
return null return null
} }
// Fetch user's display name for presence
const { data: profile } = await supabase
.from('profiles')
.select('display_name')
.eq('id', user.id)
.single()
const userDisplayName = profile?.display_name || user.email || 'Anonymous'
// Try to load as owner first // Try to load as owner first
const { data: ownedProject } = await supabase const { data: ownedProject } = await supabase
.from('projects') .from('projects')
@ -102,6 +111,7 @@ export default async function EditorPage({ params }: PageProps) {
<FlowchartEditor <FlowchartEditor
projectId={project.id} projectId={project.id}
userId={user.id} userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner} isOwner={isOwner}
initialData={flowchartData} initialData={flowchartData}
needsMigration={needsMigration} needsMigration={needsMigration}

View File

@ -0,0 +1,65 @@
'use client'
import type { PresenceUser } from '@/lib/collaboration/realtime'
type PresenceAvatarsProps = {
users: PresenceUser[]
}
const MAX_VISIBLE = 5
// Generate a consistent color from a user ID hash
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
const colors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
return colors[Math.abs(hash) % colors.length]
}
// Get initials from a display name (first letter of first two words)
function getInitials(displayName: string): string {
const parts = displayName.trim().split(/\s+/)
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return displayName.slice(0, 2).toUpperCase()
}
export default function PresenceAvatars({ users }: PresenceAvatarsProps) {
if (users.length === 0) return null
const visibleUsers = users.slice(0, MAX_VISIBLE)
const overflow = users.length - MAX_VISIBLE
return (
<div className="flex items-center -space-x-2">
{visibleUsers.map((user) => {
const color = getUserColor(user.userId)
return (
<div
key={user.userId}
title={user.displayName}
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold text-white dark:border-zinc-800"
style={{ backgroundColor: color }}
>
{getInitials(user.displayName)}
</div>
)
})}
{overflow > 0 && (
<div
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-zinc-400 text-[10px] font-semibold text-white dark:border-zinc-800 dark:bg-zinc-500"
title={`${overflow} more collaborator${overflow > 1 ? 's' : ''}`}
>
+{overflow}
</div>
)}
</div>
)
}

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import type { ConnectionState } from '@/lib/collaboration/realtime' import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
import PresenceAvatars from './PresenceAvatars'
type ToolbarProps = { type ToolbarProps = {
onAddDialogue: () => void onAddDialogue: () => void
@ -12,6 +13,7 @@ type ToolbarProps = {
onProjectSettings: () => void onProjectSettings: () => void
onShare: () => void onShare: () => void
connectionState?: ConnectionState connectionState?: ConnectionState
presenceUsers?: PresenceUser[]
} }
const connectionLabel: Record<ConnectionState, string> = { const connectionLabel: Record<ConnectionState, string> = {
@ -38,6 +40,7 @@ export default function Toolbar({
onProjectSettings, onProjectSettings,
onShare, onShare,
connectionState, connectionState,
presenceUsers,
}: ToolbarProps) { }: ToolbarProps) {
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">
@ -66,6 +69,11 @@ export default function Toolbar({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{presenceUsers && presenceUsers.length > 0 && (
<div className="mr-2">
<PresenceAvatars users={presenceUsers} />
</div>
)}
{connectionState && ( {connectionState && (
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[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={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />

View File

@ -3,9 +3,14 @@ import type { RealtimeChannel } from '@supabase/supabase-js'
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
export type PresenceUser = {
userId: string
displayName: string
}
type RealtimeCallbacks = { type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (presences: Record<string, unknown[]>) => void onPresenceSync?: (users: PresenceUser[]) => void
} }
const HEARTBEAT_INTERVAL_MS = 30_000 const HEARTBEAT_INTERVAL_MS = 30_000
@ -19,13 +24,15 @@ export class RealtimeConnection {
private reconnectAttempts = 0 private reconnectAttempts = 0
private projectId: string private projectId: string
private userId: string private userId: string
private displayName: string
private callbacks: RealtimeCallbacks private callbacks: RealtimeCallbacks
private isDestroyed = false private isDestroyed = false
private supabase = createClient() private supabase = createClient()
constructor(projectId: string, userId: string, callbacks: RealtimeCallbacks) { constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
this.projectId = projectId this.projectId = projectId
this.userId = userId this.userId = userId
this.displayName = displayName
this.callbacks = callbacks this.callbacks = callbacks
} }
@ -41,7 +48,18 @@ export class RealtimeConnection {
.on('presence', { event: 'sync' }, () => { .on('presence', { event: 'sync' }, () => {
if (this.channel) { if (this.channel) {
const state = this.channel.presenceState() const state = this.channel.presenceState()
this.callbacks.onPresenceSync?.(state) const users: PresenceUser[] = []
for (const [key, presences] of Object.entries(state)) {
if (key === this.userId) continue // Exclude own presence
const presence = presences[0] as { userId?: string; displayName?: string } | undefined
if (presence?.userId) {
users.push({
userId: presence.userId,
displayName: presence.displayName || 'Anonymous',
})
}
}
this.callbacks.onPresenceSync?.(users)
} }
}) })
.subscribe(async (status) => { .subscribe(async (status) => {
@ -52,6 +70,11 @@ export class RealtimeConnection {
this.callbacks.onConnectionStateChange('connected') this.callbacks.onConnectionStateChange('connected')
this.startHeartbeat() this.startHeartbeat()
await this.createSession() await this.createSession()
// Track presence with user info
await this.channel?.track({
userId: this.userId,
displayName: this.displayName,
})
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
this.callbacks.onConnectionStateChange('reconnecting') this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect() this.scheduleReconnect()