Ver Fonte

fix: quota auto-unit GB/MB, improve copy invite link, UI refinements

- openQuotaEdit: auto-select GB when quota >= 1GB to avoid 2048 MB display
- handleInvite: use safeCopy() fallback for clipboard API
- settings: UI improvements for workspace invite section
- project: refine member invite copy link button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kingkong há 1 mês atrás
pai
commit
57fcd7c7cd

+ 43 - 17
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -13,17 +13,20 @@ import { useDropzone } from 'react-dropzone';
 
 async function safeCopy(text: string): Promise<void> {
   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> = {
@@ -165,6 +168,8 @@ export default function ProjectDetailPage() {
   const [inviteError, setInviteError] = useState('');
   const [inviteSuccess, setInviteSuccess] = useState('');
   const [createdLink, setCreatedLink] = useState('');
+  const [createdLinkEmail, setCreatedLinkEmail] = useState('');
+  const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
 
   // Edit member role
   const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
@@ -313,17 +318,20 @@ export default function ProjectDetailPage() {
     setInviteError('');
     setInviteSuccess('');
     setCreatedLink('');
+    setLinkCopiedAgain(false);
+    const email = inviteEmail.trim();
     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);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
       await safeCopy(inviteUrl);
       setCreatedLink(inviteUrl);
+      setCreatedLinkEmail(email);
       setInviteEmail('');
     } catch (err: any) {
       const msg = err instanceof Error ? err.message : String(err);
       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 {
         setInviteError(msg || 'Failed to create invitation link');
       }
@@ -1043,17 +1051,35 @@ export default function ProjectDetailPage() {
                   </form>
 
                   {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)' }}>
                       <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" />
                         </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>
-                      <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}
                       </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>
                   )}
 

+ 116 - 1
src/app/(dashboard)/settings/page.tsx

@@ -2,9 +2,27 @@
 
 import { useState, useEffect } from 'react';
 import { useAuth } from '@/lib/auth-context';
-import { usersApi, settingsApi } from '@/lib/api';
+import { usersApi, settingsApi, invitationsApi } from '@/lib/api';
 import { ProfilePictureUpload } from '@/components/settings/ProfilePictureUpload';
 
+async function safeCopy(text: string): Promise<void> {
+  if (typeof window === 'undefined') return;
+  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 */ }
+}
+
 export default function SettingsPage() {
   const { user, token, updateUserData } = useAuth();
   const [name, setName] = useState(user?.name ?? '');
@@ -18,6 +36,13 @@ export default function SettingsPage() {
   const [registrationEnabled, setRegistrationEnabled] = useState(true);
   const [loadingReg, setLoadingReg] = useState(false);
 
+  // Workspace invite (admin only)
+  const [inviteEmail, setInviteEmail] = useState('');
+  const [inviting, setInviting] = useState(false);
+  const [inviteError, setInviteError] = useState('');
+  const [inviteLink, setInviteLink] = useState('');
+  const [linkCopied, setLinkCopied] = useState(false);
+
   const isAdmin = user?.globalRole === 'ADMIN';
 
   useEffect(() => {
@@ -84,6 +109,26 @@ export default function SettingsPage() {
     }
   };
 
+  const handleWorkspaceInvite = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !inviteEmail.trim()) return;
+    setInviting(true);
+    setInviteError('');
+    setInviteLink('');
+    setLinkCopied(false);
+    try {
+      const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim());
+      setInviteLink(inviteUrl);
+      await safeCopy(inviteUrl);
+      setLinkCopied(true);
+      setInviteEmail('');
+    } catch (err) {
+      setInviteError(err instanceof Error ? err.message : 'Failed to create invitation');
+    } finally {
+      setInviting(false);
+    }
+  };
+
   if (!user || !token) return null;
 
   return (
@@ -241,6 +286,76 @@ export default function SettingsPage() {
                   : 'Registration is closed — only invited users can join via invite link.'}
               </p>
             </div>
+
+            {/* Invite members */}
+            <div className="pt-4 border-t" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
+              <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+                Invite Member
+              </p>
+              <p className="text-xs mb-4" style={{ color: 'var(--text-muted)' }}>
+                Generate an invitation link and share it directly with a colleague.
+              </p>
+
+              <form onSubmit={handleWorkspaceInvite} className="space-y-3">
+                <div className="flex items-end gap-3 flex-wrap">
+                  <div className="flex-1 min-w-[200px]">
+                    <input
+                      type="email"
+                      className="input"
+                      value={inviteEmail}
+                      onChange={e => setInviteEmail(e.target.value)}
+                      placeholder="colleague@company.com"
+                    />
+                  </div>
+                  <button
+                    type="submit"
+                    disabled={inviting || !inviteEmail.trim()}
+                    className="btn btn-primary btn-md shrink-0"
+                  >
+                    {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 &amp; Copy Link
+                      </span>
+                    )}
+                  </button>
+                </div>
+
+                {inviteError && (
+                  <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
+                )}
+
+                {inviteLink && (
+                  <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' }}>
+                        {linkCopied ? 'Link created & copied!' : 'Link created!'}
+                      </span>
+                      <button
+                        type="button"
+                        onClick={async () => { await safeCopy(inviteLink); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }}
+                        className="ml-auto text-[10px] px-2 py-0.5 rounded"
+                        style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
+                      >
+                        {linkCopied ? 'Copied!' : 'Copy again'}
+                      </button>
+                    </div>
+                    <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
+                      {inviteLink}
+                    </p>
+                    <p className="text-[10px] mt-1.5" style={{ color: 'rgba(134,239,172,0.5)' }}>
+                      Link expires in 7 days. Share it with {inviteEmail || 'your colleague'}.
+                    </p>
+                  </div>
+                )}
+              </form>
+            </div>
           </section>
         )}
 

+ 29 - 4
src/app/(dashboard)/users/page.tsx

@@ -20,6 +20,23 @@ import { Avatar } from '@/components/ui/avatar';
  *  (Project-level roles in ProjectMember.role:
  *   ADMIN | EDITOR | REVIEWER | VIEWER — scoped to a specific project.)
  */
+async function safeCopy(text: string): Promise<void> {
+  if (typeof window === 'undefined') return;
+  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 GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
   ADMIN:        { label: 'Admin',         badge: 'badge-danger' },
   MEMBER:       { label: 'Member',        badge: 'badge-muted' },
@@ -53,9 +70,17 @@ export default function UsersPage() {
   };
 
   const openQuotaEdit = (u: AdminUser) => {
-    const mb = (u.storageQuota / 1024 / 1024).toFixed(0);
-    setQuotaInput(mb);
-    setQuotaUnit('MB');
+    const quotaBytes = u.storageQuota;
+    // Determine best unit: show GB if >= 1024 MB, otherwise MB
+    const quotaMB = quotaBytes / 1024 / 1024;
+    if (quotaMB >= 1024) {
+      // Show in GB
+      setQuotaInput(String(Math.round(quotaMB / 1024)));
+      setQuotaUnit('GB');
+    } else {
+      setQuotaInput(String(Math.round(quotaMB)));
+      setQuotaUnit('MB');
+    }
     setQuotaError('');
     setEditingQuota(u.id);
   };
@@ -159,7 +184,7 @@ export default function UsersPage() {
       }]);
       setInviteEmail('');
       setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
-      await navigator.clipboard.writeText(fullUrl).catch(() => {});
+      await safeCopy(fullUrl);
       setCreatedLink(fullUrl);
       setTimeout(() => setInviteSuccess(''), 6000);
     } catch (err) {