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>) {
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>) {
@@ -55,3 +72,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</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;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border: none;
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
background-color: transparent;
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
cursor: pointer;
min-width: 200px;
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);
}
}
@@ -36,20 +41,10 @@
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"] {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: var(--space-1);
border: 1px solid var(--color-border);
@@ -58,14 +53,15 @@
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="option"],
[data-slot="create-option"] {
[data-slot="item"],
[data-slot="create-item"] {
width: 100%;
padding: var(--space-2-5) var(--space-3);
border: none;
@@ -74,41 +70,6 @@
font-size: var(--font-size-sm);
font-family: var(--font-sans);
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"] {
@@ -150,35 +111,4 @@
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 { 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 "./workspace-picker.css"
const getWorkspaces = query(async () => {
@@ -85,25 +86,17 @@ export function WorkspacePicker() {
return (
<div data-component="workspace-picker">
<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>
<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}>
<div data-slot="dropdown">
<For each={workspaces()}>
{(workspace) => (
<button
data-slot="option"
data-slot="item"
data-selected={workspace.id === params.id}
type="button"
onClick={() => handleSelectWorkspace(workspace.id)}
@@ -112,7 +105,7 @@ export function WorkspacePicker() {
</button>
)}
</For>
<button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
+ Create New Workspace
</button>
</div>

View File

@@ -11,7 +11,6 @@
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
@@ -55,9 +54,6 @@
a {
color: var(--color-text);
text-decoration: underline;
text-underline-offset: var(--space-0-75);
text-decoration-thickness: 1px;
}
/* Workspace Header */
@@ -80,16 +76,14 @@
[data-slot="header-brand"] {
flex: 0 0 auto;
padding-top: 4px;
svg {
width: 138px;
}
display: flex;
align-items: center;
gap: var(--space-4);
[data-component="site-title"] {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.02em;
}
}
@@ -109,19 +103,5 @@
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 { 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 { useAuthSession } from "~/context/auth.session"
import { IconLogo } from "../component/icon"
import { IconLogo, IconWorkspaceLogo } from "../component/icon"
import { WorkspacePicker } from "./workspace-picker"
import { UserMenu } from "./user-menu"
import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -19,22 +18,6 @@ const getUserEmail = query(async (workspaceID: string) => {
}, workspaceID)
}, "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) {
const params = useParams()
const userEmail = createAsync(() => getUserEmail(params.id))
@@ -44,19 +27,14 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
<header data-component="workspace-header">
<div data-slot="header-brand">
<A href="/" data-component="site-title">
<IconLogo />
<IconWorkspaceLogo />
</A>
</div>
<div data-slot="header-actions">
<Show when={sessionInfo()?.isBeta}>
<WorkspacePicker />
</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>
</header>
<div>{props.children}</div>