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 { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime"; import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome"; import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail"; import { LeftRail } from "../LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav"; import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser"; import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
@@ -16,13 +17,14 @@ import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null; type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)"; const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
export const AppShell = (): JSX.Element => { const AppShellContent = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light"); const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false); const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false); const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false); const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null); const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData();
onMount((): void => { onMount((): void => {
setThemeState(getDocumentTheme()); setThemeState(getDocumentTheme());
@@ -79,7 +81,7 @@ export const AppShell = (): JSX.Element => {
}; };
return ( return (
<div class={styles.shell} data-ui="app-shell"> <div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar <TopBar
theme={themeState()} theme={themeState()}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
@@ -163,3 +165,11 @@ export const AppShell = (): JSX.Element => {
</div> </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 { 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"; 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 => { export const DepartmentSelector = (): JSX.Element => {
const appShellData = useAppShellData();
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment); const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(appShellData.departmentItems()[0] ?? appShellData.activeDepartment());
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName); const [selectedTeamName, setSelectedTeamName] = createSignal(appShellData.activeDepartment().teamName);
let rootRef: HTMLDivElement | undefined; 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(() => { onMount(() => {
const handlePointerDown = (event: PointerEvent): void => { const handlePointerDown = (event: PointerEvent): void => {
if (!isOpen()) return; if (!isOpen()) return;
@@ -66,7 +73,7 @@ export const DepartmentSelector = (): JSX.Element => {
<div class={styles.menuSection}> <div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Departments</span> <span class={styles.menuSectionLabel}>Departments</span>
<For each={departmentItems}> <For each={appShellData.departmentItems()}>
{(item): JSX.Element => ( {(item): JSX.Element => (
<button <button
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }} classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,15 @@
import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons"; import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector"; import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import { import {
activeProject,
createWorkspaceStaticTarget, createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget, createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget, createWorkspaceTreeTarget,
getWorkspaceNodeIcon, getWorkspaceNodeIcon,
workspaceSidebarHeaderActions, workspaceSidebarHeaderActions,
workspaceStaticItems, workspaceStaticItems,
workspaceTree,
type WorkspaceContextMenuAction, type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget, type WorkspaceContextMenuTarget,
type WorkspaceStaticItem, 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 [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController(); const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail"); 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 contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => { const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState(); const state = contextMenu.menuState();
@@ -174,7 +174,7 @@ const WorkspaceTreeBranch = (props: {
data-ui="workspace-sidebar" data-ui="workspace-sidebar"
data-collapsed={props.collapsed ? "true" : "false"} data-collapsed={props.collapsed ? "true" : "false"}
onContextMenu={(event): void => { onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget); contextMenu.openMenu(event, sidebarContextMenuTarget());
}} }}
> >
<div <div
@@ -256,7 +256,7 @@ const WorkspaceTreeBranch = (props: {
<div data-slot="workspace-tree-root"> <div data-slot="workspace-tree-root">
<WorkspaceTreeBranch <WorkspaceTreeBranch
nodes={workspaceTree} nodes={appShellData.workspaceTree()}
onOpenContextMenu={contextMenu.openMenu} onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement} 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; contextLabel: string;
}; };
const personalDockActions: readonly ServerDockAction[] = [ export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User }, { id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings }, { id: "settings", label: "Settings", icon: Settings },
] as const; ] as const;
const organizationAdminDockActions: readonly ServerDockAction[] = [ export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User }, { id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings }, { id: "server", label: "Server", icon: Settings },
] as const; ] as const;

View File

@@ -1,6 +1,11 @@
.viewport { .viewport,
.wizardLayer {
--workspace-content-max-width: var(--content-width-wide); --workspace-content-max-width: var(--content-width-wide);
--workspace-card-min-height: calc(var(--space-12) * 3); --bootstrap-accent: var(--color-accent-primary, var(--color-primary-2, hsl(272 80% 70%)));
--bootstrap-accent-contrast: var(--color-accent-primary-contrast, white);
}
.viewport {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -81,6 +86,12 @@
max-width: var(--workspace-content-max-width); max-width: var(--workspace-content-max-width);
} }
.heroActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.eyebrow { .eyebrow {
@include text-caption; @include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -98,18 +109,12 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.grid { .overviewCard,
width: 100%; .summaryCard,
max-width: var(--workspace-content-max-width); .wizardSidebarSection,
display: grid; .wizardStepPanel {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.card {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
min-height: var(--workspace-card-min-height);
padding: var(--space-4); padding: var(--space-4);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
@@ -117,23 +122,376 @@
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
.cardTitle { .overviewCard {
width: 100%;
max-width: var(--workspace-content-max-width);
gap: var(--space-3);
}
.summaryGrid {
width: 100%;
max-width: var(--workspace-content-max-width);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.summaryCard {
min-height: 0;
}
.summaryCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.summaryStepNumber,
.wizardStepEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sectionHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.sectionTitle,
.cardTitle,
.wizardTitle,
.sidebarTitle {
@include text-title; @include text-title;
} }
.cardCopy { .sectionCopy,
.cardCopy,
.cardMeta {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.statusBadge {
@include text-caption;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: calc(var(--control-size-sm) - 0.25rem);
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
color: var(--color-text-muted);
white-space: nowrap;
}
.statusBadge[data-status="submitting"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
}
.statusBadge[data-status="success"] {
color: var(--color-success-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-success-border, var(--color-border)) 64%, transparent);
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.endpointMeta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.endpointMeta span {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
}
.endpointMeta code,
.cardMeta { .cardMeta {
@include text-caption; @include text-caption;
}
.endpointMeta code {
padding: 0.125rem 0.5rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface-secondary) 90%, transparent);
color: var(--color-text);
}
.form {
display: grid;
gap: var(--space-3);
}
.field {
display: grid;
gap: var(--space-1);
}
.fieldLabel {
@include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.field input,
.field select {
min-height: var(--control-size-md);
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
color: var(--color-text);
padding: 0 var(--space-3);
transition:
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
background 160ms var(--easing-standard);
}
.field input:focus-visible,
.field select:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--bootstrap-accent) 60%, var(--color-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.wizardFormActions,
.heroActions,
.primaryButton,
.secondaryButton {
display: flex;
align-items: center;
}
.wizardFormActions {
justify-content: space-between;
gap: var(--space-3);
flex-wrap: nowrap;
}
.wizardFormActions .primaryButton {
margin-left: auto;
}
.primaryButton,
.secondaryButton,
.wizardStepButton,
.wizardCloseButton {
appearance: none;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
border-radius: var(--radius-pill);
font-weight: 600;
cursor: pointer;
transition:
transform 180ms var(--easing-standard),
background 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.primaryButton,
.secondaryButton,
.wizardCloseButton {
padding: 0 var(--space-4);
}
.primaryButton {
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 72%, black 8%);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 88%, white 12%),
color-mix(in srgb, var(--bootstrap-accent) 92%, black 8%)
);
color: var(--bootstrap-accent-contrast);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 28%, transparent),
0 10px 24px color-mix(in srgb, var(--bootstrap-accent) 26%, transparent);
}
.secondaryButton,
.wizardCloseButton {
border: 1px solid var(--color-border);
background: var(--color-surface-secondary);
color: var(--color-text);
}
.primaryButton:hover,
.secondaryButton:hover,
.wizardCloseButton:hover,
.wizardStepButton:hover {
transform: translateY(-1px);
}
.primaryButton:hover,
.primaryButton:focus-visible {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bootstrap-accent) 84%, white 16%),
color-mix(in srgb, var(--bootstrap-accent) 90%, black 10%)
);
border-color: color-mix(in srgb, var(--bootstrap-accent) 78%, black 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
0 14px 32px color-mix(in srgb, var(--bootstrap-accent) 30%, transparent);
}
.primaryButton:disabled,
.secondaryButton:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.errorText {
@include text-caption;
color: var(--color-danger-text, var(--color-text));
}
.wizardLayer {
position: fixed;
inset: 0;
z-index: var(--z-modal);
}
.wizardBackdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 56%, transparent);
backdrop-filter: blur(var(--blur-overlay));
}
.wizardPanel {
position: relative;
z-index: 1;
width: min(calc(100vw - (var(--space-6) * 2)), 72rem);
max-height: calc(100dvh - (var(--space-6) * 2));
margin: var(--space-6) auto;
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-surface-elevated, var(--color-surface)) 6%);
box-shadow: var(--shadow-strong);
overflow: auto;
}
.wizardHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.wizardHeaderCopy {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.wizardBody {
display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: var(--space-4);
min-height: 0;
}
.wizardSidebar {
display: grid;
gap: var(--space-4);
align-content: start;
}
.wizardSidebarSection {
gap: var(--space-3);
}
.wizardSteps {
display: grid;
gap: var(--space-2);
}
.wizardStepButton {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
text-align: left;
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
}
.wizardStepButton[data-active="true"] {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface));
}
.wizardStepButton:disabled {
opacity: 0.56;
cursor: not-allowed;
transform: none;
}
.wizardStepIndex {
width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
color: var(--color-text);
}
.wizardStepCopy {
min-width: 0;
display: grid;
gap: 0.125rem;
}
.wizardStepCopy strong {
@include text-label;
}
.wizardStepCopy small {
@include text-caption;
color: var(--color-text-muted);
}
.wizardStepPanel {
align-content: start;
gap: var(--space-3);
}
@include respond-down(tablet) { @include respond-down(tablet) {
.grid { .summaryGrid,
.wizardBody {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sectionHeader,
.wizardHeader {
display: grid;
}
} }
@include respond-down(mobile) { @include respond-down(mobile) {
@@ -155,4 +513,19 @@
.workspaceTopBarCenter { .workspaceTopBarCenter {
justify-content: flex-start; justify-content: flex-start;
} }
.summaryGrid {
grid-template-columns: 1fr;
}
.wizardPanel {
width: calc(100vw - (var(--space-4) * 2));
max-height: calc(100dvh - (var(--space-4) * 2));
margin: var(--space-4) auto;
padding: var(--space-3);
}
.wizardFormActions {
gap: var(--space-2);
}
} }

View File

@@ -1,85 +1,473 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js"; import { For, Show, createMemo, createSignal, onMount, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
import { ChevronLeft, ChevronRight } from "../../../lib/icons"; import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { activeProject, activeServer } from "../../shell/data/shell.data"; import { useAppShellData } from "../../shell/data/app-shell.context";
import styles from "./WorkspaceHome.module.scss"; import styles from "./WorkspaceHome.module.scss";
type ShellCheckpointCard = { type BootstrapStepKey = "instance" | "mode" | "admin" | "structure";
type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string; title: string;
copy: string; buttonLabel: string;
meta: string;
}; };
const shellCheckpointCards: readonly ShellCheckpointCard[] = [ type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ {
title: "Server shell", id: "instance",
copy: "Top bar, server rail, sidebar, and content viewport are now split into modular components.", title: "Instance shape",
meta: "Layout foundation", buttonLabel: "Save and continue",
}, },
{ {
title: "Presence foundation", id: "mode",
copy: "The dock now distinguishes personal and organization servers, leaving clear space for future presence and server-aware controls.", title: "Server mode",
meta: "Server foundation", buttonLabel: "Save and continue",
}, },
{ {
title: "Next build target", id: "admin",
copy: "You can now plug in auth state, server onboarding, and live presence without redesigning the whole frame.", title: "Admin account",
meta: "Ready for v0.1.0 work", buttonLabel: "Save and continue",
},
{
id: "structure",
title: "Initial structure",
buttonLabel: "Submit",
}, },
]; ];
const bootstrapCompletionStorageKey = "moku.bootstrap.completed";
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const readBootstrapCompletion = (): boolean => {
if (typeof window === "undefined") {
return false;
}
return window.localStorage.getItem(bootstrapCompletionStorageKey) === "true";
};
const writeBootstrapCompletion = (isComplete: boolean): void => {
if (typeof window === "undefined") {
return;
}
if (isComplete) {
window.localStorage.setItem(bootstrapCompletionStorageKey, "true");
return;
}
window.localStorage.removeItem(bootstrapCompletionStorageKey);
};
const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
type WorkspaceHomeProps = { type WorkspaceHomeProps = {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
onToggleSidebarCollapse: () => void; onToggleSidebarCollapse: () => void;
}; };
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"); const appShellData = useAppShellData();
const breadcrumb = (): string => `${activeServer.name} / ${activeProject.name} / Home`; const [instanceForm, setInstanceForm] = createStore({
protocol: "http",
access: "local",
host: "localhost",
});
const [modeForm, setModeForm] = createStore({
mode: "personal",
});
const [adminForm, setAdminForm] = createStore({
displayName: "Ronald",
email: "admin@example.com",
password: "",
});
const [structureForm, setStructureForm] = createStore({
departmentName: "Platform",
teamName: "Core",
projectName: "Moku",
});
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
onMount(() => {
const isComplete = readBootstrapCompletion();
setIsBootstrapComplete(isComplete);
setIsWizardOpen(!isComplete);
setIsBootstrapStateResolved(true);
});
const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
const apiBase = (): string => resolveAPIBase();
const currentStep = createMemo<BootstrapStepDefinition>(
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
);
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete();
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(
typeof data?.error?.message === "string"
? data.error.message
: `Bootstrap ${step} request failed.`,
);
}
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (isLastStep()) {
writeBootstrapCompletion(true);
setIsBootstrapComplete(true);
setIsWizardOpen(false);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, bootstrapStepDefinitions.length - 1));
};
const handleCurrentStepSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (event): void => {
event.preventDefault();
void submitCurrentStep();
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "success":
return "Saved";
case "error":
return "Request failed";
default:
return "Ready";
}
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return ( return (
<main class={styles.viewport} data-ui="workspace-home"> <>
<div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar"> <main class={styles.viewport} data-ui="workspace-home">
<div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start"> <div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar">
<button <div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start">
type="button" <button
class={styles.workspaceCollapseButton} type="button"
aria-label={sidebarToggleLabel()} class={styles.workspaceCollapseButton}
title={sidebarToggleLabel()} aria-label={sidebarToggleLabel()}
data-slot="workspace-home-sidebar-toggle" title={sidebarToggleLabel()}
onClick={props.onToggleSidebarCollapse} data-slot="workspace-home-sidebar-toggle"
> onClick={props.onToggleSidebarCollapse}
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />} >
</button> {props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
</div>
<div class={styles.workspaceTopBarCenter} data-slot="workspace-home-top-bar-center">
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span>
</div>
<div class={styles.workspaceTopBarEnd} data-slot="workspace-home-top-bar-end" aria-hidden="true" />
</div> </div>
<div class={styles.workspaceTopBarCenter} data-slot="workspace-home-top-bar-center"> <section class={styles.hero} data-slot="workspace-home-hero">
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span> <span class={styles.eyebrow}>Bootstrap</span>
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
Open bootstrap wizard
</button>
</div>
</Show>
</section>
</main>
<Show when={isBootstrapStateResolved() && isWizardOpen()}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={currentStep().id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap {appShellData.activeServer().name}
</h2>
</div>
<Show when={canDismissWizard()}>
<button type="button" class={styles.wizardCloseButton} onClick={(): void => setIsWizardOpen(false)}>
Close
</button>
</Show>
</header>
<div class={styles.wizardBody}>
<aside class={styles.wizardSidebar} data-slot="bootstrap-wizard-sidebar">
<nav class={styles.wizardSteps} aria-label="Bootstrap steps">
<For each={bootstrapStepDefinitions}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardStepButton}
data-active={step.id === currentStep().id ? "true" : "false"}
disabled={index() > currentStepIndex()}
onClick={(): void => {
if (index() <= currentStepIndex()) {
setCurrentStepIndex(index());
}
}}
>
<span class={styles.wizardStepIndex}>{index() + 1}</span>
<span class={styles.wizardStepCopy}>
<strong>{step.title}</strong>
<Show when={stepStatusLabel(step)}>
<small>{stepStatusLabel(step)}</small>
</Show>
</span>
</button>
)}
</For>
</nav>
</aside>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<div class={styles.sectionHeader}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div>
<div class={styles.statusBadge} data-status={currentStepState().status}>{statusLabel(currentStepState())}</div>
</div> </div>
<div class={styles.workspaceTopBarEnd} data-slot="workspace-home-top-bar-end" aria-hidden="true" /> <form class={styles.form} onSubmit={handleCurrentStepSubmit}>
</div> <Show when={currentStep().id === "instance"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Protocol</span>
<select value={instanceForm.protocol} onInput={(event): void => setInstanceForm("protocol", event.currentTarget.value)}>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Access</span>
<select value={instanceForm.access} onInput={(event): void => setInstanceForm("access", event.currentTarget.value)}>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Host</span>
<input
type="text"
value={instanceForm.host}
onInput={(event): void => setInstanceForm("host", event.currentTarget.value)}
placeholder="localhost or app.example.com"
/>
</label>
</>
</Show>
<section class={styles.hero} data-slot="workspace-home-hero"> <Show when={currentStep().id === "mode"}>
<span class={styles.eyebrow}>Server home</span> <label class={styles.field}>
<h1 class={styles.title}>{activeServer.name} is ready for its first real shell.</h1> <span class={styles.fieldLabel}>Mode</span>
<p class={styles.description}> <select value={modeForm.mode} onInput={(event): void => setModeForm("mode", event.currentTarget.value)}>
This is the barebone app frame for v0.1.0 enough structure to start building a real self-hosted server experience on top of the backend core. <option value="personal">personal</option>
</p> <option value="organizational">organizational</option>
</section> </select>
</label>
</Show>
<section class={styles.grid} aria-label="Shell checkpoints" data-slot="workspace-home-grid"> <Show when={currentStep().id === "admin"}>
<For each={shellCheckpointCards}> <>
{(card): JSX.Element => ( <label class={styles.field}>
<article class={styles.card} data-slot="workspace-home-card"> <span class={styles.fieldLabel}>Display name</span>
<h2 class={styles.cardTitle}>{card.title}</h2> <input
<p class={styles.cardCopy}>{card.copy}</p> type="text"
<span class={styles.cardMeta}>{card.meta}</span> value={adminForm.displayName}
</article> onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
)} placeholder="First admin"
</For> />
</section> </label>
</main> <label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input
type="email"
value={adminForm.email}
onInput={(event): void => setAdminForm("email", event.currentTarget.value)}
placeholder="admin@example.com"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input
type="password"
value={adminForm.password}
onInput={(event): void => setAdminForm("password", event.currentTarget.value)}
placeholder="Temporary for echo testing"
/>
</label>
</>
</Show>
<Show when={currentStep().id === "structure"}>
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Department</span>
<input
type="text"
value={structureForm.departmentName}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
placeholder="Platform"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Team</span>
<input
type="text"
value={structureForm.teamName}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder="Core"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Project</span>
<input
type="text"
value={structureForm.projectName}
onInput={(event): void => setStructureForm("projectName", event.currentTarget.value)}
placeholder="Moku"
/>
</label>
</>
</Show>
<div class={styles.wizardFormActions}>
<button
type="button"
class={styles.secondaryButton}
disabled={isFirstStep()}
onClick={(): void => setCurrentStepIndex((index) => Math.max(index - 1, 0))}
>
Back
</button>
<button
type="submit"
class={styles.primaryButton}
disabled={currentStepState().status === "submitting"}
>
{currentStep().buttonLabel}
</button>
</div>
</form>
<Show when={currentStepState().error}>
<p class={styles.errorText}>{currentStepState().error}</p>
</Show>
</div>
</div>
</section>
</div>
</Portal>
</Show>
</>
); );
}; };

View File

@@ -1,3 +1,11 @@
// Path: Frontend/src/global.d.ts // Path: Frontend/src/global.d.ts
/// <reference types="@solidjs/start/env" /> /// <reference types="@solidjs/start/env" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

11
Frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,11 @@
// Path: Frontend/src/lib/api.ts
export const resolveAPIBase = (): string => {
const configuredBase = import.meta.env.VITE_API_BASE_URL?.trim();
if (!configuredBase) {
return "/v1";
}
return configuredBase.replace(/\/$/, "");
};