diff options
| author | Frank <[email protected]> | 2025-08-08 13:22:54 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-08-08 13:24:32 -0400 |
| commit | 183e0911b76025a1f2a82e979d9834fec2131d0e (patch) | |
| tree | 9987c1753bd64d1ce1d174ab397f1a8c681f642c /cloud/web/src/pages/components | |
| parent | c7bb19ad0712469063eab35589aa5d3602b0c5b1 (diff) | |
| download | opencode-183e0911b76025a1f2a82e979d9834fec2131d0e.tar.gz opencode-183e0911b76025a1f2a82e979d9834fec2131d0e.zip | |
wip: gateway
Diffstat (limited to 'cloud/web/src/pages/components')
| -rw-r--r-- | cloud/web/src/pages/components/context-api.tsx | 24 | ||||
| -rw-r--r-- | cloud/web/src/pages/components/context-workspace.tsx | 38 | ||||
| -rw-r--r-- | cloud/web/src/pages/components/layout.module.css | 199 | ||||
| -rw-r--r-- | cloud/web/src/pages/components/layout.tsx | 96 |
4 files changed, 357 insertions, 0 deletions
diff --git a/cloud/web/src/pages/components/context-api.tsx b/cloud/web/src/pages/components/context-api.tsx new file mode 100644 index 000000000..0a348f48f --- /dev/null +++ b/cloud/web/src/pages/components/context-api.tsx @@ -0,0 +1,24 @@ +import { hc } from "hono/client" +import { ApiType } from "@opencode/cloud-function/src/gateway" +import { useWorkspace } from "./context-workspace" +import { useOpenAuth } from "../../components/context-openauth" + +export function useApi() { + const workspace = useWorkspace() + const auth = useOpenAuth() + return hc<ApiType>(import.meta.env.VITE_API_URL, { + async fetch(...args: Parameters<typeof fetch>): Promise<Response> { + const [input, init] = args + const request = input instanceof Request ? input : new Request(input, init) + const headers = new Headers(request.headers) + headers.set("authorization", `Bearer ${await auth.access()}`) + headers.set("x-opencode-workspace", workspace.id) + return fetch( + new Request(request, { + ...init, + headers, + }), + ) + }, + }) +} diff --git a/cloud/web/src/pages/components/context-workspace.tsx b/cloud/web/src/pages/components/context-workspace.tsx new file mode 100644 index 000000000..6bad39840 --- /dev/null +++ b/cloud/web/src/pages/components/context-workspace.tsx @@ -0,0 +1,38 @@ +import { useNavigate, useParams } from "@solidjs/router" +import { createInitializedContext } from "../../util/context" +import { useAccount } from "../../components/context-account" +import { createEffect, createMemo } from "solid-js" + +export const { use: useWorkspace, provider: WorkspaceProvider } = + createInitializedContext("WorkspaceProvider", () => { + const params = useParams() + const account = useAccount() + const workspace = createMemo(() => + account.current?.workspaces.find( + (x) => x.id === params.workspace || x.slug === params.workspace, + ), + ) + const nav = useNavigate() + + createEffect(() => { + if (!workspace()) nav("/") + }) + + const result = () => workspace()! + result.ready = true + + return { + get id() { + return workspace()!.id + }, + get slug() { + return workspace()!.slug + }, + get name() { + return workspace()!.name + }, + get ready() { + return workspace() !== undefined + }, + } + }) diff --git a/cloud/web/src/pages/components/layout.module.css b/cloud/web/src/pages/components/layout.module.css new file mode 100644 index 000000000..c64faa18e --- /dev/null +++ b/cloud/web/src/pages/components/layout.module.css @@ -0,0 +1,199 @@ +.root { + --padding: var(--space-10); + --vertical-padding: var(--space-8); + --heading-font-size: var(--font-size-4xl); + --sidebar-width: 200px; + --mobile-breakpoint: 40rem; + --topbar-height: 60px; + + margin: var(--space-4); + border: 2px solid var(--color-border); + height: calc(100vh - var(--space-8)); + display: flex; + flex-direction: row; + overflow: hidden; + /* Prevent overall scrolling */ + position: relative; +} + +[data-component="mobile-top-bar"] { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--topbar-height); + background: var(--color-background); + border-bottom: 2px solid var(--color-border); + z-index: 20; + align-items: center; + padding: 0 var(--space-4) 0 0; + + [data-slot="logo"] { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + div { + text-transform: uppercase; + font-weight: 600; + letter-spacing: -0.03125rem; + } + + svg { + height: 28px; + width: auto; + color: var(--color-white); + } + } + + [data-slot="toggle"] { + background: transparent; + border: none; + padding: var(--space-4); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + & svg { + width: 24px; + height: 24px; + color: var(--color-foreground); + } + } +} + +[data-component="sidebar"] { + width: var(--sidebar-width); + border-right: 2px solid var(--color-border); + display: flex; + flex-direction: column; + padding: calc(var(--padding) / 2); + overflow-y: auto; + /* Allow scrolling if needed */ + position: sticky; + top: 0; + height: 100%; + background-color: var(--color-background); + z-index: 10; + + [data-slot="logo"] { + margin-top: 2px; + margin-bottom: var(--space-7); + color: var(--color-white); + + & svg { + height: 32px; + width: auto; + } + } + + [data-slot="nav"] { + flex: 1; + + ul { + list-style-type: none; + padding: 0; + } + + li { + margin-bottom: calc(var(--vertical-padding) / 2); + text-transform: uppercase; + font-weight: 500; + } + + a { + display: block; + padding: var(--space-2) 0; + } + } + + [data-slot="user"] { + [data-component="button"] { + padding-left: 0; + padding-bottom: 0; + height: auto; + } + } +} + +.navActiveLink { + cursor: default; + text-decoration: none; +} + +[data-slot="main-content"] { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + /* Full height */ + overflow: hidden; + /* Prevent overflow */ + position: relative; + /* For positioning footer */ + width: 100%; + /* Full width */ +} + +/* Backdrop for mobile */ +[data-component="backdrop"] { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* background-color: rgba(0, 0, 0, 0.5); */ + z-index: 25; + backdrop-filter: blur(2px); +} + +/* Mobile styles */ +@media (max-width: 40rem) { + .root { + margin: 0; + border: none; + height: 100vh; + } + + [data-component="mobile-top-bar"] { + display: flex; + } + + [data-component="backdrop"] { + display: block; + } + + [data-component="sidebar"] { + position: fixed; + left: -100%; + top: 0; + height: 100vh; + width: 80%; + max-width: 280px; + transition: left 0.3s ease-in-out; + box-shadow: none; + z-index: 30; + padding: var(--space-8); + background-color: var(--color-bg); + + &[data-opened="true"] { + left: 0; + box-shadow: 8px 0 0px 0px var(--color-gray-4); + } + } + + [data-slot="main-content"] { + padding-top: var(--topbar-height); + /* Add space for the top bar */ + overflow-y: auto; + } + + /* Hide the logo in the sidebar on mobile since it's in the top bar */ + [data-component="sidebar"] [data-slot="logo"] { + display: none; + } +} diff --git a/cloud/web/src/pages/components/layout.tsx b/cloud/web/src/pages/components/layout.tsx new file mode 100644 index 000000000..711ed8fc2 --- /dev/null +++ b/cloud/web/src/pages/components/layout.tsx @@ -0,0 +1,96 @@ +import style from "./layout.module.css" +import { useAccount } from "../../components/context-account" +import { Button } from "../../ui/button" +import { IconLogomark } from "../../ui/svg" +import { IconBars3BottomLeft } from "../../ui/svg/icons" +import { ParentProps, createMemo, createSignal } from "solid-js" +import { A, useLocation } from "@solidjs/router" +import { useOpenAuth } from "../../components/context-openauth" + +export default function Layout(props: ParentProps) { + const auth = useOpenAuth() + const account = useAccount() + const [sidebarOpen, setSidebarOpen] = createSignal(false) + const location = useLocation() + + const workspaceId = createMemo(() => account.current?.workspaces[0].id) + const pageTitle = createMemo(() => { + const path = location.pathname + if (path.endsWith("/billing")) return "Billing" + if (path.endsWith("/keys")) return "API Keys" + return null + }) + + function handleLogout() { + auth.logout(auth.subject?.id!) + } + + return ( + <div class={style.root}> + {/* Mobile top bar */} + <div data-component="mobile-top-bar"> + <button data-slot="toggle" onClick={() => setSidebarOpen(!sidebarOpen())}> + <IconBars3BottomLeft /> + </button> + + <div data-slot="logo"> + {pageTitle() ? ( + <div>{pageTitle()}</div> + ) : ( + <A href="/"> + <IconLogomark /> + </A> + )} + </div> + </div> + + {/* Backdrop for mobile sidebar - closes sidebar when clicked */} + {sidebarOpen() && <div data-component="backdrop" onClick={() => setSidebarOpen(false)}></div>} + + <div data-component="sidebar" data-opened={sidebarOpen() ? "true" : "false"}> + <div data-slot="logo"> + <A href="/"> + <IconLogomark /> + </A> + </div> + + <nav data-slot="nav"> + <ul> + <li> + <A end activeClass={style.navActiveLink} href={`/${workspaceId()}`} onClick={() => setSidebarOpen(false)}> + Chat + </A> + </li> + <li> + <A + activeClass={style.navActiveLink} + href={`/${workspaceId()}/billing`} + onClick={() => setSidebarOpen(false)} + > + Billing + </A> + </li> + <li> + <A + activeClass={style.navActiveLink} + href={`/${workspaceId()}/keys`} + onClick={() => setSidebarOpen(false)} + > + API Keys + </A> + </li> + </ul> + </nav> + + <div data-slot="user"> + <Button color="ghost" onClick={handleLogout} title={account.current?.email || ""}> + Logout + </Button> + </div> + </div> + + {/* Main Content */} + <div data-slot="main-content">{props.children}</div> + </div> + ) +} |
