Feat: Hydrate shell from app state

This commit is contained in:
MangoPig
2026-06-19 17:39:39 +01:00
parent 913825f596
commit 6ba04effcf
15 changed files with 1258 additions and 131 deletions

View File

@@ -3,6 +3,7 @@
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
@@ -16,13 +17,14 @@ import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
export const AppShell = (): JSX.Element => {
const AppShellContent = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData();
onMount((): void => {
setThemeState(getDocumentTheme());
@@ -79,7 +81,7 @@ export const AppShell = (): JSX.Element => {
};
return (
<div class={styles.shell} data-ui="app-shell">
<div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar
theme={themeState()}
onToggleTheme={toggleTheme}
@@ -163,3 +165,11 @@ export const AppShell = (): JSX.Element => {
</div>
);
};
export const AppShell = (): JSX.Element => {
return (
<AppShellDataProvider>
<AppShellContent />
</AppShellDataProvider>
);
};

View File

@@ -1,22 +1,29 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons";
import { activeDepartment, departmentItems, type DepartmentItem } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { type DepartmentItem } from "../data/shell.data";
import styles from "./DepartmentSelector.module.scss";
const defaultDepartment = departmentItems.find((item) => item.id === activeDepartment.id) ?? departmentItems[0];
const defaultTeamName = departmentItems
.find((item) => item.id === activeDepartment.id)
?.teams.find((teamName) => teamName === activeDepartment.teamName)
?? defaultDepartment?.teams[0]
?? "";
export const DepartmentSelector = (): JSX.Element => {
const appShellData = useAppShellData();
const [isOpen, setIsOpen] = createSignal(false);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment);
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(appShellData.departmentItems()[0] ?? appShellData.activeDepartment());
const [selectedTeamName, setSelectedTeamName] = createSignal(appShellData.activeDepartment().teamName);
let rootRef: HTMLDivElement | undefined;
createEffect(() => {
const activeDepartment = appShellData.activeDepartment();
const matchingDepartment = appShellData.departmentItems().find((item) => item.id === activeDepartment.id) ?? appShellData.departmentItems()[0];
if (!matchingDepartment) {
return;
}
setSelectedDepartment(matchingDepartment);
setSelectedTeamName(activeDepartment.teamName || matchingDepartment.teams[0] || "");
});
onMount(() => {
const handlePointerDown = (event: PointerEvent): void => {
if (!isOpen()) return;
@@ -66,7 +73,7 @@ export const DepartmentSelector = (): JSX.Element => {
<div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Departments</span>
<For each={departmentItems}>
<For each={appShellData.departmentItems()}>
{(item): JSX.Element => (
<button
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}

View File

@@ -1,7 +1,8 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js";
import { railItems, type RailItem } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { type RailItem } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
type RailEntryProps = {
@@ -46,8 +47,9 @@ type LeftRailProps = {
};
export const LeftRail = (props: LeftRailProps): JSX.Element => {
const personalItem = railItems.find((item) => item.kind === "personal");
const organizationItems = railItems.filter((item) => item.kind === "organization");
const appShellData = useAppShellData();
const personalItem = () => appShellData.railItems().find((item) => item.kind === "personal");
const organizationItems = () => appShellData.railItems().filter((item) => item.kind === "organization");
return (
<aside
@@ -58,7 +60,7 @@ export const LeftRail = (props: LeftRailProps): JSX.Element => {
aria-label="Server rail"
>
<div class={styles.topCluster}>
<Show when={!props.collapsed && personalItem}>
<Show when={!props.collapsed && personalItem()}>
{(item): JSX.Element => (
<RailEntry item={item()} label={item().label} abbreviation={item().abbreviation} personal />
)}
@@ -71,7 +73,7 @@ export const LeftRail = (props: LeftRailProps): JSX.Element => {
<Show when={!props.collapsed}>
<div class={styles.items}>
<For each={organizationItems}>
<For each={organizationItems()}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>

View File

@@ -1,7 +1,8 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { activeProject, activeServer, mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import styles from "./MobileBottomNav.module.scss";
type MobileBottomNavProps = {
@@ -39,12 +40,14 @@ const MobileNavEntry = (props: {
};
export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => {
const appShellData = useAppShellData();
return (
<nav class={styles.mobileNav} aria-label="Mobile workspace navigation">
<div class={styles.contextBar}>
<span class={styles.contextServer}>{activeServer.name}</span>
<span class={styles.contextServer}>{appShellData.activeServer().name}</span>
<span class={styles.contextDivider}>/</span>
<span class={styles.contextProject}>{activeProject.name}</span>
<span class={styles.contextProject}>{appShellData.activeProject().name}</span>
</div>
<div class={styles.navGrid}>

View File

@@ -1,15 +1,13 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { createLongPressGesture } from "../createLongPressGesture";
import {
activeProject,
activeServer,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceStaticItems,
workspaceTree,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
@@ -149,10 +147,11 @@ const WorkspaceTreeBranch = (props: {
};
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const appShellData = useAppShellData();
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const sectionNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = createWorkspaceSurfaceTarget(activeProject);
const sectionNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
@@ -160,7 +159,7 @@ export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.
setActionSheetTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget);
openActionSheet(workspaceTarget());
};
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
@@ -187,8 +186,8 @@ export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{activeProject.name}</strong>
<span class={styles.brandContext}>{activeServer.name}</span>
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
</div>
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
@@ -222,15 +221,15 @@ export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
<WorkspaceTreeBranch nodes={sectionNodes} onOpenActionSheet={openActionSheet} />
<WorkspaceTreeBranch nodes={sectionNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
<Show when={looseNodes.length > 0}>
<Show when={looseNodes().length > 0}>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="more">
<span class={styles.sectionLabel}>More</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="more">
<WorkspaceTreeBranch nodes={looseNodes} onOpenActionSheet={openActionSheet} />
<WorkspaceTreeBranch nodes={looseNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</Show>

View File

@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, Folder } from "../../../lib/icons";
import { activeProject, projectItems } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
@@ -12,13 +12,16 @@ type ProjectSelectorProps = {
onClose: () => void;
};
const defaultProject = projectItems.find((item) => item.id === activeProject.id) ?? projectItems[0];
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const [selectedProject, setSelectedProject] = createSignal({ id: defaultProject.id, name: defaultProject.name });
const appShellData = useAppShellData();
const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
const [drawerTop, setDrawerTop] = createSignal<number>(0);
let triggerRef: HTMLButtonElement | undefined;
createEffect(() => {
setSelectedProject(appShellData.activeProject());
});
onMount(() => {
if (!triggerRef) {
return;
@@ -57,7 +60,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
};
const selectProject = (projectId: string): void => {
const nextProject = projectItems.find((item): boolean => item.id === projectId);
const nextProject = appShellData.projectItems().find((item): boolean => item.id === projectId);
if (!nextProject) {
return;
@@ -132,7 +135,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
>
<div class={styles.drawerBody}>
<ul class={styles.projectList} role="list">
<For each={projectItems}>
<For each={appShellData.projectItems()}>
{(item): JSX.Element => {
const isSelected = (): boolean => selectedProject().id === item.id;

View File

@@ -1,33 +1,36 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { activeServer } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
const appShellData = useAppShellData();
const activeServer = () => appShellData.activeServer();
return (
<section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer.kind}>
<section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer().kind}>
<div class={styles.identity} data-slot="server-dock-identity">
<div class={styles.glyph} aria-hidden="true">
{activeServer.abbreviation}
{activeServer().abbreviation}
</div>
<div class={styles.copy} data-slot="server-dock-copy">
<span class={styles.name}>{activeServer.name}</span>
<span class={styles.name}>{activeServer().name}</span>
<Show
when={activeServer.kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer.subtitle}</span>}
when={activeServer().kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer().subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer.connectedLabel}</span>
<span>{activeServer().connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer.dockActions.length > 0}>
<Show when={activeServer().dockActions.length > 0}>
<div class={styles.actions} data-slot="server-dock-actions">
<For each={activeServer.dockActions}>
<For each={activeServer().dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;

View File

@@ -1,6 +1,7 @@
import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons";
import { activeUserProfile, profileMenuSections } from "../data/shell.data";
import { useAppShellData } from "../data/app-shell.context";
import { profileMenuSections } from "../data/shell.data";
import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
@@ -12,6 +13,8 @@ type ProfileMenuProps = {
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover";
const appShellData = useAppShellData();
const activeUserProfile = () => appShellData.activeUserProfile();
return (
<div
@@ -35,10 +38,10 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
</div>
<div class={styles.summaryCopy}>
<strong class={styles.name}>{activeUserProfile.name}</strong>
<span class={styles.email}>{activeUserProfile.email}</span>
<span class={styles.role}>{activeUserProfile.roleLabel}</span>
<span class={styles.context}>{activeUserProfile.contextLabel}</span>
<strong class={styles.name}>{activeUserProfile().name}</strong>
<span class={styles.email}>{activeUserProfile().email}</span>
<span class={styles.role}>{activeUserProfile().roleLabel}</span>
<span class={styles.context}>{activeUserProfile().contextLabel}</span>
</div>
</div>

View File

@@ -2,16 +2,15 @@
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import {
activeProject,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceSidebarHeaderActions,
workspaceStaticItems,
workspaceTree,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
@@ -142,11 +141,12 @@ const WorkspaceTreeBranch = (props: {
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const appShellData = useAppShellData();
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(activeProject);
const sidebarContextMenuTarget = createMemo(() => createWorkspaceSurfaceTarget(appShellData.activeProject()));
const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState();
@@ -174,7 +174,7 @@ const WorkspaceTreeBranch = (props: {
data-ui="workspace-sidebar"
data-collapsed={props.collapsed ? "true" : "false"}
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget);
contextMenu.openMenu(event, sidebarContextMenuTarget());
}}
>
<div
@@ -256,7 +256,7 @@ const WorkspaceTreeBranch = (props: {
<div data-slot="workspace-tree-root">
<WorkspaceTreeBranch
nodes={workspaceTree}
nodes={appShellData.workspaceTree()}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>

View File

@@ -0,0 +1,317 @@
// Path: Frontend/src/components/shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type Accessor,
type JSX,
} from "solid-js";
import { Folder } from "../../../lib/icons";
import { resolveAPIBase } from "../../../lib/api";
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
type AppShellInstallation = {
id: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
};
type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
const AppShellContext = createContext<AppShellContextValue>();
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: organization.name,
abbreviation: buildAbbreviation(organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
return {
id: installation.id,
name: organization.name || installation.host || fallbackActiveServer.name,
abbreviation: buildAbbreviation(organization.name || installation.host, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
active: index === 0,
}));
};
const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams
.filter((team) => team.departmentId === department.id)
.map((team) => team.name),
active: index === 0,
}));
};
const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
// The workspace tree should represent items inside the current project, not the
// project container itself. We do not have project-contents hydration yet, so
// return an empty tree rather than showing the project root as a fake item.
return [];
};
const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.organizations[0]?.name ?? fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as { data?: AppShellPayload; error?: { message?: string } };
if (!response.ok || !body.data) {
throw new Error(body.error?.message || "Failed to load app shell state.");
}
setPayload(body.data);
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};

View File

@@ -325,12 +325,12 @@ export type ActiveUserProfile = {
contextLabel: string;
};
const personalDockActions: readonly ServerDockAction[] = [
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
const organizationAdminDockActions: readonly ServerDockAction[] = [
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;