Jelajahi Sumber

wip: zen style header

Frank 4 bulan lalu
induk
melakukan
bc0e00cbb7

+ 56 - 20
packages/console/app/src/component/icon.tsx

@@ -2,26 +2,43 @@ import { JSX } from "solid-js"
 
 
 export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
 export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
   return (
   return (
-  <svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none"
-       xmlns="http://www.w3.org/2000/svg">
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/>
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/>
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/>
-    <path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/>
-    <path d="M114 36H108V12H114V36Z" fill="currentColor"/>
-    <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/>
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/>
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/>
-    <path fill-rule="evenodd" clip-rule="evenodd"
-          d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/>
-  </svg>
-
-)
+    <svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+        d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z"
+        fill="currentColor"
+      />
+      <path fill-rule="evenodd" clip-rule="evenodd" d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor" />
+      <path
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+        d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z"
+        fill="currentColor"
+      />
+      <path d="M108 12H96V36H90V6H108V12Z" fill="currentColor" />
+      <path d="M114 36H108V12H114V36Z" fill="currentColor" />
+      <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor" />
+      <path
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+        d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z"
+        fill="currentColor"
+      />
+      <path
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+        d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z"
+        fill="currentColor"
+      />
+      <path
+        fill-rule="evenodd"
+        clip-rule="evenodd"
+        d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z"
+        fill="currentColor"
+      />
+    </svg>
+  )
 }
 }
 
 
 export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
 export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
@@ -55,3 +72,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
     </svg>
     </svg>
   )
   )
 }
 }
+
+export function IconChevron(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path
+        fill="currentColor"
+        d="M4.00024 5.04041L7.37401 1.66663L6.66691 0.959525L4.00024 3.62619L1.33357 0.959525L0.626465 1.66663L4.00024 5.04041Z"
+      />
+    </svg>
+  )
+}
+
+export function IconWorkspaceLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} width="24" height="30" viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z" fill="currentColor" />
+    </svg>
+  )
+}

+ 68 - 0
packages/console/app/src/routes/user-menu.css

@@ -0,0 +1,68 @@
+[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;
+      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);
+    }
+
+    form {
+      width: 100%;
+    }
+  }
+
+  [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;
+  }
+}

+ 63 - 0
packages/console/app/src/routes/user-menu.tsx

@@ -0,0 +1,63 @@
+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 "./user-menu.css"
+
+const logout = action(async () => {
+  "use server"
+  const auth = await useAuthSession()
+  const event = getRequestEvent()
+  const current = auth.data.current
+  if (current)
+    await auth.update((val) => {
+      delete val.account?.[current]
+      const first = Object.keys(val.account ?? {})[0]
+      val.current = first
+      event!.locals.actor = undefined
+      return val
+    })
+  throw redirect("/zen")
+})
+
+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>
+    </div>
+  )
+}

+ 11 - 81
packages/console/app/src/routes/workspace-picker.css

@@ -15,19 +15,24 @@
     justify-content: space-between;
     justify-content: space-between;
     gap: var(--space-2);
     gap: var(--space-2);
     padding: var(--space-2) var(--space-3);
     padding: var(--space-2) var(--space-3);
-    border: 1px solid var(--color-border);
+    border: none;
     border-radius: var(--border-radius-sm);
     border-radius: var(--border-radius-sm);
-    background-color: var(--color-bg);
+    background-color: transparent;
     color: var(--color-text);
     color: var(--color-text);
     font-size: var(--font-size-sm);
     font-size: var(--font-size-sm);
     font-family: var(--font-sans);
     font-family: var(--font-sans);
     cursor: pointer;
     cursor: pointer;
-    min-width: 200px;
+    transition: all 0.15s ease;
+
+    &:hover {
+      background-color: var(--color-surface-hover);
+    }
 
 
     span {
     span {
       flex: 1;
       flex: 1;
       text-align: left;
       text-align: left;
       font-weight: 500;
       font-weight: 500;
+      color: var(--color-text);
     }
     }
   }
   }
 
 
@@ -36,20 +41,10 @@
     color: var(--color-text-secondary);
     color: var(--color-text-secondary);
   }
   }
 
 
-  [data-slot="dropdown"] button {
-    text-decoration: none !important;
-  }
-
-  /* Ensure text inside buttons has no underline */
-  [data-slot="dropdown"] button * {
-    text-decoration: none !important;
-  }
-
   [data-slot="dropdown"] {
   [data-slot="dropdown"] {
     position: absolute;
     position: absolute;
     top: 100%;
     top: 100%;
     left: 0;
     left: 0;
-    right: 0;
     z-index: 1000;
     z-index: 1000;
     margin-top: var(--space-1);
     margin-top: var(--space-1);
     border: 1px solid var(--color-border);
     border: 1px solid var(--color-border);
@@ -58,14 +53,15 @@
     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
     max-height: 240px;
     max-height: 240px;
     overflow-y: auto;
     overflow-y: auto;
+    min-width: 200px;
 
 
     @media (prefers-color-scheme: dark) {
     @media (prefers-color-scheme: dark) {
       box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
       box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
     }
     }
   }
   }
 
 
