|
@@ -13,17 +13,20 @@ import { useDropzone } from 'react-dropzone';
|
|
|
|
|
|
|
|
async function safeCopy(text: string): Promise<void> {
|
|
async function safeCopy(text: string): Promise<void> {
|
|
|
if (typeof window === 'undefined') return;
|
|
if (typeof window === 'undefined') return;
|
|
|
- if (navigator.clipboard?.writeText) {
|
|
|
|
|
- try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
|
|
|
|
|
- } else {
|
|
|
|
|
- const el = document.createElement('textarea');
|
|
|
|
|
- el.value = text;
|
|
|
|
|
- el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
|
|
|
|
|
- document.body.appendChild(el);
|
|
|
|
|
- el.focus(); el.select();
|
|
|
|
|
- try { document.execCommand('copy'); } catch { /* ignore */ }
|
|
|
|
|
- document.body.removeChild(el);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const cb = navigator.clipboard;
|
|
|
|
|
+ if (cb && typeof cb.writeText === 'function') {
|
|
|
|
|
+ await cb.writeText(text);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const el = document.createElement('textarea');
|
|
|
|
|
+ el.value = text;
|
|
|
|
|
+ el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
|
|
|
|
|
+ document.body.appendChild(el);
|
|
|
|
|
+ el.focus(); el.select();
|
|
|
|
|
+ try { document.execCommand('copy'); } catch { /* ignore */ }
|
|
|
|
|
+ document.body.removeChild(el);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch { /* ignore */ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
@@ -165,6 +168,8 @@ export default function ProjectDetailPage() {
|
|
|
const [inviteError, setInviteError] = useState('');
|
|
const [inviteError, setInviteError] = useState('');
|
|
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
|
|
const [createdLink, setCreatedLink] = useState('');
|
|
const [createdLink, setCreatedLink] = useState('');
|
|
|
|
|
+ const [createdLinkEmail, setCreatedLinkEmail] = useState('');
|
|
|
|
|
+ const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
|
|
|
|
|
|
|
|
// Edit member role
|
|
// Edit member role
|
|
|
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
|
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
|
@@ -313,17 +318,20 @@ export default function ProjectDetailPage() {
|
|
|
setInviteError('');
|
|
setInviteError('');
|
|
|
setInviteSuccess('');
|
|
setInviteSuccess('');
|
|
|
setCreatedLink('');
|
|
setCreatedLink('');
|
|
|
|
|
+ setLinkCopiedAgain(false);
|
|
|
|
|
+ const email = inviteEmail.trim();
|
|
|
try {
|
|
try {
|
|
|
- const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
|
|
|
|
|
+ const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole);
|
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
|
setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
|
await safeCopy(inviteUrl);
|
|
await safeCopy(inviteUrl);
|
|
|
setCreatedLink(inviteUrl);
|
|
setCreatedLink(inviteUrl);
|
|
|
|
|
+ setCreatedLinkEmail(email);
|
|
|
setInviteEmail('');
|
|
setInviteEmail('');
|
|
|
} catch (err: any) {
|
|
} catch (err: any) {
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
|
|
if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
|
|
|
- setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
|
|
|
|
|
|
|
+ setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`);
|
|
|
} else {
|
|
} else {
|
|
|
setInviteError(msg || 'Failed to create invitation link');
|
|
setInviteError(msg || 'Failed to create invitation link');
|
|
|
}
|
|
}
|
|
@@ -1043,17 +1051,35 @@ export default function ProjectDetailPage() {
|
|
|
</form>
|
|
</form>
|
|
|
|
|
|
|
|
{createdLink && (
|
|
{createdLink && (
|
|
|
- <div className="rounded-lg p-3 animate-scale-in"
|
|
|
|
|
|
|
+ <div className="rounded-lg p-4 animate-scale-in"
|
|
|
style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
|
|
style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
|
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
|
- <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
|
|
+ <svg className="w-4 h-4 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
</svg>
|
|
</svg>
|
|
|
- <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
|
|
|
|
|
|
|
+ <span className="text-sm font-medium" style={{ color: '#86EFAC' }}>Invitation link created!</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={async () => {
|
|
|
|
|
+ await safeCopy(createdLink);
|
|
|
|
|
+ setLinkCopiedAgain(true);
|
|
|
|
|
+ setTimeout(() => setLinkCopiedAgain(false), 2000);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="ml-auto text-xs px-3 py-1 rounded-lg transition-all"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.06)', color: linkCopiedAgain ? '#86EFAC' : 'var(--text-muted)' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {linkCopiedAgain ? '✓ Copied' : 'Copy link'}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
|
|
|
|
|
|
|
+ <p className="text-[10px] mb-2" style={{ color: 'rgba(134,239,172,0.5)' }}>
|
|
|
|
|
+ Invite sent to <strong style={{ color: '#86EFAC' }}>{createdLinkEmail}</strong> as {inviteRole} · Link expires in 7 days
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs break-all font-mono" style={{ color: 'rgba(134,239,172,0.7)' }}>
|
|
|
{createdLink}
|
|
{createdLink}
|
|
|
</p>
|
|
</p>
|
|
|
|
|
+ <p className="text-[10px] mt-2" style={{ color: 'rgba(134,239,172,0.45)' }}>
|
|
|
|
|
+ Share this link with your colleague — they can use it to join the project directly.
|
|
|
|
|
+ </p>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|