web: scope sidebar to workspace routes

This commit is contained in:
Keisuke Hirata 2026-07-23 19:12:50 +09:00
parent 61b1735292
commit 2bd69f9403
No known key found for this signature in database
6 changed files with 89 additions and 60 deletions

View File

@ -122,9 +122,8 @@
} }
} }
@layer layout { @layer layout {
.workspace-layout { .app-layout {
display: grid; display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
width: 100vw; width: 100vw;
height: 100dvh; height: 100dvh;
@ -133,12 +132,25 @@
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
} }
.app-shell {
min-width: 0;
min-height: 0;
overflow: auto;
}
.workspace-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
min-width: 0;
}
.workspace-layout.sidebar-collapsed { .workspace-layout.sidebar-collapsed {
grid-template-columns: max-content minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
} }
.workspace-topbar { .workspace-topbar {
grid-column: 2;
grid-row: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@ -180,7 +192,6 @@
} }
.shell { .shell {
grid-column: 2; grid-column: 2;
grid-row: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-6); gap: var(--space-6);
@ -236,7 +247,7 @@
@layer components { @layer components {
.workspace-sidebar { .workspace-sidebar {
grid-column: 1; grid-column: 1;
grid-row: 1 / 3; grid-row: 1;
align-self: stretch; align-self: stretch;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;

View File

@ -390,6 +390,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const rootLayoutLoad = await Deno.readTextFile( const rootLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url), new URL("./../../../routes/+layout.ts", import.meta.url),
); );
const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
);
const workspaceLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
);
const sidebar = await Deno.readTextFile( const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
); );
@ -422,10 +428,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
rootLayout.includes("topbar-icon-button") && rootLayout.includes("topbar-icon-button") &&
rootLayout.includes('href="/account"') && rootLayout.includes('href="/account"') &&
rootLayout.includes("Open Account") && rootLayout.includes("Open Account") &&
!rootLayout.includes("WorkspaceSidebar") &&
!sidebar.includes("accountHref") && !sidebar.includes("accountHref") &&
!sidebar.includes("Open Account"), !sidebar.includes("Open Account"),
"Account navigation should live in the global layout header, not the workspace sidebar", "Account navigation should live in the global layout header, not the workspace sidebar",
); );
assert(
workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes("workspace={data.workspace}") &&
workspaceLayoutLoad.includes("workspaceApiPath(params.workspaceId"),
"Workspace sidebar and workspace-scoped data loading should live under /w/[workspaceId] layout",
);
assert( assert(
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'), rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace", "Root layout should not redirect account and device-login public routes to a workspace",

View File

@ -1,26 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte'; import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let sidebarCollapsed = $state(false);
</script> </script>
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout"> <div class="app-layout">
<WorkspaceSidebar
workspace={data.workspace}
workspaceError={data.workspaceError}
repositories={data.repositories}
repositoriesError={data.repositoriesError}
currentPath={page.url.pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
/>
<header class="workspace-topbar"> <header class="workspace-topbar">
<nav class="workspace-topbar-actions" aria-label="Global navigation"> <nav class="workspace-topbar-actions" aria-label="Global navigation">
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account"> <a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
@ -31,7 +19,7 @@
</a> </a>
</nav> </nav>
</header> </header>
<main class="shell"> <div class="app-shell">
{@render children()} {@render children()}
</main> </div>
</div> </div>

View File

@ -1,49 +1,25 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http"; import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const ssr = false; export const ssr = false;
export const prerender = false; export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => { export const load: LayoutLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId; if (params.workspaceId) {
return {};
if (!workspaceId) {
const publicRoutes = new Set(["/account", "/login/device"]);
if (publicRoutes.has(url.pathname)) {
return {
workspace: null,
workspaceError: null,
repositories: null,
repositoriesError: null,
};
}
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {
workspace: null,
workspaceError: workspace.error,
repositories: null,
repositoriesError: null,
};
} }
const apiPath = (path: string) => workspaceApiPath(workspaceId, path); const publicRoutes = new Set(["/account", "/login/device"]);
if (publicRoutes.has(url.pathname)) {
return {};
}
const [workspace, repositories] = await Promise.all([ const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")), if (workspace.data) {
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")), const scopedPath = workspaceRoute(workspace.data.workspace_id);
]); throw redirect(307, `${scopedPath}${url.search}`);
}
return { return {};
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
}; };

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let sidebarCollapsed = $state(false);
</script>
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
<WorkspaceSidebar
workspace={data.workspace}
workspaceError={data.workspaceError}
repositories={data.repositories}
repositoriesError={data.repositoriesError}
currentPath={page.url.pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
/>
<main class="shell">
{@render children()}
</main>
</div>

View File

@ -0,0 +1,18 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};