-  [data-slot="option"],
-  [data-slot="create-option"] {
+  [data-slot="item"],
+  [data-slot="create-item"] {
     width: 100%;
     width: 100%;
     padding: var(--space-2-5) var(--space-3);
     padding: var(--space-2-5) var(--space-3);
     border: none;
     border: none;
@@ -74,41 +70,6 @@
     font-size: var(--font-size-sm);
     font-size: var(--font-size-sm);
     font-family: var(--font-sans);
     font-family: var(--font-sans);
     text-align: left;
     text-align: left;
-    cursor: pointer;
-    text-decoration: none;
-
-    &:hover {
-      background-color: var(--color-surface);
-      text-decoration: none;
-    }
-
-    &:focus {
-      text-decoration: none;
-    }
-
-    &:active {
-      text-decoration: none;
-    }
-
-    &:first-child {
-      border-top-left-radius: var(--border-radius-sm);
-      border-top-right-radius: var(--border-radius-sm);
-    }
-
-    &:last-child {
-      border-bottom-left-radius: var(--border-radius-sm);
-      border-bottom-right-radius: var(--border-radius-sm);
-    }
-  }
-
-  [data-slot="option"][data-selected="true"] {
-    background-color: transparent;
-    color: var(--color-text);
-  }
-
-  [data-slot="create-option"] {
-    color: var(--color-text-secondary);
-    font-weight: 500;
   }
   }
 
 
   [data-slot="create-form"] {
   [data-slot="create-form"] {
@@ -150,35 +111,4 @@
       color: var(--color-text-muted);
       color: var(--color-text-muted);
     }
     }
   }
   }
-
-  button[type="submit"],
-  button[type="button"] {
-    padding: var(--space-2-5) var(--space-4);
-    background-color: var(--color-bg);
-    color: var(--color-text);
-    font-size: var(--font-size-sm);
-    font-family: var(--font-sans);
-    font-weight: 500;
-    cursor: pointer;
-    white-space: nowrap;
-
-    &:focus {
-      outline: none;
-      box-shadow: none;
-    }
-
-    &:active {
-      transform: translateY(1px);
-    }
-
-    &[data-color="primary"] {
-      background-color: var(--color-text-secondary);
-      border-color: var(--color-text-secondary);
-      color: var(--color-bg);
-    }
-
-    @media (max-width: 30rem) {
-      flex: 1;
-    }
-  }
 }
 }

+ 6 - 13
packages/console/app/src/routes/workspace-picker.tsx

