225 lines
9.0 KiB
TypeScript
225 lines
9.0 KiB
TypeScript
'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<Collaborator[]>([])
|
|
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<string | null>(null)
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/50"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
|
Share Project
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Invite Form - only for owners */}
|
|
{isOwner && (
|
|
<form onSubmit={handleInvite} className="mb-6">
|
|
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
|
|
Invite collaborator by email
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="user@example.com"
|
|
disabled={inviting}
|
|
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
/>
|
|
<select
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
|
|
disabled={inviting}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50"
|
|
>
|
|
<option value="editor">Editor</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
<button
|
|
type="submit"
|
|
disabled={inviting || !email.trim()}
|
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
{inviting ? 'Inviting...' : 'Invite'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Messages */}
|
|
{error && (
|
|
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{successMessage && (
|
|
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
|
|
{successMessage}
|
|
</div>
|
|
)}
|
|
|
|
{/* Collaborators List */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
|
Collaborators
|
|
</h3>
|
|
{loading ? (
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">Loading...</p>
|
|
) : collaborators.length === 0 ? (
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
No collaborators yet. Invite someone to get started.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{collaborators.map((collab) => (
|
|
<div
|
|
key={collab.id}
|
|
className="flex items-center justify-between rounded-md border border-zinc-200 p-3 dark:border-zinc-700"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-zinc-900 truncate dark:text-zinc-50">
|
|
{collab.display_name || collab.email || 'Unknown user'}
|
|
</p>
|
|
{collab.display_name && collab.email && (
|
|
<p className="text-xs text-zinc-500 truncate dark:text-zinc-400">
|
|
{collab.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-3">
|
|
{isOwner ? (
|
|
<>
|
|
<select
|
|
value={collab.role}
|
|
onChange={(e) =>
|
|
handleRoleChange(collab.id, e.target.value as 'editor' | 'viewer')
|
|
}
|
|
className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs text-zinc-700 focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
|
|
>
|
|
<option value="editor">Editor</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
<button
|
|
onClick={() => handleRemove(collab.id)}
|
|
className="rounded p-1 text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
|
|
title="Remove collaborator"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<span className="rounded bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
|
|
{collab.role}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Close button */}
|
|
<div className="mt-6 flex justify-end">
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-md border border-zinc-300 bg-white px-4 py-2 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-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|