Frank 4 месяцев назад
Родитель
Сommit
d8b3aa9382

+ 134 - 3
packages/console/app/src/routes/workspace/[id]/members/member-section.module.css

@@ -132,7 +132,6 @@
         position: absolute;
         top: 100%;
         left: 0;
-        right: 0;
         z-index: 10;
         margin-top: var(--space-1);
         padding: var(--space-1);
@@ -140,6 +139,8 @@
         border: 1px solid var(--color-border);
         border-radius: var(--border-radius-sm);
         box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+        min-width: 280px;
+        width: max-content;
 
         [data-slot="item"] {
           display: block;
@@ -199,6 +200,14 @@
       font-weight: normal;
       color: var(--color-text-muted);
       text-transform: uppercase;
+
+      &:nth-child(2) {
+        width: 180px;
+      }
+
+      &:nth-child(3) {
+        width: 200px;
+      }
     }
 
     td {
@@ -216,6 +225,94 @@
       &[data-slot="member-role"] {
         font-family: var(--font-mono);
 
+        [data-slot="role-selector"] {
+          position: relative;
+
+          [data-slot="trigger"] {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            gap: var(--space-2);
+            width: 100%;
+            padding: var(--space-2) var(--space-3);
+            border: 1px solid var(--color-border);
+            border-radius: var(--border-radius-sm);
+            background-color: var(--color-bg);
+            color: var(--color-text);
+            font-size: var(--font-size-sm);
+            line-height: 1.5;
+            cursor: pointer;
+            transition: all 0.15s ease;
+            font-family: var(--font-sans);
+
+            &:hover {
+              border-color: var(--color-accent);
+            }
+
+            &:focus {
+              outline: none;
+              border-color: var(--color-accent);
+              box-shadow: 0 0 0 3px var(--color-accent-alpha);
+            }
+
+            [data-slot="chevron"] {
+              opacity: 0.6;
+              transition: transform 0.15s ease;
+            }
+          }
+
+          [data-slot="dropdown"] {
+            position: absolute;
+            top: 100%;
+            left: 0;
+            z-index: 10;
+            margin-top: var(--space-1);
+            padding: var(--space-1);
+            background-color: var(--color-bg);
+            border: 1px solid var(--color-border);
+            border-radius: var(--border-radius-sm);
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+            min-width: 280px;
+            width: max-content;
+
+            [data-slot="item"] {
+              display: block;
+              width: 100%;
+              padding: var(--space-2) var(--space-3);
+              border: none;
+              background-color: transparent;
+              color: var(--color-text);
+              font-size: var(--font-size-sm);
+              text-align: left;
+              cursor: pointer;
+              border-radius: var(--border-radius-sm);
+              transition: background-color 0.15s ease;
+
+              &:hover {
+                background-color: var(--color-bg-surface);
+              }
+
+              &[data-selected="true"] {
+                background-color: var(--color-accent-alpha);
+              }
+
+              div {
+                strong {
+                  display: block;
+                  color: var(--color-text);
+                  margin-bottom: var(--space-1);
+                }
+
+                p {
+                  font-size: var(--font-size-xs);
+                  color: var(--color-text-muted);
+                  margin: 0;
+                }
+              }
+            }
+          }
+        }
+
         button {
           display: flex;
           align-items: center;
@@ -248,6 +345,30 @@
         }
       }
 
+      &[data-slot="member-usage"] {
+        input {
+          width: 100%;
+          padding: var(--space-2) var(--space-3);
+          border: 1px solid var(--color-border);
+          border-radius: var(--border-radius-sm);
+          background-color: var(--color-bg);
+          color: var(--color-text);
+          font-size: var(--font-size-sm);
+          line-height: 1.5;
+          font-family: var(--font-mono);
+
+          &:focus {
+            outline: none;
+            border-color: var(--color-accent);
+            box-shadow: 0 0 0 3px var(--color-accent-alpha);
+          }
+
+          &::placeholder {
+            color: var(--color-text-disabled);
+          }
+        }
+      }
+
       &[data-slot="member-date"] {
         color: var(--color-text);
       }
@@ -257,7 +378,17 @@
         display: flex;
         gap: var(--space-2);
 
-        form button {
+        [data-slot="inline-edit-form"] {
+          display: flex;
+          gap: var(--space-2);
+
+          button {
+            opacity: 1;
+            pointer-events: auto;
+          }
+        }
+
+        form:not([data-slot="inline-edit-form"]) button {
           opacity: 0;
           pointer-events: none;
           transition: opacity 0.15s ease;
@@ -267,7 +398,7 @@
 
     tbody tr {
       &:hover {
-        [data-slot="member-actions"] form button {
+        [data-slot="member-actions"] form:not([data-slot="inline-edit-form"]) button {
           opacity: 1;
           pointer-events: auto;
         }

+ 128 - 81
packages/console/app/src/routes/workspace/[id]/members/member-section.tsx

@@ -86,17 +86,50 @@ const updateMember = action(async (form: FormData) => {
 }, "member.update")
 
 function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
-  const [editing, setEditing] = createSignal(false)
   const submission = useSubmission(updateMember)
   const isCurrentUser = () => props.actorID === props.member.id
   const isAdmin = () => props.actorRole === "admin"
+  const [store, setStore] = createStore({
+    editing: false,
+    selectedRole: props.member.role as (typeof UserRole)[number],
+    showRoleDropdown: false,
+    limit: "",
+  })
+
+  let roleDropdownRef: HTMLDivElement | undefined
 
   createEffect(() => {
     if (!submission.pending && submission.result && !submission.result.error) {
-      setEditing(false)
+      setStore("editing", false)
     }
   })
 
+  createEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) {
+        setStore("showRoleDropdown", false)
+      }
+    }
+
+    document.addEventListener("click", handleClickOutside)
+    onCleanup(() => document.removeEventListener("click", handleClickOutside))
+  })
+
+  function show() {
+    while (true) {
+      submission.clear()
+      if (!submission.result) break
+    }
+    setStore("editing", true)
+    setStore("selectedRole", props.member.role)
+    setStore("limit", props.member.monthlyLimit?.toString() ?? "")
+  }
+
+  function hide() {
+    setStore("editing", false)
+    setStore("showRoleDropdown", false)
+  }
+
   function getUsageDisplay() {
     const currentUsage = (() => {
       const dateLastUsed = props.member.timeMonthlyUsageUpdated
@@ -120,96 +153,110 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
     return `$${currentUsage} / ${limit}`
   }
 
-  return (
-    <Show
-      when={editing()}
-      fallback={
-        <tr>
-          <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
-          <td data-slot="member-role">{props.member.role}</td>
-          <td data-slot="member-usage">{getUsageDisplay()}</td>
-          <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
-          <Show when={isAdmin()}>
-            <td data-slot="member-actions">
-              <button data-color="ghost" onClick={() => setEditing(true)}>
-                Edit
-              </button>
-              <Show when={!isCurrentUser()}>
-                <form action={removeMember} method="post">
-                  <input type="hidden" name="id" value={props.member.id} />
-                  <input type="hidden" name="workspaceID" value={props.workspaceID} />
-                  <button data-color="ghost">Delete</button>
-                </form>
-              </Show>
-            </td>
-          </Show>
-        </tr>
-      }
-    >
-      <tr>
-        <td colspan={isAdmin() ? 5 : 4}>
-          <form action={updateMember} method="post">
-            <div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
-            <input type="hidden" name="id" value={props.member.id} />
-            <input type="hidden" name="workspaceID" value={props.workspaceID} />
+  const roleLabels = {
+    admin: { title: "Admin", description: "Can manage models, members, and billing" },
+    member: { title: "Member", description: "Can only generate API keys for themselves" },
+  }
 
-            <Show
-              when={!isCurrentUser()}
-              fallback={
-                <>
-                  <div data-slot="current-user-role">Role: {props.member.role}</div>
-                  <input type="hidden" name="role" value={props.member.role} />
-                </>
-              }
+  return (
+    <tr>
+      <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
+      <td data-slot="member-role">
+        <Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
+          <div data-slot="role-selector" ref={roleDropdownRef}>
+            <button
+              data-slot="trigger"
+              type="button"
+              onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
             >
-              <div data-slot="role-selector">
-                <label>
-                  <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
+              <span>{roleLabels[store.selectedRole].title}</span>
+              <IconChevron data-slot="chevron" />
+            </button>
+            <Show when={store.showRoleDropdown}>
+              <div data-slot="dropdown">
+                <button
+                  data-slot="item"
+                  data-selected={store.selectedRole === "admin"}
+                  type="button"
+                  onClick={() => {
+                    setStore("selectedRole", "admin")
+                    setStore("showRoleDropdown", false)
+                  }}
+                >
                   <div>
                     <strong>Admin</strong>
-                    <p>Can manage models, members, and billing</p>
+                    <p>{roleLabels.admin.description}</p>
                   </div>
-                </label>
-                <label>
-                  <input type="radio" name="role" value="member" checked={props.member.role === "member"} />
+                </button>
+                <button
+                  data-slot="item"
+                  data-selected={store.selectedRole === "member"}
+                  type="button"
+                  onClick={() => {
+                    setStore("selectedRole", "member")
+                    setStore("showRoleDropdown", false)
+                  }}
+                >
                   <div>
-                    <strong>Member</strong>
-                    <p>Can only generate API keys for themselves</p>
+                    <strong>{roleLabels.member.title}</strong>
+                    <p>{roleLabels.member.description}</p>
                   </div>
-                </label>
+                </button>
               </div>
             </Show>
-
-            <div data-slot="limit-selector">
-              <label>
-                <strong>Monthly Limit</strong>
-                <input
-                  type="number"
-                  name="limit"
-                  value={props.member.monthlyLimit ?? ""}
-                  placeholder="No limit"
-                  min="0"
-                />
-                <p>Set a monthly spending limit for this user</p>
-              </label>
-            </div>
-
-            <Show when={submission.result && submission.result.error}>
-              {(err) => <div data-slot="form-error">{err()}</div>}
-            </Show>
-
-            <div data-slot="form-actions">
-              <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
-                Cancel
-              </button>
-              <button type="submit" data-color="primary" disabled={submission.pending}>
+          </div>
+        </Show>
+      </td>
+      <td data-slot="member-usage">
+        <Show when={store.editing} fallback={<span>{getUsageDisplay()}</span>}>
+          <input
+            data-component="input"
+            type="number"
+            value={store.limit}
+            onInput={(e) => setStore("limit", e.currentTarget.value)}
+            placeholder="No limit"
+            min="0"
+          />
+        </Show>
+      </td>
+      <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
+      <Show when={isAdmin()}>
+        <td data-slot="member-actions">
+          <Show
+            when={store.editing}
+            fallback={
+              <>
+                <button data-color="ghost" onClick={() => show()}>
+                  Edit
+                </button>
+                <Show when={!isCurrentUser()}>
+                  <form action={removeMember} method="post">
+                    <input type="hidden" name="id" value={props.member.id} />
+                    <input type="hidden" name="workspaceID" value={props.workspaceID} />
+                    <button data-color="ghost">Delete</button>
+                  </form>
+                </Show>
+              </>
+            }
+          >
+            <form action={updateMember} method="post" data-slot="inline-edit-form">
+              <input type="hidden" name="id" value={props.member.id} />
+              <input type="hidden" name="workspaceID" value={props.workspaceID} />
+              <input type="hidden" name="role" value={store.selectedRole} />
+              <input type="hidden" name="limit" value={store.limit} />
+              <button type="submit" data-color="ghost" disabled={submission.pending}>
                 {submission.pending ? "Saving..." : "Save"}
               </button>
-            </div>
-          </form>
+              <Show when={!submission.pending}>
+                <button type="button" data-color="ghost" onClick={() => hide()}>
+                  Cancel
+                </button>
+              </Show>
+            </form>
+          </Show>
         </td>
-      </tr>
-    </Show>
+      </Show>
+    </tr>
   )
 }
 
@@ -370,7 +417,7 @@ export function MemberSection() {
             <tr>
               <th>Email</th>
               <th>Role</th>
-              <th>Usage</th>
+              <th>Limit</th>
               <th></th>
               <Show when={data()?.actorRole === "admin"}>
                 <th></th>