summaryrefslogtreecommitdiffhomepage
path: root/cloud/web/src/pages/components
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-08-08 13:22:54 -0400
committerFrank <[email protected]>2025-08-08 13:24:32 -0400
commit183e0911b76025a1f2a82e979d9834fec2131d0e (patch)
tree9987c1753bd64d1ce1d174ab397f1a8c681f642c /cloud/web/src/pages/components
parentc7bb19ad0712469063eab35589aa5d3602b0c5b1 (diff)
downloadopencode-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.tsx24
-rw-r--r--cloud/web/src/pages/components/context-workspace.tsx38
-rw-r--r--cloud/web/src/pages/components/layout.module.css199
-rw-r--r--cloud/web/src/pages/components/layout.tsx96
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>
+ )
+}