Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useCallback } from 'react'; | |
| import { X, Search, UserPlus, Shield, Trash2, Check } from 'lucide-react'; | |
| import { api } from '../../api/client'; | |
| import { useCollaborationStore } from '../../store/collaborationStore'; | |
| interface ShareModalProps { | |
| projectId: string; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| } | |
| interface UserResult { | |
| id: string; | |
| username: string; | |
| } | |
| interface CollaboratorResult { | |
| id: string; | |
| username: string; | |
| permission: string; | |
| created_at: number; | |
| } | |
| export default function ShareModal({ projectId, isOpen, onClose }: ShareModalProps) { | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [searchResults, setSearchResults] = useState<UserResult[]>([]); | |
| const [searching, setSearching] = useState(false); | |
| const [collaborators, setCollaborators] = useState<CollaboratorResult[]>([]); | |
| const [owner, setOwner] = useState<{ id: string; username: string } | null>(null); | |
| const [addingUser, setAddingUser] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const connected = useCollaborationStore((s) => s.connected); | |
| const fetchCollaborators = useCallback(async () => { | |
| try { | |
| const res = await api.request<{ owner: any; collaborators: CollaboratorResult[] }>('GET', `/projects/${projectId}/collaborators`); | |
| setOwner(res.owner); | |
| setCollaborators(res.collaborators); | |
| if (res.owner) { | |
| useCollaborationStore.getState().setOwner(res.owner); | |
| } | |
| } catch {} | |
| }, [projectId]); | |
| useEffect(() => { | |
| if (isOpen) { | |
| fetchCollaborators(); | |
| setSearchQuery(''); | |
| setSearchResults([]); | |
| setError(null); | |
| } | |
| }, [isOpen, fetchCollaborators]); | |
| useEffect(() => { | |
| if (searchQuery.length < 2) { | |
| setSearchResults([]); | |
| return; | |
| } | |
| const timer = setTimeout(async () => { | |
| setSearching(true); | |
| try { | |
| const res = await api.request<{ users: UserResult[] }>('GET', `/projects/collaborators/search?username=${encodeURIComponent(searchQuery)}`); | |
| setSearchResults(res.users); | |
| } catch { | |
| setSearchResults([]); | |
| } finally { | |
| setSearching(false); | |
| } | |
| }, 300); | |
| return () => clearTimeout(timer); | |
| }, [searchQuery]); | |
| const handleAddCollaborator = async (username: string) => { | |
| setAddingUser(username); | |
| setError(null); | |
| try { | |
| await api.request('POST', `/projects/${projectId}/collaborators`, { username, permission: 'edit' }); | |
| await fetchCollaborators(); | |
| setSearchQuery(''); | |
| setSearchResults([]); | |
| } catch (err: any) { | |
| setError(err.message || 'Failed to add collaborator'); | |
| } finally { | |
| setAddingUser(null); | |
| } | |
| }; | |
| const handleUpdatePermission = async (userId: string, permission: string) => { | |
| try { | |
| await api.request('PUT', `/projects/${projectId}/collaborators/${userId}`, { permission }); | |
| await fetchCollaborators(); | |
| } catch {} | |
| }; | |
| const handleRemoveCollaborator = async (userId: string) => { | |
| try { | |
| await api.request('DELETE', `/projects/${projectId}/collaborators/${userId}`); | |
| await fetchCollaborators(); | |
| } catch {} | |
| }; | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-surface-900 border border-surface-700 rounded-xl w-full max-w-lg max-h-[80vh] flex flex-col"> | |
| <div className="flex items-center justify-between px-5 py-4 border-b border-surface-700"> | |
| <div className="flex items-center gap-2"> | |
| <Shield className="w-4 h-4 text-primary-400" /> | |
| <h2 className="text-sm font-semibold text-white">Share Project</h2> | |
| {connected && ( | |
| <span className="text-xs text-green-400 bg-green-400/10 px-1.5 py-0.5 rounded-full"> | |
| Live | |
| </span> | |
| )} | |
| </div> | |
| <button onClick={onClose} className="text-surface-400 hover:text-white p-1"> | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <div className="p-4 border-b border-surface-700"> | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" /> | |
| <input | |
| type="text" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| placeholder="Search users by username..." | |
| className="w-full bg-surface-800 border border-surface-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-surface-500 focus:outline-none focus:border-primary-500" | |
| /> | |
| {searching && ( | |
| <div className="absolute right-3 top-1/2 -translate-y-1/2"> | |
| <div className="animate-spin rounded-full h-3 w-3 border border-primary-500 border-t-transparent" /> | |
| </div> | |
| )} | |
| </div> | |
| {searchResults.length > 0 && ( | |
| <div className="mt-2 bg-surface-800 border border-surface-700 rounded-lg overflow-hidden"> | |
| {searchResults.map((user) => ( | |
| <button | |
| key={user.id} | |
| onClick={() => handleAddCollaborator(user.username)} | |
| disabled={addingUser === user.username} | |
| className="w-full flex items-center justify-between px-3 py-2 hover:bg-surface-700 text-sm disabled:opacity-50" | |
| > | |
| <span className="text-white">{user.username}</span> | |
| <span className="flex items-center gap-1 text-primary-400 text-xs"> | |
| {addingUser === user.username ? ( | |
| <> | |
| <div className="animate-spin rounded-full h-3 w-3 border border-primary-500 border-t-transparent" /> | |
| Adding... | |
| </> | |
| ) : ( | |
| <> | |
| <UserPlus className="w-3.5 h-3.5" /> | |
| Add | |
| </> | |
| )} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {error && ( | |
| <div className="px-4 py-2 bg-red-500/10 text-red-400 text-xs">{error}</div> | |
| )} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-2"> | |
| {owner && ( | |
| <div className="flex items-center justify-between px-3 py-2 bg-surface-800 rounded-lg"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-7 h-7 rounded-full bg-amber-500/20 flex items-center justify-center"> | |
| <span className="text-xs font-bold text-amber-400"> | |
| {owner.username.charAt(0).toUpperCase()} | |
| </span> | |
| </div> | |
| <div> | |
| <span className="text-sm text-white font-medium">{owner.username}</span> | |
| <span className="text-xs text-surface-400 ml-2">Owner</span> | |
| </div> | |
| </div> | |
| <span className="text-xs bg-amber-500/10 text-amber-400 px-2 py-0.5 rounded-full"> | |
| admin | |
| </span> | |
| </div> | |
| )} | |
| {collaborators.map((collab) => ( | |
| <div | |
| key={collab.id} | |
| className="flex items-center justify-between px-3 py-2 bg-surface-800 rounded-lg" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="w-7 h-7 rounded-full bg-primary-500/20 flex items-center justify-center"> | |
| <span className="text-xs font-bold text-primary-400"> | |
| {collab.username.charAt(0).toUpperCase()} | |
| </span> | |
| </div> | |
| <span className="text-sm text-white">{collab.username}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <select | |
| value={collab.permission} | |
| onChange={(e) => handleUpdatePermission(collab.id, e.target.value)} | |
| className="bg-surface-700 border border-surface-600 rounded text-xs text-white px-2 py-1 focus:outline-none focus:border-primary-500" | |
| > | |
| <option value="view">view</option> | |
| <option value="edit">edit</option> | |
| <option value="admin">admin</option> | |
| </select> | |
| <button | |
| onClick={() => handleRemoveCollaborator(collab.id)} | |
| className="text-surface-400 hover:text-red-400 p-1" | |
| title="Remove collaborator" | |
| > | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| {!owner && collaborators.length === 0 && ( | |
| <p className="text-surface-500 text-xs text-center py-8"> | |
| No collaborators yet. Search for users to share this project. | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |