incognitolm
adf
72aa761
Raw
History Blame Contribute Delete
9.1 kB
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>
);
}