|
@@ -40,6 +40,7 @@ export default function ProjectDetailPage() {
|
|
|
const [inviting, setInviting] = useState(false);
|
|
const [inviting, setInviting] = useState(false);
|
|
|
const [inviteError, setInviteError] = useState('');
|
|
const [inviteError, setInviteError] = useState('');
|
|
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
|
const [inviteSuccess, setInviteSuccess] = useState('');
|
|
|
|
|
+ const [createdLink, setCreatedLink] = useState('');
|
|
|
|
|
|
|
|
// Edit member role
|
|
// Edit member role
|
|
|
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
|
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
|
@@ -95,6 +96,7 @@ export default function ProjectDetailPage() {
|
|
|
setInviting(true);
|
|
setInviting(true);
|
|
|
setInviteError('');
|
|
setInviteError('');
|
|
|
setInviteSuccess('');
|
|
setInviteSuccess('');
|
|
|
|
|
+ setCreatedLink('');
|
|
|
try {
|
|
try {
|
|
|
const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
@@ -110,6 +112,28 @@ export default function ProjectDetailPage() {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // ── Create & copy link ─────────────────────────────────────────────────────
|
|
|
|
|
+ const handleCreateLink = async () => {
|
|
|
|
|
+ if (!token || !inviteEmail.trim()) return;
|
|
|
|
|
+ setInviting(true);
|
|
|
|
|
+ setInviteError('');
|
|
|
|
|
+ setInviteSuccess('');
|
|
|
|
|
+ setCreatedLink('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
|
|
|
+ const { invitations } = await invitationsApi.list(token, projectId);
|
|
|
|
|
+ setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
|
|
|
+ const fullUrl = `${window.location.origin}${inviteUrl}`;
|
|
|
|
|
+ await navigator.clipboard.writeText(fullUrl);
|
|
|
|
|
+ setCreatedLink(fullUrl);
|
|
|
|
|
+ setInviteEmail('');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setInviteError(err instanceof Error ? err.message : 'Failed to create link');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setInviting(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
// ── Change role ────────────────────────────────────────────────────────────
|
|
// ── Change role ────────────────────────────────────────────────────────────
|
|
|
const handleChangeRole = async (memberId: string) => {
|
|
const handleChangeRole = async (memberId: string) => {
|
|
|
if (!token || !editingRole) return;
|
|
if (!token || !editingRole) return;
|
|
@@ -419,45 +443,111 @@ export default function ProjectDetailPage() {
|
|
|
Invite someone
|
|
Invite someone
|
|
|
</h2>
|
|
</h2>
|
|
|
|
|
|
|
|
- <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
|
|
|
|
|
- <div className="flex-1 min-w-[180px]">
|
|
|
|
|
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
|
|
|
|
|
- <input
|
|
|
|
|
- type="email"
|
|
|
|
|
- className="input"
|
|
|
|
|
- value={inviteEmail}
|
|
|
|
|
- onChange={e => setInviteEmail(e.target.value)}
|
|
|
|
|
- placeholder="colleague@company.com"
|
|
|
|
|
- required
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
|
|
|
|
|
+ <div className="flex-1 min-w-[180px]">
|
|
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="email"
|
|
|
|
|
+ className="input"
|
|
|
|
|
+ value={inviteEmail}
|
|
|
|
|
+ onChange={e => setInviteEmail(e.target.value)}
|
|
|
|
|
+ placeholder="colleague@company.com"
|
|
|
|
|
+ required
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="w-36">
|
|
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
|
|
|
|
|
+ <select
|
|
|
|
|
+ className="input"
|
|
|
|
|
+ value={inviteRole}
|
|
|
|
|
+ onChange={e => setInviteRole(e.target.value)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {Object.entries(ROLE_LABELS).map(([value, label]) => (
|
|
|
|
|
+ <option key={value} value={value}>{label}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={inviting}
|
|
|
|
|
+ className="btn btn-primary btn-md"
|
|
|
|
|
+ >
|
|
|
|
|
+ {inviting ? 'Sending…' : 'Send invite'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </form>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Or separator */}
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
|
|
|
|
|
+ <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>or</span>
|
|
|
|
|
+ <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
|
|
|
</div>
|
|
</div>
|
|
|
- <div className="w-36">
|
|
|
|
|
- <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
|
|
|
|
|
- <select
|
|
|
|
|
- className="input"
|
|
|
|
|
- value={inviteRole}
|
|
|
|
|
- onChange={e => setInviteRole(e.target.value)}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Create & copy link */}
|
|
|
|
|
+ <div className="flex items-end gap-3 flex-wrap">
|
|
|
|
|
+ <div className="flex-1 min-w-[180px]">
|
|
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email for the link</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="email"
|
|
|
|
|
+ className="input"
|
|
|
|
|
+ value={inviteEmail}
|
|
|
|
|
+ onChange={e => setInviteEmail(e.target.value)}
|
|
|
|
|
+ placeholder="Paste their email here"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="w-36">
|
|
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
|
|
|
|
|
+ <select
|
|
|
|
|
+ className="input"
|
|
|
|
|
+ value={inviteRole}
|
|
|
|
|
+ onChange={e => setInviteRole(e.target.value)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {Object.entries(ROLE_LABELS).map(([value, label]) => (
|
|
|
|
|
+ <option key={value} value={value}>{label}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ disabled={inviting || !inviteEmail.trim()}
|
|
|
|
|
+ onClick={handleCreateLink}
|
|
|
|
|
+ className="btn btn-secondary btn-md"
|
|
|
>
|
|
>
|
|
|
- {Object.entries(ROLE_LABELS).map(([value, label]) => (
|
|
|
|
|
- <option key={value} value={value}>{label}</option>
|
|
|
|
|
- ))}
|
|
|
|
|
- </select>
|
|
|
|
|
|
|
+ {inviting ? 'Creating…' : (
|
|
|
|
|
+ <span className="flex items-center gap-1.5">
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Create & Copy Link
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button
|
|
|
|
|
- type="submit"
|
|
|
|
|
- disabled={inviting}
|
|
|
|
|
- className="btn btn-primary btn-md"
|
|
|
|
|
- >
|
|
|
|
|
- {inviting ? 'Sending…' : 'Send invite'}
|
|
|
|
|
- </button>
|
|
|
|
|
- </form>
|
|
|
|
|
-
|
|
|
|
|
- {inviteError && (
|
|
|
|
|
- <p className="text-xs mt-2" style={{ color: '#F87171' }}>{inviteError}</p>
|
|
|
|
|
- )}
|
|
|
|
|
- {inviteSuccess && (
|
|
|
|
|
- <p className="text-xs mt-2" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Created link feedback */}
|
|
|
|
|
+ {createdLink && (
|
|
|
|
|
+ <div className="rounded-lg p-3 animate-scale-in"
|
|
|
|
|
+ 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">
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 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" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>
|
|
|
|
|
+ {createdLink}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {inviteError && (
|
|
|
|
|
+ <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {inviteSuccess && (
|
|
|
|
|
+ <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|