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 ( +
+ + ) +} 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 +