mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-21 08:44:53 +01:00
wip: zen refactor selector
This commit is contained in:
80
packages/console/app/src/component/dropdown.css
Normal file
80
packages/console/app/src/component/dropdown.css
Normal file
@@ -0,0 +1,80 @@
|
||||
[data-component="dropdown"] {
|
||||
position: relative;
|
||||
|
||||
[data-slot="trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
margin-top: var(--space-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 160px;
|
||||
|
||||
&[data-align="left"] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&[data-align="right"] {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="item"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
background-color: var(--color-accent-alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
packages/console/app/src/component/dropdown.tsx
Normal file
79
packages/console/app/src/component/dropdown.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { JSX, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { IconChevron } from "./icon"
|
||||
import "./dropdown.css"
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: JSX.Element | string
|
||||
children: JSX.Element
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
align?: "left" | "right"
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function Dropdown(props: DropdownProps) {
|
||||
const [store, setStore] = createStore({
|
||||
isOpen: props.open ?? false,
|
||||
})
|
||||
let dropdownRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open !== undefined) {
|
||||
setStore("isOpen", props.open)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
setStore("isOpen", false)
|
||||
props.onOpenChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
onCleanup(() => document.removeEventListener("click", handleClickOutside))
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !store.isOpen
|
||||
setStore("isOpen", newValue)
|
||||
props.onOpenChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="dropdown" class={props.class} ref={dropdownRef}>
|
||||
<button data-slot="trigger" type="button" onClick={toggle}>
|
||||
{typeof props.trigger === "string" ? <span>{props.trigger}</span> : props.trigger}
|
||||
<IconChevron data-slot="chevron" />
|
||||
</button>
|
||||
|
||||
<Show when={store.isOpen}>
|
||||
<div data-slot="dropdown" data-align={props.align ?? "left"}>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DropdownItemProps {
|
||||
children: JSX.Element
|
||||
selected?: boolean
|
||||
onClick?: () => void
|
||||
type?: "button" | "submit" | "reset"
|
||||
}
|
||||
|
||||
export function DropdownItem(props: DropdownItemProps) {
|
||||
return (
|
||||
<button
|
||||
data-slot="item"
|
||||
data-selected={props.selected ?? false}
|
||||
type={props.type ?? "button"}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +1,17 @@
|
||||
[data-component="user-menu"] {
|
||||
position: relative;
|
||||
|
||||
[data-slot="trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
[data-component="dropdown"] {
|
||||
[data-slot="trigger"] span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
margin-top: var(--space-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 160px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
[data-slot="dropdown"] {
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
[data-slot="item"] {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="item"],
|
||||
[data-slot="create-item"] {
|
||||
width: 100%;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Show, onCleanup, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { action, redirect } from "@solidjs/router"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { IconChevron } from "~/component/icon"
|
||||
import { Dropdown } from "~/component/dropdown"
|
||||
import "./user-menu.css"
|
||||
|
||||
const logout = action(async () => {
|
||||
@@ -23,41 +21,15 @@ const logout = action(async () => {
|
||||
})
|
||||
|
||||
export function UserMenu(props: { email: string | null | undefined }) {
|
||||
const [store, setStore] = createStore({
|
||||
showDropdown: false,
|
||||
})
|
||||
let dropdownRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
setStore("showDropdown", false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
|
||||
onCleanup(() => document.removeEventListener("click", handleClickOutside))
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="user-menu">
|
||||
<div ref={dropdownRef}>
|
||||
<button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
|
||||
<span>{props.email}</span>
|
||||
<IconChevron data-slot="chevron" />
|
||||
</button>
|
||||
|
||||
<Show when={store.showDropdown}>
|
||||
<div data-slot="dropdown">
|
||||
<form action={logout} method="post">
|
||||
<button type="submit" formaction={logout} data-slot="item">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Dropdown trigger={props.email ?? ""} align="right">
|
||||
<form action={logout} method="post">
|
||||
<button type="submit" formaction={logout} data-slot="item">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,66 +1,23 @@
|
||||
[data-component="workspace-picker"] {
|
||||
position: relative;
|
||||
|
||||
[data-slot="trigger"] {
|
||||
/* Override blue accent colors with neutral colors for dropdown trigger */
|
||||
--color-accent: var(--color-border);
|
||||
--color-accent-hover: var(--color-border);
|
||||
--color-accent-active: var(--color-border);
|
||||
--color-primary: var(--color-border);
|
||||
--color-primary-hover: var(--color-border);
|
||||
--color-primary-active: var(--color-border);
|
||||
--color-primary-alpha-20: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
[data-component="dropdown"] {
|
||||
[data-slot="trigger"] {
|
||||
/* Override blue accent colors with neutral colors for dropdown trigger */
|
||||
--color-accent: var(--color-border);
|
||||
--color-accent-hover: var(--color-border);
|
||||
--color-accent-active: var(--color-border);
|
||||
--color-primary: var(--color-border);
|
||||
--color-primary-hover: var(--color-border);
|
||||
--color-primary-active: var(--color-border);
|
||||
--color-primary-alpha-20: transparent;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
[data-slot="dropdown"] {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
margin-top: var(--space-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
min-width: 200px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="item"],
|
||||
[data-slot="create-item"] {
|
||||
width: 100%;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
@@ -70,6 +27,12 @@
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="create-form"] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||
import { For, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
@@ -7,7 +7,7 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
||||
import { IconChevron } from "~/component/icon"
|
||||
import { Dropdown, DropdownItem } from "~/component/dropdown"
|
||||
import { Modal } from "~/component/modal"
|
||||
import "./workspace-picker.css"
|
||||
|
||||
@@ -45,9 +45,7 @@ export function WorkspacePicker() {
|
||||
const submission = useSubmission(createWorkspace)
|
||||
const [store, setStore] = createStore({
|
||||
showForm: false,
|
||||
showDropdown: false,
|
||||
})
|
||||
let dropdownRef: HTMLDivElement | undefined
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
const currentWorkspace = () => {
|
||||
@@ -56,7 +54,7 @@ export function WorkspacePicker() {
|
||||
}
|
||||
|
||||
const handleWorkspaceNew = () => {
|
||||
setStore({ showForm: true, showDropdown: false })
|
||||
setStore("showForm", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -66,11 +64,7 @@ export function WorkspacePicker() {
|
||||
})
|
||||
|
||||
const handleSelectWorkspace = (workspaceID: string) => {
|
||||
if (workspaceID === params.id) {
|
||||
setStore("showDropdown", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (workspaceID === params.id) return
|
||||
window.location.href = `/workspace/${workspaceID}`
|
||||
}
|
||||
|
||||
@@ -78,48 +72,22 @@ export function WorkspacePicker() {
|
||||
createEffect(() => {
|
||||
params.id
|
||||
setStore("showForm", false)
|
||||
setStore("showDropdown", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
setStore("showDropdown", false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
onCleanup(() => document.removeEventListener("click", handleClickOutside))
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="workspace-picker">
|
||||
<div ref={dropdownRef}>
|
||||
<button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
|
||||
<span>{currentWorkspace()}</span>
|
||||
<IconChevron data-slot="chevron" />
|
||||
<Dropdown trigger={currentWorkspace()} align="left">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<DropdownItem selected={workspace.id === params.id} onClick={() => handleSelectWorkspace(workspace.id)}>
|
||||
{workspace.name || workspace.slug}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</For>
|
||||
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
|
||||
+ Create New Workspace
|
||||
</button>
|
||||
|
||||
<Show when={store.showDropdown}>
|
||||
<div data-slot="dropdown">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<button
|
||||
data-slot="item"
|
||||
data-selected={workspace.id === params.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.name || workspace.slug}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
|
||||
+ Create New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
|
||||
<form data-slot="create-form" action={createWorkspace} method="post">
|
||||
|
||||
@@ -92,93 +92,6 @@
|
||||
line-height: 1.4;
|
||||
margin-top: calc(var(--space-1) * -1);
|
||||
}
|
||||
|
||||
[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;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="members-table"] {
|
||||
@@ -226,125 +139,7 @@
|
||||
|
||||
&[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;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-transform: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&[data-slot="member-usage"] {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createSignal, For, Show, onCleanup } from "solid-js"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import styles from "./member-section.module.css"
|
||||
import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { User } from "@opencode-ai/console-core/user.js"
|
||||
import { IconChevron } from "~/component/icon"
|
||||
import { RoleDropdown } from "./role-dropdown"
|
||||
|
||||
const listMembers = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
@@ -92,29 +92,15 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
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) {
|
||||
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()
|
||||
@@ -127,7 +113,6 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
|
||||
function hide() {
|
||||
setStore("editing", false)
|
||||
setStore("showRoleDropdown", false)
|
||||
}
|
||||
|
||||
function getUsageDisplay() {
|
||||
@@ -153,58 +138,16 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
return `$${currentUsage} / ${limit}`
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
admin: { title: "Admin", description: "Can manage models, members, and billing" },
|
||||
member: { title: "Member", description: "Can only generate API keys for themselves" },
|
||||
}
|
||||
|
||||
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)}
|
||||
>
|
||||
<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>{roleLabels.admin.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
data-slot="item"
|
||||
data-selected={store.selectedRole === "member"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStore("selectedRole", "member")
|
||||
setStore("showRoleDropdown", false)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{roleLabels.member.title}</strong>
|
||||
<p>{roleLabels.member.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={roleOptions}
|
||||
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="member-usage">
|
||||
@@ -260,6 +203,11 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
)
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: "admin", description: "Can manage models, members, and billing" },
|
||||
{ value: "member", description: "Can only generate API keys for themselves" },
|
||||
]
|
||||
|
||||
export function MemberSection() {
|
||||
const params = useParams()
|
||||
const data = createAsync(() => listMembers(params.id))
|
||||
@@ -267,12 +215,10 @@ export function MemberSection() {
|
||||
const [store, setStore] = createStore({
|
||||
show: false,
|
||||
selectedRole: "member" as (typeof UserRole)[number],
|
||||
showRoleDropdown: false,
|
||||
limit: "",
|
||||
})
|
||||
|
||||
let input: HTMLInputElement
|
||||
let roleDropdownRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!submission.pending && submission.result && !submission.result.error) {
|
||||
@@ -280,17 +226,6 @@ export function MemberSection() {
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -304,12 +239,6 @@ export function MemberSection() {
|
||||
|
||||
function hide() {
|
||||
setStore("show", false)
|
||||
setStore("showRoleDropdown", false)
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
admin: { title: "Admin", description: "Can manage models, members, and billing" },
|
||||
member: { title: "Member", description: "Can only generate API keys for themselves" },
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -340,48 +269,11 @@ export function MemberSection() {
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>Role</p>
|
||||
<div data-slot="role-selector" ref={roleDropdownRef}>
|
||||
<button
|
||||
data-slot="trigger"
|
||||
type="button"
|
||||
onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
|
||||
>
|
||||
<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>{roleLabels.admin.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
data-slot="item"
|
||||
data-selected={store.selectedRole === "member"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStore("selectedRole", "member")
|
||||
setStore("showRoleDropdown", false)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{roleLabels.member.title}</strong>
|
||||
<p>{roleLabels.member.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={roleOptions}
|
||||
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>Monthly spending limit</p>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
.role-dropdown {
|
||||
[data-slot="trigger"] {
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
width: 100%;
|
||||
text-transform: capitalize;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="chevron"] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-slot="dropdown"] {
|
||||
padding: var(--space-1);
|
||||
min-width: 280px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
[data-slot="role-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);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { Dropdown } from "~/component/dropdown"
|
||||
import "./role-dropdown.css"
|
||||
|
||||
interface RoleOption {
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface RoleDropdownProps {
|
||||
value: string
|
||||
options: RoleOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function RoleDropdown(props: RoleDropdownProps) {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
props.onChange(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown trigger={props.value} open={open()} onOpenChange={setOpen} class="role-dropdown">
|
||||
<>
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
data-slot="role-item"
|
||||
data-selected={props.value === option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<div>
|
||||
<strong>{option.value}</strong>
|
||||
<p>{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user