diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 8b153c5..89bceb4 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import NewProjectButton from '@/components/NewProjectButton'
import ProjectList from '@/components/ProjectList'
+import ProjectCard from '@/components/ProjectCard'
export default async function DashboardPage() {
const supabase = await createClient()
@@ -19,6 +20,21 @@ export default async function DashboardPage() {
.eq('user_id', user.id)
.order('updated_at', { ascending: false })
+ // Fetch shared projects (projects where this user is a collaborator)
+ const { data: collaborations } = await supabase
+ .from('project_collaborators')
+ .select('role, projects(id, name, updated_at)')
+ .eq('user_id', user.id)
+
+ const sharedProjects = (collaborations || [])
+ .filter((c) => c.projects)
+ .map((c) => ({
+ ...(c.projects as unknown as { id: string; name: string; updated_at: string }),
+ shared: true,
+ role: c.role,
+ }))
+ .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
+
if (error) {
return (
@@ -44,6 +60,26 @@ export default async function DashboardPage() {
+
+ {sharedProjects.length > 0 && (
+
+
+ Shared with me
+
+
+ {sharedProjects.map((project) => (
+
+ ))}
+
+
+ )}
)
}
diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx
index 2ba1cab..44b4c68 100644
--- a/src/app/editor/[projectId]/FlowchartEditor.tsx
+++ b/src/app/editor/[projectId]/FlowchartEditor.tsx
@@ -28,11 +28,13 @@ import ExportValidationModal, { type ValidationIssue } from '@/components/editor
import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
+import ShareModal from '@/components/editor/ShareModal'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
type FlowchartEditorProps = {
projectId: string
userId: string
+ isOwner: boolean
initialData: FlowchartData
needsMigration?: boolean
}
@@ -204,7 +206,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
}
// Inner component that uses useReactFlow hook
-function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }: FlowchartEditorProps) {
+function FlowchartEditorInner({ projectId, userId, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
@@ -230,6 +232,7 @@ function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }
const [characters, setCharacters] = useState(migratedData.characters)
const [variables, setVariables] = useState(migratedData.variables)
const [showSettings, setShowSettings] = useState(false)
+ const [showShare, setShowShare] = useState(false)
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
const [toastMessage, setToastMessage] = useState(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState(null)
@@ -529,6 +532,7 @@ function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }
onExport={handleExport}
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
+ onShare={() => setShowShare(true)}
connectionState={connectionState}
/>
@@ -560,6 +564,13 @@ function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }
getVariableUsageCount={getVariableUsageCount}
/>
)}
+ {showShare && (
+
setShowShare(false)}
+ />
+ )}
{selectedEdge && (
{
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) {
+ return { success: false, error: 'Not authenticated' }
+ }
+
+ // Verify user owns the project
+ const { data: project } = await supabase
+ .from('projects')
+ .select('id, user_id')
+ .eq('id', projectId)
+ .single()
+
+ if (!project) {
+ return { success: false, error: 'Project not found' }
+ }
+
+ const isOwner = project.user_id === user.id
+
+ // Check if user is a collaborator
+ if (!isOwner) {
+ const { data: collab } = await supabase
+ .from('project_collaborators')
+ .select('id')
+ .eq('project_id', projectId)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!collab) {
+ return { success: false, error: 'Access denied' }
+ }
+ }
+
+ // Fetch collaborators with profile info
+ const { data: collaborators, error } = await supabase
+ .from('project_collaborators')
+ .select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)')
+ .eq('project_id', projectId)
+
+ if (error) {
+ return { success: false, error: error.message }
+ }
+
+ const result: Collaborator[] = (collaborators || []).map((c) => {
+ const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null
+ return {
+ id: c.id,
+ user_id: c.user_id,
+ role: c.role as 'owner' | 'editor' | 'viewer',
+ invited_at: c.invited_at,
+ accepted_at: c.accepted_at,
+ display_name: profile?.display_name || null,
+ email: profile?.email || null,
+ }
+ })
+
+ return { success: true, data: result }
+}
+
+export async function inviteCollaborator(
+ projectId: string,
+ email: string,
+ role: 'editor' | 'viewer'
+): Promise<{ success: boolean; error?: string }> {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) {
+ return { success: false, error: 'Not authenticated' }
+ }
+
+ // Verify user owns the project
+ const { data: project } = await supabase
+ .from('projects')
+ .select('id, user_id')
+ .eq('id', projectId)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!project) {
+ return { success: false, error: 'Only the project owner can invite collaborators' }
+ }
+
+ // Find the user by email in profiles
+ const { data: targetProfile } = await supabase
+ .from('profiles')
+ .select('id, email')
+ .eq('email', email)
+ .single()
+
+ if (!targetProfile) {
+ return { success: false, error: 'No user found with that email address' }
+ }
+
+ // Cannot invite yourself
+ if (targetProfile.id === user.id) {
+ return { success: false, error: 'You cannot invite yourself' }
+ }
+
+ // Check if already a collaborator
+ const { data: existing } = await supabase
+ .from('project_collaborators')
+ .select('id')
+ .eq('project_id', projectId)
+ .eq('user_id', targetProfile.id)
+ .single()
+
+ if (existing) {
+ return { success: false, error: 'This user is already a collaborator' }
+ }
+
+ // Insert collaborator
+ const { error: insertError } = await supabase
+ .from('project_collaborators')
+ .insert({
+ project_id: projectId,
+ user_id: targetProfile.id,
+ role,
+ invited_at: new Date().toISOString(),
+ accepted_at: new Date().toISOString(), // Auto-accept for now
+ })
+
+ if (insertError) {
+ return { success: false, error: insertError.message }
+ }
+
+ return { success: true }
+}
+
+export async function updateCollaboratorRole(
+ projectId: string,
+ collaboratorId: string,
+ role: 'editor' | 'viewer'
+): Promise<{ success: boolean; error?: string }> {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) {
+ return { success: false, error: 'Not authenticated' }
+ }
+
+ // Verify user owns the project
+ const { data: project } = await supabase
+ .from('projects')
+ .select('id, user_id')
+ .eq('id', projectId)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!project) {
+ return { success: false, error: 'Only the project owner can change roles' }
+ }
+
+ const { error } = await supabase
+ .from('project_collaborators')
+ .update({ role })
+ .eq('id', collaboratorId)
+ .eq('project_id', projectId)
+
+ if (error) {
+ return { success: false, error: error.message }
+ }
+
+ return { success: true }
+}
+
+export async function removeCollaborator(
+ projectId: string,
+ collaboratorId: string
+): Promise<{ success: boolean; error?: string }> {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) {
+ return { success: false, error: 'Not authenticated' }
+ }
+
+ // Verify user owns the project
+ const { data: project } = await supabase
+ .from('projects')
+ .select('id, user_id')
+ .eq('id', projectId)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!project) {
+ return { success: false, error: 'Only the project owner can remove collaborators' }
+ }
+
+ const { error } = await supabase
+ .from('project_collaborators')
+ .delete()
+ .eq('id', collaboratorId)
+ .eq('project_id', projectId)
+
+ if (error) {
+ return { success: false, error: error.message }
+ }
+
+ return { success: true }
+}
diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx
index ce16cb0..5bc3ec7 100644
--- a/src/app/editor/[projectId]/page.tsx
+++ b/src/app/editor/[projectId]/page.tsx
@@ -20,15 +20,43 @@ export default async function EditorPage({ params }: PageProps) {
return null
}
- const { data: project, error } = await supabase
+ // Try to load as owner first
+ const { data: ownedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
- if (error || !project) {
- notFound()
+ let project = ownedProject
+ let isOwner = true
+
+ // If not the owner, check if the user is a collaborator
+ if (!project) {
+ // RLS on projects allows collaborators to SELECT shared projects
+ const { data: collab } = await supabase
+ .from('project_collaborators')
+ .select('id, role')
+ .eq('project_id', projectId)
+ .eq('user_id', user.id)
+ .single()
+
+ if (!collab) {
+ notFound()
+ }
+
+ const { data: sharedProject } = await supabase
+ .from('projects')
+ .select('id, name, flowchart_data')
+ .eq('id', projectId)
+ .single()
+
+ if (!sharedProject) {
+ notFound()
+ }
+
+ project = sharedProject
+ isOwner = false
}
const rawData = project.flowchart_data || {}
@@ -74,6 +102,7 @@ export default async function EditorPage({ params }: PageProps) {
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx
index cd47ec0..707aad2 100644
--- a/src/components/ProjectCard.tsx
+++ b/src/components/ProjectCard.tsx
@@ -8,8 +8,10 @@ interface ProjectCardProps {
id: string
name: string
updatedAt: string
- onDelete: (id: string) => void
- onRename: (id: string, newName: string) => void
+ onDelete?: (id: string) => void
+ onRename?: (id: string, newName: string) => void
+ shared?: boolean
+ sharedRole?: string
}
function formatDate(dateString: string): string {
@@ -29,6 +31,8 @@ export default function ProjectCard({
updatedAt,
onDelete,
onRename,
+ shared,
+ sharedRole,
}: ProjectCardProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -62,7 +66,7 @@ export default function ProjectCard({
setIsDeleting(false)
setShowDeleteDialog(false)
- onDelete(id)
+ onDelete?.(id)
}
const handleCancelDelete = () => {
@@ -106,7 +110,7 @@ export default function ProjectCard({
setIsRenaming(false)
setShowRenameDialog(false)
- onRename(id, newName.trim())
+ onRename?.(id, newName.trim())
}
const handleCancelRename = () => {
@@ -122,46 +126,55 @@ export default function ProjectCard({
onClick={handleCardClick}
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
>
-
-
-
+
-
+
+
+
+ )}
+ {shared && (
+
+
+ {sharedRole === 'editor' ? 'Editor' : 'Viewer'}
+
+
+ )}
{name}
diff --git a/src/components/editor/ShareModal.tsx b/src/components/editor/ShareModal.tsx
new file mode 100644
index 0000000..88f7e18
--- /dev/null
+++ b/src/components/editor/ShareModal.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import {
+ getCollaborators,
+ inviteCollaborator,
+ updateCollaboratorRole,
+ removeCollaborator,
+ type Collaborator,
+} from '@/app/editor/[projectId]/actions'
+
+type ShareModalProps = {
+ projectId: string
+ isOwner: boolean
+ onClose: () => void
+}
+
+export default function ShareModal({ projectId, isOwner, onClose }: ShareModalProps) {
+ const [collaborators, setCollaborators] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [email, setEmail] = useState('')
+ const [role, setRole] = useState<'editor' | 'viewer'>('editor')
+ const [inviting, setInviting] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMessage, setSuccessMessage] = useState(null)
+
+ const fetchCollaborators = useCallback(async () => {
+ const result = await getCollaborators(projectId)
+ if (result.success && result.data) {
+ setCollaborators(result.data)
+ }
+ setLoading(false)
+ }, [projectId])
+
+ useEffect(() => {
+ fetchCollaborators()
+ }, [fetchCollaborators])
+
+ const handleInvite = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!email.trim()) return
+
+ setInviting(true)
+ setError(null)
+ setSuccessMessage(null)
+
+ const result = await inviteCollaborator(projectId, email.trim(), role)
+ if (result.success) {
+ setEmail('')
+ setSuccessMessage('Collaborator invited successfully')
+ fetchCollaborators()
+ } else {
+ setError(result.error || 'Failed to invite collaborator')
+ }
+ setInviting(false)
+ }
+
+ const handleRoleChange = async (collaboratorId: string, newRole: 'editor' | 'viewer') => {
+ setError(null)
+ const result = await updateCollaboratorRole(projectId, collaboratorId, newRole)
+ if (result.success) {
+ setCollaborators((prev) =>
+ prev.map((c) => (c.id === collaboratorId ? { ...c, role: newRole } : c))
+ )
+ } else {
+ setError(result.error || 'Failed to update role')
+ }
+ }
+
+ const handleRemove = async (collaboratorId: string) => {
+ setError(null)
+ const result = await removeCollaborator(projectId, collaboratorId)
+ if (result.success) {
+ setCollaborators((prev) => prev.filter((c) => c.id !== collaboratorId))
+ } else {
+ setError(result.error || 'Failed to remove collaborator')
+ }
+ }
+
+ return (
+
+
+
+
+
+ Share Project
+
+
+
+
+ {/* Invite Form - only for owners */}
+ {isOwner && (
+
+ )}
+
+ {/* Messages */}
+ {error && (
+
+ {error}
+
+ )}
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+ {/* Collaborators List */}
+
+
+ Collaborators
+
+ {loading ? (
+
Loading...
+ ) : collaborators.length === 0 ? (
+
+ No collaborators yet. Invite someone to get started.
+
+ ) : (
+
+ {collaborators.map((collab) => (
+
+
+
+ {collab.display_name || collab.email || 'Unknown user'}
+
+ {collab.display_name && collab.email && (
+
+ {collab.email}
+
+ )}
+
+
+ {isOwner ? (
+ <>
+
+
+ >
+ ) : (
+
+ {collab.role}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {/* Close button */}
+
+
+
+
+
+ )
+}
diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx
index 3161530..f283ff4 100644
--- a/src/components/editor/Toolbar.tsx
+++ b/src/components/editor/Toolbar.tsx
@@ -10,6 +10,7 @@ type ToolbarProps = {
onExport: () => void
onImport: () => void
onProjectSettings: () => void
+ onShare: () => void
connectionState?: ConnectionState
}
@@ -35,6 +36,7 @@ export default function Toolbar({
onExport,
onImport,
onProjectSettings,
+ onShare,
connectionState,
}: ToolbarProps) {
return (
@@ -78,6 +80,12 @@ export default function Toolbar({
>
Project Settings
+