@@ -7,6 +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 { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
 import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
 import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
 import { Workspace } from "@opencode-ai/console-core/workspace.js"
 import { Workspace } from "@opencode-ai/console-core/workspace.js"
+import { IconChevron } from "~/component/icon"
 import "./workspace-picker.css"
 import "./workspace-picker.css"
 
 
 const getWorkspaces = query(async () => {
 const getWorkspaces = query(async () => {
@@ -85,25 +86,17 @@ export function WorkspacePicker() {
   return (
   return (
     <div data-component="workspace-picker">
     <div data-component="workspace-picker">
       <div ref={dropdownRef}>
       <div ref={dropdownRef}>
-        <div data-slot="trigger" onClick={() => setStore("showDropdown", !store.showDropdown)}>
+        <button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
           <span>{currentWorkspace()}</span>
           <span>{currentWorkspace()}</span>
-          <svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none">
-            <path
-              d="M1 1L6 6L11 1"
-              stroke="currentColor"
-              stroke-width="2"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-            />
-          </svg>
-        </div>
+          <IconChevron data-slot="chevron" />
+        </button>
 
 
         <Show when={store.showDropdown}>
         <Show when={store.showDropdown}>
           <div data-slot="dropdown">
           <div data-slot="dropdown">
             <For each={workspaces()}>
             <For each={workspaces()}>
               {(workspace) => (
               {(workspace) => (
                 <button
                 <button
-                  data-slot="option"
+                  data-slot="item"
                   data-selected={workspace.id === params.id}
                   data-selected={workspace.id === params.id}
                   type="button"
                   type="button"
                   onClick={() => handleSelectWorkspace(workspace.id)}
                   onClick={() => handleSelectWorkspace(workspace.id)}
@@ -112,7 +105,7 @@ export function WorkspacePicker() {
                 </button>
                 </button>
               )}
               )}
             </For>
             </For>
-            <button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
+            <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
               + Create New Workspace
               + Create New Workspace
             </button>
             </button>
           </div>
           </div>

+ 4 - 24
packages/console/app/src/routes/workspace.css

@@ -11,7 +11,6 @@
     font-size: var(--font-size-sm);
     font-size: var(--font-size-sm);
     font-family: var(--font-sans);
     font-family: var(--font-sans);
     font-weight: 500;
     font-weight: 500;
-    text-transform: uppercase;
     cursor: pointer;
     cursor: pointer;
     transition: all 0.15s ease;
     transition: all 0.15s ease;
 
 
@@ -55,9 +54,6 @@
 
 
   a {
   a {
     color: var(--color-text);
     color: var(--color-text);
-    text-decoration: underline;
-    text-underline-offset: var(--space-0-75);
-    text-decoration-thickness: 1px;
   }
   }
 
 
   /* Workspace Header */
   /* Workspace Header */
@@ -80,16 +76,14 @@
   [data-slot="header-brand"] {
   [data-slot="header-brand"] {
     flex: 0 0 auto;
     flex: 0 0 auto;
     padding-top: 4px;
     padding-top: 4px;
-
-    svg {
-      width: 138px;
-    }
+    display: flex;
+    align-items: center;
+    gap: var(--space-4);
 
 
     [data-component="site-title"] {
     [data-component="site-title"] {
       font-size: var(--font-size-lg);
       font-size: var(--font-size-lg);
       font-weight: 600;
       font-weight: 600;
       color: var(--color-text);
       color: var(--color-text);
-      text-decoration: none;
       letter-spacing: -0.02em;
       letter-spacing: -0.02em;
     }
     }
   }
   }
@@ -109,19 +103,5 @@
         display: none;
         display: none;
       }
       }
     }
     }
-
-    a,
-    button {
-      appearance: none;
-      background: none;
-      border: none;
-      cursor: pointer;
-      padding: 0;
-      color: var(--color-text);
-      text-decoration: underline;
-      text-underline-offset: var(--space-0-75);
-      text-decoration-thickness: 1px;
-      text-transform: uppercase;
-    }
   }
   }
-}
+}

+ 7 - 29
packages/console/app/src/routes/workspace.tsx

@@ -1,10 +1,9 @@
 import { Show } from "solid-js"
 import { Show } from "solid-js"
-import { getRequestEvent } from "solid-js/web"
-import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
+import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
 import "./workspace.css"
 import "./workspace.css"
-import { useAuthSession } from "~/context/auth.session"
-import { IconLogo } from "../component/icon"
+import { IconLogo, IconWorkspaceLogo } from "../component/icon"
 import { WorkspacePicker } from "./workspace-picker"
 import { WorkspacePicker } from "./workspace-picker"
+import { UserMenu } from "./user-menu"
 import { withActor } from "~/context/auth.withActor"
 import { withActor } from "~/context/auth.withActor"
 import { User } from "@opencode-ai/console-core/user.js"
 import { User } from "@opencode-ai/console-core/user.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -19,22 +18,6 @@ const getUserEmail = query(async (workspaceID: string) => {
   }, workspaceID)
   }, workspaceID)
 }, "userEmail")
 }, "userEmail")
 
 
-const logout = action(async () => {
-  "use server"
-  const auth = await useAuthSession()
-  const event = getRequestEvent()
-  const current = auth.data.current
-  if (current)
-    await auth.update((val) => {
-      delete val.account?.[current]
-      const first = Object.keys(val.account ?? {})[0]
-      val.current = first
-      event!.locals.actor = undefined
-      return val
-    })
-  throw redirect("/zen")
-})
-
 export default function WorkspaceLayout(props: RouteSectionProps) {
 export default function WorkspaceLayout(props: RouteSectionProps) {
   const params = useParams()
   const params = useParams()
   const userEmail = createAsync(() => getUserEmail(params.id))
   const userEmail = createAsync(() => getUserEmail(params.id))
@@ -44,19 +27,14 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
       <header data-component="workspace-header">
       <header data-component="workspace-header">
         <div data-slot="header-brand">
         <div data-slot="header-brand">
           <A href="/" data-component="site-title">
           <A href="/" data-component="site-title">
-            <IconLogo />
+            <IconWorkspaceLogo />
           </A>
           </A>
-        </div>
-        <div data-slot="header-actions">
           <Show when={sessionInfo()?.isBeta}>
           <Show when={sessionInfo()?.isBeta}>
             <WorkspacePicker />
             <WorkspacePicker />
           </Show>
           </Show>
-          <span data-slot="user">{userEmail()}</span>
-          <form action={logout} method="post">
-            <button type="submit" formaction={logout}>
-              Logout
-            </button>
-          </form>
+        </div>
+        <div data-slot="header-actions">
+          <UserMenu email={userEmail()} />
         </div>
         </div>
       </header>
       </header>
       <div>{props.children}</div>
       <div>{props.children}</div>