wip: zen style header

This commit is contained in:
Frank
2025-10-09 22:38:42 -04:00
parent 60dd987efd
commit bc0e00cbb7
7 changed files with 215 additions and 167 deletions

View File

@@ -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" <svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg"> <path
<path fill-rule="evenodd" clip-rule="evenodd" fill-rule="evenodd"
d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/> clip-rule="evenodd"
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z"
d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/> fill="currentColor"
<path fill-rule="evenodd" clip-rule="evenodd" />
d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor" />
<path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/> <path
<path d="M114 36H108V12H114V36Z" fill="currentColor"/> fill-rule="evenodd"
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/> clip-rule="evenodd"
<path fill-rule="evenodd" clip-rule="evenodd" d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z"
d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/> fill="currentColor"
<path fill-rule="evenodd" clip-rule="evenodd" />
d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/> <path d="M108 12H96V36H90V6H108V12Z" fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd" <path d="M114 36H108V12H114V36Z" fill="currentColor" />
d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/> <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor" />
</svg> <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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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="item"],
[data-slot="create-option"] { [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;
}
}
} }

View File

@@ -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"> <IconChevron data-slot="chevron" />
<path </button>
d="M1 1L6 6L11 1"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<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>

View File

@@ -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;
display: flex;
svg { align-items: center;
width: 138px; 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;
}
} }
} }

View File

@@ -1,10 +1,9 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import { getRequestEvent } from "solid-js/web" import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import "./workspace.css" import "./workspace.css"
import { useAuthSession } from "~/context/auth.session" import { IconLogo, IconWorkspaceLogo } from "../component/icon"
import { IconLogo } 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> </div>
<form action={logout} method="post"> <div data-slot="header-actions">
<button type="submit" formaction={logout}> <UserMenu email={userEmail()} />
Logout
</button>
</form>
</div> </div>
</header> </header>
<div>{props.children}</div> <div>{props.children}</div>