diff options
| author | Frank <[email protected]> | 2025-10-10 16:34:07 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-10 16:34:07 -0400 |
| commit | d8b3aa9382e98eba9609cb1344846c4354d0d4b6 (patch) | |
| tree | f6201b63ab16cc2fbdd36a4fb2a78d4e518849ed /packages | |
| parent | ea9b5b8d769b08e1caeed3c9ce618d88762b29ad (diff) | |
| download | opencode-d8b3aa9382e98eba9609cb1344846c4354d0d4b6.tar.gz opencode-d8b3aa9382e98eba9609cb1344846c4354d0d4b6.zip | |
wip: zen
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/members/member-section.module.css | 137 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/members/member-section.tsx | 209 |
2 files changed, 262 insertions, 84 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css index 4d142c486..d67a29eba 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css +++ b/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; } diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx index 8cbff5032..99408d51e 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/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> |
