diff --git a/Frontend/src/app.tsx b/Frontend/src/app.tsx index 50b94e2..6a0d34c 100644 --- a/Frontend/src/app.tsx +++ b/Frontend/src/app.tsx @@ -1,7 +1,7 @@ // Path: Frontend/src/app.tsx import type { JSX } from "solid-js"; -import { AppShell } from "./components/shell/AppShell/AppShell"; +import { AppShell } from "./components/app-shell/AppShell/AppShell"; import "./styles/main.scss"; import "./styles/user-overrides.scss"; diff --git a/Frontend/src/components/shell/AppShell/AppShell.module.scss b/Frontend/src/components/app-shell/AppShell/AppShell.module.scss similarity index 100% rename from Frontend/src/components/shell/AppShell/AppShell.module.scss rename to Frontend/src/components/app-shell/AppShell/AppShell.module.scss diff --git a/Frontend/src/components/shell/AppShell/AppShell.tsx b/Frontend/src/components/app-shell/AppShell/AppShell.tsx similarity index 81% rename from Frontend/src/components/shell/AppShell/AppShell.tsx rename to Frontend/src/components/app-shell/AppShell/AppShell.tsx index 9365125..2e5baa0 100644 --- a/Frontend/src/components/shell/AppShell/AppShell.tsx +++ b/Frontend/src/components/app-shell/AppShell/AppShell.tsx @@ -1,17 +1,19 @@ -// Path: Frontend/src/components/shell/AppShell/AppShell.tsx +// Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx 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 { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime"; +import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard"; +import { WorkspaceHome } from "../../workspace-home/WorkspaceHome"; import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context"; -import { LeftRail } from "../LeftRail/LeftRail"; +import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail"; import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav"; -import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser"; +import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser"; import { ServerDock } from "../ServerDock/ServerDock"; -import { NotificationsMenu } from "../TopBar/NotificationsMenu"; -import { ProfileMenu } from "../TopBar/ProfileMenu"; -import { TopBar } from "../TopBar/TopBar"; -import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar"; +import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu"; +import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu"; +import { TopBar } from "../../top-bar/TopBar/TopBar"; +import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar"; +import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar"; import styles from "./AppShell.module.scss"; type MobileWorkspaceView = "notifications" | "profile" | null; @@ -80,6 +82,8 @@ const AppShellContent = (): JSX.Element => { setActiveMobileWorkspaceView(null); }; + const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; + return (
{ { - setIsSidebarCollapsed((collapsed) => !collapsed); - }} - /> + <> + { + setIsSidebarCollapsed((collapsed) => !collapsed); + }} + /> + + } >
@@ -162,6 +170,7 @@ const AppShellContent = (): JSX.Element => { setIsMobileWorkspaceBrowserOpen(false); }} /> +
); }; diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss similarity index 100% rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.module.scss rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.module.scss diff --git a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx similarity index 95% rename from Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx rename to Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx index cfb1711..84ef430 100644 --- a/Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx +++ b/Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx @@ -1,4 +1,4 @@ -// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx +// Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx import { For, type JSX } from "solid-js"; import { useAppShellData } from "../data/app-shell.context"; diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.module.scss b/Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss similarity index 100% rename from Frontend/src/components/shell/ServerDock/ServerDock.module.scss rename to Frontend/src/components/app-shell/ServerDock/ServerDock.module.scss diff --git a/Frontend/src/components/shell/ServerDock/ServerDock.tsx b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx similarity index 95% rename from Frontend/src/components/shell/ServerDock/ServerDock.tsx rename to Frontend/src/components/app-shell/ServerDock/ServerDock.tsx index 580abc7..2cd568e 100644 --- a/Frontend/src/components/shell/ServerDock/ServerDock.tsx +++ b/Frontend/src/components/app-shell/ServerDock/ServerDock.tsx @@ -1,4 +1,4 @@ -// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx +// Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx import { For, Show, type JSX } from "solid-js"; import { useAppShellData } from "../data/app-shell.context"; diff --git a/Frontend/src/components/app-shell/data/app-shell.builders.ts b/Frontend/src/components/app-shell/data/app-shell.builders.ts new file mode 100644 index 0000000..f6ecd55 --- /dev/null +++ b/Frontend/src/components/app-shell/data/app-shell.builders.ts @@ -0,0 +1,169 @@ +// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts + +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"; +import type { AppShellPayload } from "./app-shell.types"; + +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; +}; + +export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => { + if (!payload?.installation || payload.organizations.length === 0) { + return fallbackRailItems; + } + + const kind = payload.installation.mode === "personal" ? "personal" : "organization"; + const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host; + + return payload.organizations.map((organization, index) => ({ + id: organization.id, + label: serverName || organization.name, + abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"), + kind, + active: index === 0, + })); +}; + +export 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"; + const serverName = installation.name || organization.name || installation.host; + + return { + id: installation.id, + name: serverName || fallbackActiveServer.name, + abbreviation: buildAbbreviation(serverName, 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, + }; +}; + +export 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", + groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects", + parentLabel: + payload.teams.find((team) => team.id === project.teamId)?.name || + payload.departments.find((department) => department.id === project.departmentId)?.name || + "Shared project", + meta: (() => { + const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length; + + return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined; + })(), + active: index === 0, + })); +}; + +export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => { + const firstProject = payload?.projects[0]; + + if (!firstProject) { + return fallbackActiveProject; + } + + return { + id: firstProject.id, + name: firstProject.name, + }; +}; + +export 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, + })); +}; + +export 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, + }; +}; + +export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => { + if (!payload?.projects.length) { + return fallbackWorkspaceTree; + } + + return []; +}; + +export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => { + if (!payload?.admin) { + return fallbackActiveUserProfile; + } + + const organizationName = payload.installation?.name || 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, + }; +}; diff --git a/Frontend/src/components/app-shell/data/app-shell.context.tsx b/Frontend/src/components/app-shell/data/app-shell.context.tsx new file mode 100644 index 0000000..75c1a6c --- /dev/null +++ b/Frontend/src/components/app-shell/data/app-shell.context.tsx @@ -0,0 +1,97 @@ +// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx + +import { + createContext, + createMemo, + createSignal, + onMount, + useContext, + type JSX, +} from "solid-js"; +import { resolveAPIBase } from "../../../lib/api"; +import { + buildActiveDepartment, + buildActiveProject, + buildActiveServer, + buildActiveUserProfile, + buildDepartmentItems, + buildProjectItems, + buildRailItems, + buildWorkspaceTree, +} from "./app-shell.builders"; +import type { AppShellContextValue, AppShellPayload } from "./app-shell.types"; +import { normalizeAppShellPayload } from "./app-shell.types"; + +const AppShellContext = createContext(); + +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(null); + + const load = async (): Promise => { + 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 } | string; + message?: string; + }; + const errorMessage = + typeof body.message === "string" + ? body.message + : typeof body.error === "string" + ? body.error + : body.error?.message; + + if (!response.ok || !body.data) { + throw new Error(errorMessage || "Failed to load app shell state."); + } + + setPayload(normalizeAppShellPayload(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, + installation: createMemo(() => payload()?.installation), + 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 {props.children}; +}; + +export const useAppShellData = (): AppShellContextValue => { + const context = useContext(AppShellContext); + + if (!context) { + throw new Error("useAppShellData must be used within AppShellDataProvider"); + } + + return context; +}; diff --git a/Frontend/src/components/app-shell/data/app-shell.types.ts b/Frontend/src/components/app-shell/data/app-shell.types.ts new file mode 100644 index 0000000..b4103e6 --- /dev/null +++ b/Frontend/src/components/app-shell/data/app-shell.types.ts @@ -0,0 +1,130 @@ +// Path: Frontend/src/components/app-shell/data/app-shell.types.ts + +import type { Accessor } from "solid-js"; +import type { + ActiveDepartment, + ActiveProject, + ActiveServer, + ActiveUserProfile, + DepartmentItem, + ProjectItem, + RailItem, + WorkspaceTreeNode, +} from "./shell.data"; + +export type AppShellInstallation = { + id: string; + name: string; + mode: "personal" | "organizational" | string; + access: string; + protocol: string; + host: string; + isBootstrapped: boolean; + materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string; + materializationError?: string; +}; + +export type AppShellAdmin = { + id: string; + email: string; + displayName: string; + isInstanceAdmin: boolean; + homeTitle: string; +}; + +export type AppShellOrganization = { + id: string; + name: string; + slug: string; +}; + +export type AppShellDepartment = { + id: string; + organizationId: string; + name: string; + slug: string; +}; + +export type AppShellTeam = { + id: string; + organizationId: string; + departmentId?: string; + name: string; + slug: string; +}; + +export type AppShellProject = { + id: string; + organizationId: string; + departmentId?: string; + teamId?: string; + name: string; + slug: string; +}; + +export type AppShellWorkspace = { + id: string; + organizationId: string; + name: string; + slug: string; + kind: "organization" | "department" | "team" | "project" | string; + departmentId?: string; + teamId?: string; + projectId?: string; +}; + +export type AppShellPayload = { + installation?: AppShellInstallation; + admin?: AppShellAdmin; + organizations: AppShellOrganization[]; + departments: AppShellDepartment[]; + teams: AppShellTeam[]; + projects: AppShellProject[]; + workspaces: AppShellWorkspace[]; +}; + +export type AppShellContextValue = { + status: Accessor<"idle" | "loading" | "success" | "error">; + error: Accessor; + installation: Accessor; + railItems: Accessor; + activeServer: Accessor; + activeProject: Accessor; + activeDepartment: Accessor; + projectItems: Accessor; + departmentItems: Accessor; + workspaceTree: Accessor; + activeUserProfile: Accessor; + reload: () => Promise; +}; + +export const normalizeInstallation = ( + installation: AppShellInstallation | null | undefined, +): AppShellInstallation | undefined => { + if (!installation) { + return undefined; + } + + const materializationStatus = installation.materializationStatus?.trim() + ? installation.materializationStatus + : installation.isBootstrapped + ? "succeeded" + : "not_started"; + const materializationError = installation.materializationError?.trim() || undefined; + + return { + ...installation, + materializationStatus, + materializationError, + }; +}; + +export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({ + installation: normalizeInstallation(payload?.installation), + admin: payload?.admin, + organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], + departments: Array.isArray(payload?.departments) ? payload.departments : [], + teams: Array.isArray(payload?.teams) ? payload.teams : [], + projects: Array.isArray(payload?.projects) ? payload.projects : [], + workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [], +}); diff --git a/Frontend/src/components/app-shell/data/shell.context-menu.ts b/Frontend/src/components/app-shell/data/shell.context-menu.ts new file mode 100644 index 0000000..48e2c83 --- /dev/null +++ b/Frontend/src/components/app-shell/data/shell.context-menu.ts @@ -0,0 +1,174 @@ +// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts + +import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry"; +import type { + ActiveProject, + ProjectContextMenuAction, + ProjectContextMenuSection, + ProjectItem, + ProjectMenuTarget, + WorkspaceContextMenuSection, + WorkspaceContextMenuTarget, + WorkspaceStaticItem, + WorkspaceTreeNode, +} from "./shell.types"; + +export { getWorkspaceContextMenuEyebrow } from "./shell.registry"; + +export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({ + id: `workspace-${workspace.id}`, + label: workspace.name, + kind: "workspace", +}); + +export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({ + id: item.id, + label: item.label, + kind: item.contextKind, +}); + +export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({ + id: node.id, + label: node.label, + ...(node.kind === "folder" + ? { kind: "folder" as const } + : { + kind: "item" as const, + itemType: node.itemType, + }), +}); + +export const getWorkspaceContextMenuSections = ( + target: WorkspaceContextMenuTarget, +): readonly WorkspaceContextMenuSection[] => { + const createActions = getWorkspaceCreateActions(); + const createSubmenuAction = { + id: "create", + label: "Create", + children: createActions, + } as const; + + switch (target.kind) { + case "workspace": + return [ + { id: "create", label: undefined, items: [createSubmenuAction] }, + { + id: "workspace", + label: undefined, + items: [ + { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } }, + { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, + ], + }, + ] as const; + case "home": + return [ + { id: "create", label: undefined, items: [createSubmenuAction] }, + { id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] }, + ] as const; + case "settings": + return [ + { + id: "settings", + label: undefined, + items: [ + { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } }, + { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, + ], + }, + ] as const; + case "folder": + return [ + { id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] }, + { id: "create", label: undefined, items: [createSubmenuAction] }, + { + id: "organize", + label: undefined, + items: [ + { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } }, + { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ], + }, + ] as const; + case "item": { + const definition = getWorkspaceItemTypeDefinition(target.itemType); + const actionPrefix = definition.actionPrefix; + const nounLabel = definition.noun; + + return [ + { + id: `${actionPrefix}-primary`, + items: [ + { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } }, + { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, + ], + }, + { + id: "organize", + label: undefined, + items: [ + { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } }, + { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ], + }, + ] as const; + } + } +}; + +const getProjectCreateActions = (): readonly ProjectContextMenuAction[] => + [ + { id: "new-project", label: "New project" }, + { id: "new-folder", label: "New folder" }, + ] as const; + +const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] => + [ + { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, + { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, + ] as const; + +export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({ + id: "project-surface", + label, + kind: "surface", +}); + +export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({ + id, + label, + kind: "folder", +}); + +export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({ + id: project.id, + label: project.name, + kind: "project", +}); + +export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => { + switch (target.kind) { + case "surface": + return "Projects"; + case "folder": + return "Folder"; + case "project": + return "Project"; + } +}; + +export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => { + const createActions = getProjectCreateActions(); + + switch (target.kind) { + case "surface": + return [{ id: "create", items: createActions }] as const; + case "folder": + return [ + { id: "create", items: createActions }, + { id: "organize", items: getProjectFolderDangerActions() }, + ] as const; + case "project": + return [{ id: "create", items: createActions }] as const; + } +}; diff --git a/Frontend/src/components/app-shell/data/shell.data.ts b/Frontend/src/components/app-shell/data/shell.data.ts new file mode 100644 index 0000000..7dbbfdb --- /dev/null +++ b/Frontend/src/components/app-shell/data/shell.data.ts @@ -0,0 +1,6 @@ +// Path: Frontend/src/components/app-shell/data/shell.data.ts + +export * from "./shell.types"; +export * from "./shell.registry"; +export * from "./shell.scaffold"; +export * from "./shell.context-menu"; diff --git a/Frontend/src/components/app-shell/data/shell.registry.ts b/Frontend/src/components/app-shell/data/shell.registry.ts new file mode 100644 index 0000000..83d3e4a --- /dev/null +++ b/Frontend/src/components/app-shell/data/shell.registry.ts @@ -0,0 +1,100 @@ +// Path: Frontend/src/components/app-shell/data/shell.registry.ts + +import { FileText, LayoutGrid } from "../../../lib/icons"; +import type { + ShellIcon, + WorkspaceContextMenuAction, + WorkspaceContextMenuTarget, + WorkspaceItemTypeDefinition, + WorkspaceItemTypeId, + WorkspaceTreeNode, +} from "./shell.types"; + +export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [ + { + id: "core.doc", + label: "Doc", + shortLabel: "Doc", + icon: FileText, + noun: "doc", + actionPrefix: "doc", + defaultCreateLabel: "New doc", + includeInWorkspaceCreate: true, + description: "Rich text documents and notes.", + }, + { + id: "core.board.kanban", + label: "Kanban board", + shortLabel: "Board", + icon: LayoutGrid, + noun: "board", + actionPrefix: "board", + defaultCreateLabel: "New board", + includeInWorkspaceCreate: true, + description: "Default board-style workspace item.", + }, + { + id: "core.board.list", + label: "List board", + shortLabel: "Board", + icon: LayoutGrid, + noun: "board", + actionPrefix: "list-board", + defaultCreateLabel: "New list board", + description: "Alternate first-party board view prepared for the future registry.", + }, +] as const; + +const workspaceItemTypeMap = new Map( + firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]), +); + +const createUnknownWorkspaceItemTypeDefinition = ( + itemType: WorkspaceItemTypeId, +): WorkspaceItemTypeDefinition => ({ + id: itemType, + label: "Item", + shortLabel: "Item", + icon: FileText, + noun: "item", + actionPrefix: "item", + defaultCreateLabel: "New item", + description: "Fallback definition for unknown or future workspace item types.", +}); + +export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => { + return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType); +}; + +export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon => + node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon; + +export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [ + { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } }, + ...firstPartyWorkspaceItemTypes + .filter((definition) => definition.includeInWorkspaceCreate) + .map((definition) => ({ + id: `create-${definition.actionPrefix}`, + label: definition.defaultCreateLabel, + shortcut: + definition.id === "core.board.kanban" + ? ({ modifiers: ["alt"], key: "b" } as const) + : definition.id === "core.doc" + ? ({ modifiers: ["alt"], key: "d" } as const) + : undefined, + })), +]; + +export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => { + switch (target.kind) { + case "workspace": + case "home": + return "Workspace"; + case "settings": + return "Configuration"; + case "folder": + return "Folder"; + case "item": + return getWorkspaceItemTypeDefinition(target.itemType).shortLabel; + } +}; diff --git a/Frontend/src/components/app-shell/data/shell.scaffold.ts b/Frontend/src/components/app-shell/data/shell.scaffold.ts new file mode 100644 index 0000000..7025cde --- /dev/null +++ b/Frontend/src/components/app-shell/data/shell.scaffold.ts @@ -0,0 +1,222 @@ +// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts + +import { + Bell, + CircleHelp, + FileText, + Folder, + Home, + Keyboard, + LayoutGrid, + ListCollapse, + LogOut, + Repeat, + Search, + Settings, + Shield, + User, +} from "../../../lib/icons"; +import type { + ActiveDepartment, + ActiveProject, + ActiveServer, + ActiveUserProfile, + DepartmentItem, + MobileBottomNavItem, + NotificationItem, + ProfileMenuSection, + ProjectItem, + RailItem, + ServerDockAction, + SidebarHeaderAction, + TopBarAction, + WorkspaceStaticItem, + WorkspaceTreeNode, +} from "./shell.types"; + +export const personalDockActions: readonly ServerDockAction[] = [ + { id: "account", label: "Account", icon: User }, + { id: "settings", label: "Settings", icon: Settings }, +] as const; + +export const organizationAdminDockActions: readonly ServerDockAction[] = [ + { id: "members", label: "Members", icon: User }, + { id: "server", label: "Server", icon: Settings }, +] as const; + +export const railItems: readonly RailItem[] = [ + { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" }, + { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true }, + { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" }, +] as const; + +export const activeServer: ActiveServer = { + id: "organization-server", + name: "Organization Name", + abbreviation: "O", + kind: "organization", + connectedLabel: "12 connected", + dockActions: organizationAdminDockActions, +}; + +export const activeProject: ActiveProject = { + id: "general", + name: "General", +}; + +export const activeDepartment: ActiveDepartment = { + id: "product", + name: "Product", + teamName: "Design Systems", +}; + +export const projectItems: readonly ProjectItem[] = [ + { + id: "general", + name: "General", + description: "Default shared project", + groupLabel: "Shared space", + parentLabel: "Workspace home", + meta: "1 workspace", + active: true, + }, + { + id: "operations", + name: "Operations", + description: "Cross-team planning and delivery", + groupLabel: "Team folders", + parentLabel: "Shared Services", + meta: "2 workspaces", + }, + { + id: "hiring", + name: "Hiring", + description: "Candidate pipeline and interview loops", + groupLabel: "Team folders", + parentLabel: "People Ops", + meta: "1 workspace", + }, +] as const; + +export const departmentItems: readonly DepartmentItem[] = [ + { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true }, + { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] }, + { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] }, +] as const; + +export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [ + { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" }, + { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" }, +] as const; + +export const workspaceTree: readonly WorkspaceTreeNode[] = [ + { + id: "product-workspace", + label: "Product", + kind: "folder", + icon: Folder, + children: [ + { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true }, + { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" }, + { + id: "research-folder", + label: "Research", + kind: "folder", + icon: Folder, + children: [ + { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" }, + { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" }, + ], + }, + ], + }, + { + id: "design-folder", + label: "Design", + kind: "folder", + icon: Folder, + children: [ + { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" }, + { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" }, + ], + }, + { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" }, +] as const; + +export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [ + { id: "search-workspace", label: "Search workspace", icon: Search }, + { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse }, +] as const; + +export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [ + { id: "home", label: "Home", icon: Home, active: true }, + { id: "search", label: "Search", icon: Search }, + { id: "browse", label: "Browse", icon: Folder }, +] as const; + +export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const; + +export const notificationItems: readonly NotificationItem[] = [ + { + id: "comment-design-systems", + title: "New comment on Design Systems", + contextLabel: "Product • Review thread updated", + timeLabel: "2m ago", + unread: true, + }, + { + id: "sprint-platform", + title: "Sprint updated in Platform", + contextLabel: "Engineering • Scope changed", + timeLabel: "15m ago", + unread: true, + }, + { + id: "member-joined", + title: "New member joined Operations", + contextLabel: "Organization Name • Access granted", + timeLabel: "1h ago", + }, + { + id: "daily-summary", + title: "Daily summary is ready", + contextLabel: "General • 8 updates across boards", + timeLabel: "Today, 8:00 AM", + }, +] as const; + +export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length; + +export const activeUserProfile: ActiveUserProfile = { + name: "Demo Account", + email: "demo@moku.work", + roleLabel: "Founder · Product", + contextLabel: "Organization Name • Design Systems", +}; + +export const profileMenuSections: readonly ProfileMenuSection[] = [ + { + id: "account", + items: [ + { id: "profile", label: "Profile", icon: User }, + { id: "account-settings", label: "Account Settings", icon: Settings }, + { id: "notifications", label: "Notifications", icon: Bell }, + { id: "security", label: "Security", icon: Shield }, + ], + }, + { + id: "preferences", + items: [ + { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard }, + { id: "theme-preferences", label: "Theme Preferences", icon: Settings }, + { id: "help-support", label: "Help & Support", icon: CircleHelp }, + ], + }, + { + id: "session", + items: [ + { id: "switch-account", label: "Switch Account", icon: Repeat }, + { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" }, + ], + }, +] as const; diff --git a/Frontend/src/components/app-shell/data/shell.types.ts b/Frontend/src/components/app-shell/data/shell.types.ts new file mode 100644 index 0000000..2fc993e --- /dev/null +++ b/Frontend/src/components/app-shell/data/shell.types.ts @@ -0,0 +1,233 @@ +// Path: Frontend/src/components/app-shell/data/shell.types.ts + +import type { Component } from "solid-js"; + +export type ShellIconProps = { + class?: string; + size?: number; + strokeWidth?: number; +}; + +export type ShellIcon = Component; + +export type RailItem = { + id: string; + label: string; + abbreviation: string; + kind: "personal" | "organization"; + active?: boolean; +}; + +export type ServerDockAction = { + id: string; + label: string; + icon: ShellIcon; +}; + +export type ActiveServer = { + id: string; + name: string; + abbreviation: string; + kind: "personal" | "organization"; + connectedLabel?: string; + subtitle?: string; + dockActions: readonly ServerDockAction[]; +}; + +export type ActiveProject = { + id: string; + name: string; +}; + +export type ActiveDepartment = { + id: string; + name: string; + teamName: string; +}; + +export type DepartmentItem = { + id: string; + name: string; + teams: readonly string[]; + active?: boolean; +}; + +export type ProjectItem = { + id: string; + name: string; + description: string; + groupLabel?: string; + parentLabel?: string; + meta?: string; + active?: boolean; +}; + +export type ProjectMenuTarget = + | { + id: string; + label: string; + kind: "surface"; + } + | { + id: string; + label: string; + kind: "folder"; + } + | { + id: string; + label: string; + kind: "project"; + }; + +export type ProjectContextMenuAction = { + id: string; + label: string; + tone?: "default" | "danger"; + shortcut?: WorkspaceContextMenuShortcut; + children?: readonly ProjectContextMenuAction[]; +}; + +export type ProjectContextMenuSection = { + id: string; + label?: string; + items: readonly ProjectContextMenuAction[]; +}; + +export type SidebarItem = { + id: string; + label: string; + icon: ShellIcon; + active?: boolean; + meta?: string; +}; + +export type WorkspaceStaticKind = "workspace" | "home" | "settings"; + +export type WorkspaceItemTypeId = string; + +export type WorkspaceStaticItem = SidebarItem & { + contextKind: WorkspaceStaticKind; +}; + +export type WorkspaceFolderNode = { + id: string; + path?: string; + label: string; + kind: "folder"; + icon: ShellIcon; + active?: boolean; + meta?: string; + children?: readonly WorkspaceTreeNode[]; +}; + +export type WorkspaceItemNode = { + id: string; + path?: string; + label: string; + kind: "item"; + itemType: WorkspaceItemTypeId; + active?: boolean; + meta?: string; + children?: undefined; +}; + +export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode; + +export type WorkspaceItemTypeDefinition = { + id: WorkspaceItemTypeId; + label: string; + shortLabel: string; + icon: ShellIcon; + noun: string; + actionPrefix: string; + defaultCreateLabel: string; + includeInWorkspaceCreate?: boolean; + description?: string; +}; + +export type SidebarHeaderAction = { + id: string; + label: string; + icon: ShellIcon; +}; + +export type TopBarAction = { + id: string; + label: string; + icon: ShellIcon; +}; + +export type MobileBottomNavItem = { + id: string; + label: string; + icon: ShellIcon; + active?: boolean; +}; + +export type WorkspaceContextMenuTarget = + | { + id: string; + label: string; + kind: WorkspaceStaticKind; + } + | { + id: string; + label: string; + kind: "folder"; + } + | { + id: string; + label: string; + kind: "item"; + itemType: WorkspaceItemTypeId; + }; + +export type WorkspaceContextMenuAction = { + id: string; + label: string; + tone?: "default" | "danger"; + shortcut?: WorkspaceContextMenuShortcut; + children?: readonly WorkspaceContextMenuAction[]; +}; + +export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift"; + +export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r"; + +export type WorkspaceContextMenuShortcut = { + modifiers?: readonly WorkspaceContextMenuShortcutModifier[]; + key: WorkspaceContextMenuShortcutKey; +}; + +export type WorkspaceContextMenuSection = { + id: string; + label?: string; + items: readonly WorkspaceContextMenuAction[]; +}; + +export type NotificationItem = { + id: string; + title: string; + contextLabel: string; + timeLabel: string; + unread?: boolean; +}; + +export type ProfileMenuAction = { + id: string; + label: string; + icon: ShellIcon; + tone?: "default" | "danger"; +}; + +export type ProfileMenuSection = { + id: string; + items: readonly ProfileMenuAction[]; +}; + +export type ActiveUserProfile = { + name: string; + email: string; + roleLabel: string; + contextLabel: string; +}; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts similarity index 98% rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts index 80b831f..d2eac7b 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts @@ -1,4 +1,4 @@ -// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts +// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure"; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts similarity index 87% rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts index e847214..eddfef0 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts @@ -1,8 +1,7 @@ -// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts +// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { createStore } from "solid-js/store"; -import { resolveAPIBase } from "../../../lib/api"; import { bootstrapPersonaDefinitions, bootstrapStepDefinitions, @@ -20,7 +19,8 @@ import { type InstanceForm, type ModeForm, type StructureForm, -} from "./WorkspaceHome.data"; +} from "./BootstrapWizard.data"; +import { submitBootstrapStepRequest } from "./bootstrapWizard.api"; type AppShellBootstrapAdapter = { installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined; @@ -49,67 +49,7 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({ const materializationPollIntervalMs = 2000; -const readResponseBody = async (response: Response): Promise => { - const raw = await response.text(); - - if (!raw.trim()) { - return null; - } - - try { - return JSON.parse(raw); - } catch { - return raw; - } -}; - -const readResponseError = (step: BootstrapStepKey, data: unknown): string => { - const fallback = `Bootstrap ${step} request failed.`; - - if (typeof data === "string") { - const message = data.trim(); - return message || fallback; - } - - if (!data || typeof data !== "object") { - return fallback; - } - - const record = data as { - error?: string; - message?: string; - requestId?: string; - }; - const message = typeof record.message === "string" ? record.message.trim() : ""; - const errorCode = typeof record.error === "string" ? record.error.trim() : ""; - const requestId = typeof record.requestId === "string" ? record.requestId.trim() : ""; - - if (!message && !errorCode && !requestId) { - return fallback; - } - - const details: string[] = []; - - if (errorCode) { - details.push(`code: ${errorCode}`); - } - - if (requestId) { - details.push(`request: ${requestId}`); - } - - if (message && details.length > 0) { - return `${message} (${details.join(", ")})`; - } - - if (message) { - return message; - } - - return `${fallback} (${details.join(", ")})`; -}; - -export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) => { +export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => { const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); @@ -303,7 +243,6 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) = }); }); - const apiBase = (): string => resolveAPIBase(); const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder; const bootstrapStepCount = createMemo(() => activeWizardSteps().length); const currentStep = createMemo(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!); @@ -342,19 +281,7 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) = 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(readResponseError(step, data)); - } + await submitBootstrapStepRequest(step, payload); setStepState(step, { status: "success", diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss similarity index 99% rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss index 1624c99..756611c 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss @@ -1,4 +1,4 @@ -/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */ +/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */ .viewport, .wizardLayer { diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx similarity index 98% rename from Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx rename to Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx index 74f5128..cc79a0e 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx @@ -1,3 +1,5 @@ +// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx + import { For, Show, type JSX } from "solid-js"; import { CircleHelp } from "../../../lib/icons"; import { @@ -11,8 +13,8 @@ import { type InstanceForm, type ModeForm, type StructureForm, -} from "./WorkspaceHome.data"; -import styles from "./WorkspaceHome.module.scss"; +} from "./BootstrapWizard.data"; +import styles from "./BootstrapWizard.module.scss"; type BootstrapSubmissionState = { status: "idle" | "submitting" | "success" | "error"; diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx new file mode 100644 index 0000000..813dfe1 --- /dev/null +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.tsx @@ -0,0 +1,107 @@ +import { type JSX } from "solid-js"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { useBootstrapWizard } from "./BootstrapWizard.hook"; +import { BootstrapWizardDialog } from "./BootstrapWizardDialog"; + +export const BootstrapWizard = (): JSX.Element => { + const appShellData = useAppShellData(); + const { + instanceForm, + setInstanceForm, + modeForm, + setModeForm, + adminForm, + setAdminForm, + structureForm, + setStructureForm, + selectedPersona, + hasChosenPersona, + stepState, + isBootstrapStateResolved, + isWizardOpen, + setIsWizardOpen, + setIsFinishingBootstrapFlow, + fieldTooltip, + materializationState, + isMaterializationInFlight, + hasMaterializationFailed, + showBootstrapFinishingState, + materializationStatusLabel, + materializationMessage, + selectedPersonaIsAvailable, + usesCondensedBootstrapFlow, + activeWizardSteps, + bootstrapNamePlaceholder, + bootstrapStepCount, + currentStep, + currentWizardStepIndex, + wizardProgressFillWidth, + currentStepState, + isFirstStep, + canDismissWizard, + handleCurrentStepSubmit, + applyPersonaSelection, + statusLabel, + showFieldTooltip, + hideFieldTooltip, + stepStatusLabel, + navigateBack, + navigateToVisibleStep, + } = useBootstrapWizard(appShellData); + + return ( + { + setIsWizardOpen(false); + }} + onCloseFinishingState={(): void => { + setIsFinishingBootstrapFlow(false); + setIsWizardOpen(false); + }} + onSubmit={handleCurrentStepSubmit} + onSelectPersona={applyPersonaSelection} + onProtocolChange={(value): void => setInstanceForm("protocol", value)} + onAccessChange={(value): void => setInstanceForm("access", value)} + onHostChange={(value): void => setInstanceForm("host", value)} + onModeNameChange={(value): void => setModeForm("name", value)} + onProjectNameChange={(value): void => setStructureForm("projectName", value)} + onTeamNameChange={(value): void => setStructureForm("teamName", value)} + onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)} + onAdminEmailChange={(value): void => setAdminForm("email", value)} + onAdminPasswordChange={(value): void => setAdminForm("password", value)} + onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)} + onShowTooltip={showFieldTooltip} + onHideTooltip={hideFieldTooltip} + statusLabel={statusLabel} + stepStatusLabel={stepStatusLabel} + onNavigateBack={navigateBack} + onSelectStep={navigateToVisibleStep} + /> + ); +}; diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx new file mode 100644 index 0000000..a94fd8c --- /dev/null +++ b/Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx @@ -0,0 +1,234 @@ +// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx + +import { Show, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { + type AdminForm, + type BootstrapPersona, + bootstrapPersonaDefinitions, + type BootstrapStepDefinition, + type BootstrapStepKey, + type InstanceForm, + type ModeForm, + type StructureForm, +} from "./BootstrapWizard.data"; +import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook"; +import { + BootstrapAdminStep, + BootstrapFinishingState, + BootstrapInstanceStep, + BootstrapModeStep, + BootstrapPersonaStep, + BootstrapStructureStep, + BootstrapWizardProgress, +} from "./BootstrapWizard.parts"; +import styles from "./BootstrapWizard.module.scss"; + +export type BootstrapWizardDialogProps = { + isOpen: boolean; + currentStep: BootstrapStepDefinition; + currentStepState: BootstrapSubmissionState; + activeWizardSteps: readonly BootstrapStepDefinition[]; + currentWizardStepIndex: number; + stepState: Record; + bootstrapStepCount: number; + wizardProgressFillWidth: string; + showBootstrapFinishingState: boolean; + materializationState: MaterializationState; + materializationStatusLabel: string; + materializationMessage: string; + isMaterializationInFlight: boolean; + hasMaterializationFailed: boolean; + instanceForm: InstanceForm; + modeForm: ModeForm; + adminForm: AdminForm; + structureForm: StructureForm; + selectedPersona: BootstrapPersona; + hasChosenPersona: boolean; + selectedPersonaIsAvailable: boolean; + usesCondensedBootstrapFlow: boolean; + bootstrapNamePlaceholder: string; + isFirstStep: boolean; + canDismissWizard: boolean; + fieldTooltip: FieldTooltipState | null; + onClose: () => void; + onCloseFinishingState: () => void; + onSubmit: JSX.EventHandlerUnion; + onSelectPersona: (persona: BootstrapPersona) => void; + onProtocolChange: (value: InstanceForm["protocol"]) => void; + onAccessChange: (value: InstanceForm["access"]) => void; + onHostChange: (value: string) => void; + onModeNameChange: (value: string) => void; + onProjectNameChange: (value: string) => void; + onTeamNameChange: (value: string) => void; + onAdminDisplayNameChange: (value: string) => void; + onAdminEmailChange: (value: string) => void; + onAdminPasswordChange: (value: string) => void; + onDepartmentNameChange: (value: string) => void; + onShowTooltip: (target: HTMLElement, text: string) => void; + onHideTooltip: () => void; + statusLabel: (state: BootstrapSubmissionState) => string; + stepStatusLabel: (step: BootstrapStepDefinition) => string; + onNavigateBack: () => void; + onSelectStep: (index: number) => void; +}; + +export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => ( + + +
+ + + +); diff --git a/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts new file mode 100644 index 0000000..2ee6d05 --- /dev/null +++ b/Frontend/src/components/bootstrap/BootstrapWizard/bootstrapWizard.api.ts @@ -0,0 +1,81 @@ +import { resolveAPIBase } from "../../../lib/api"; +import type { BootstrapStepKey } from "./BootstrapWizard.data"; + +const readBootstrapResponseBody = async (response: Response): Promise => { + const raw = await response.text(); + + if (!raw.trim()) { + return null; + } + + try { + return JSON.parse(raw); + } catch { + return raw; + } +}; + +const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => { + const fallback = `Bootstrap ${step} request failed.`; + + if (typeof data === "string") { + const message = data.trim(); + return message || fallback; + } + + if (!data || typeof data !== "object") { + return fallback; + } + + const record = data as { + error?: string; + message?: string; + requestId?: string; + }; + const message = typeof record.message === "string" ? record.message.trim() : ""; + const errorCode = typeof record.error === "string" ? record.error.trim() : ""; + const requestId = typeof record.requestId === "string" ? record.requestId.trim() : ""; + + if (!message && !errorCode && !requestId) { + return fallback; + } + + const details: string[] = []; + + if (errorCode) { + details.push(`code: ${errorCode}`); + } + + if (requestId) { + details.push(`request: ${requestId}`); + } + + if (message && details.length > 0) { + return `${message} (${details.join(", ")})`; + } + + if (message) { + return message; + } + + return `${fallback} (${details.join(", ")})`; +}; + +export const submitBootstrapStepRequest = async ( + step: BootstrapStepKey, + payload: unknown, +): Promise => { + const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await readBootstrapResponseBody(response); + + if (!response.ok) { + throw new Error(readBootstrapResponseError(step, data)); + } +}; diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx deleted file mode 100644 index 79efa1b..0000000 --- a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { For, Show, createSignal, type JSX } from "solid-js"; -import { Portal } from "solid-js/web"; -import { ChevronRight, Plus, X } from "../../../lib/icons"; -import { useAppShellData } from "../data/app-shell.context"; -import { createLongPressGesture } from "../createLongPressGesture"; -import { - createWorkspaceStaticTarget, - createWorkspaceSurfaceTarget, - createWorkspaceTreeTarget, - getWorkspaceItemTypeDefinition, - getWorkspaceNodeIcon, - workspaceStaticItems, - type SidebarItem, - type WorkspaceContextMenuAction, - type WorkspaceContextMenuTarget, - type WorkspaceItemTypeId, - type WorkspaceStaticItem, - type WorkspaceTreeNode, -} from "../data/shell.data"; -import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData"; -import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet"; -import styles from "./MobileWorkspaceBrowser.module.scss"; - -type MobileWorkspaceBrowserProps = { - open: boolean; - onClose: VoidFunction; -}; - -type MobileWorkspaceDialogState = - | { - kind: "text"; - title: string; - message: string; - confirmLabel: string; - initialValue: string; - onConfirm: (value: string) => void; - } - | { - kind: "confirm"; - title: string; - message: string; - confirmLabel: string; - tone?: "danger"; - onConfirm: () => void; - }; - -type MobileMoveTargetState = { - kind: "folder" | "item"; - id: string; - label: string; -}; - -type MobileMoveDestination = { - id: string | null; - label: string; - depth: number; - meta?: string; -}; - -const collectMoveDestinations = ( - nodes: readonly WorkspaceTreeNode[], - movingTarget: MobileMoveTargetState, - depth = 0, - ancestorBlocked = false, -): MobileMoveDestination[] => { - const destinations: MobileMoveDestination[] = []; - - for (const node of nodes) { - if (node.kind !== "folder") { - continue; - } - - const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id; - if (!ancestorBlocked && !isBlockedFolder) { - destinations.push({ - id: node.id, - label: node.label, - depth, - meta: "Folder", - }); - } - - destinations.push( - ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder), - ); - } - - return destinations; -}; - -const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => { - for (const node of nodes) { - if (node.id === nodeId) { - return node; - } - - if (node.kind !== "folder") { - continue; - } - - const nestedMatch = findTreeNodeById(node.children ?? [], nodeId); - if (nestedMatch) { - return nestedMatch; - } - } - - return null; -}; - -const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger"; - -const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => { - const depth = props.depth ?? 0; - const Icon = getWorkspaceNodeIcon(props.node); - const hasChildren = (props.node.children?.length ?? 0) > 0; - - return ( - - ); -}; - -const StaticRow = (props: { item: SidebarItem }): JSX.Element => { - const Icon = props.item.icon; - - return ( - - ); -}; - -const WorkspaceStaticRow = (props: { - item: WorkspaceStaticItem; - onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; -}): JSX.Element => { - const target = createWorkspaceStaticTarget(props.item); - const longPress = createLongPressGesture({ - onLongPress: () => { - props.onOpenActionSheet(target); - }, - }); - - return ( -
  • { - event.preventDefault(); - props.onOpenActionSheet(target); - }} - {...longPress} - > - -
  • - ); -}; - -const WorkspaceTreeBranch = (props: { - nodes: readonly WorkspaceTreeNode[]; - depth?: number; - onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; -}): JSX.Element => { - const depth = props.depth ?? 0; - - return ( - - {(node): JSX.Element => { - const target = createWorkspaceTreeTarget(node); - const longPress = createLongPressGesture({ - onLongPress: () => { - props.onOpenActionSheet(target); - }, - }); - - return ( -
  • { - event.preventDefault(); - props.onOpenActionSheet(target); - }} - {...longPress} - > - - - -
      - -
    -
    -
  • - ); - }} -
    - ); -}; - -export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => { - const appShellData = useAppShellData(); - const [actionSheetTarget, setActionSheetTarget] = createSignal(null); - const [dialogState, setDialogState] = createSignal(null); - const [dialogValue, setDialogValue] = createSignal(""); - const [moveTarget, setMoveTarget] = createSignal(null); - const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({ - activeProjectId: () => appShellData.activeProject().id, - fallbackWorkspaceTree: () => appShellData.workspaceTree(), - }); - const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject()); - - const moveDestinations = () => { - const target = moveTarget(); - if (!target) { - return [] as MobileMoveDestination[]; - } - - return [ - { id: null, label: "Items root", depth: 0, meta: "Root" }, - ...collectMoveDestinations(workspaceTreeNodes(), target), - ]; - }; - - const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => { - switch (actionId) { - case "create-doc": - return "core.doc"; - case "create-board": - return "core.board.kanban"; - case "create-list-board": - return "core.board.list"; - default: - return null; - } - }; - - const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => { - const definition = getWorkspaceItemTypeDefinition(itemType); - void createItem(definition.defaultCreateLabel, itemType, parentId); - }; - - const openActionSheet = (target: WorkspaceContextMenuTarget): void => { - setActionSheetTarget(target); - }; - - const closeActionSheet = (): void => { - setActionSheetTarget(null); - }; - - const closeMoveSheet = (): void => { - setMoveTarget(null); - }; - - const openWorkspaceActionSheet = (): void => { - openActionSheet(workspaceTarget()); - }; - - const closeDialog = (): void => { - setDialogState(null); - setDialogValue(""); - }; - - const openMoveSheet = (target: MobileMoveTargetState): void => { - setMoveTarget(target); - }; - - const openTextDialog = (config: Omit, "kind">): void => { - setDialogValue(config.initialValue); - setDialogState({ kind: "text", ...config }); - }; - - const openConfirmDialog = (config: Omit, "kind">): void => { - setDialogValue(""); - setDialogState({ kind: "confirm", ...config }); - }; - - const submitDialog = (): void => { - const state = dialogState(); - if (!state) { - return; - } - - if (state.kind === "text") { - const value = dialogValue().trim(); - if (!value) { - return; - } - - closeDialog(); - state.onConfirm(value); - return; - } - - closeDialog(); - state.onConfirm(); - }; - - const handleMoveDestinationSelect = (destinationId: string | null): void => { - const target = moveTarget(); - if (!target) { - return; - } - - const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null; - const targetIndex = destinationNode?.kind === "folder" - ? destinationNode.children?.length ?? 0 - : destinationId - ? 0 - : workspaceTreeNodes().length; - - closeMoveSheet(); - - if (target.kind === "folder") { - void moveFolder(target.id, destinationId, targetIndex); - return; - } - - void moveItem(target.id, destinationId, targetIndex); - }; - - const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { - const createItemType = resolveCreateItemType(action.id); - if (createItemType) { - switch (target.kind) { - case "workspace": - case "home": - createPersistedItem(createItemType, null); - return; - case "folder": - createPersistedItem(createItemType, target.id); - return; - case "settings": - case "item": - return; - } - } - - switch (action.id) { - case "new-folder": { - if (target.kind === "settings" || target.kind === "item") { - return; - } - - openTextDialog({ - title: "New folder", - message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.", - confirmLabel: "Create", - initialValue: "Untitled folder", - onConfirm: (name) => { - void createFolder(name, target.kind === "folder" ? target.id : null); - }, - }); - return; - } - case "rename-folder": { - if (target.kind !== "folder") { - return; - } - - openTextDialog({ - title: "Rename folder", - message: `Update the name for "${target.label}".`, - confirmLabel: "Save", - initialValue: target.label, - onConfirm: (name) => { - if (name === target.label) { - return; - } - void renameFolder(target.id, name); - }, - }); - return; - } - case "move-folder": { - if (target.kind !== "folder") { - return; - } - - openMoveSheet({ - kind: "folder", - id: target.id, - label: target.label, - }); - return; - } - case "delete-folder": { - if (target.kind !== "folder") { - return; - } - - openConfirmDialog({ - title: "Delete folder?", - message: `"${target.label}" and everything inside it will be removed.`, - confirmLabel: "Delete", - tone: "danger", - onConfirm: () => { - void deleteFolder(target.id); - }, - }); - return; - } - case "move-doc": - case "move-board": - case "move-list-board": { - if (target.kind !== "item") { - return; - } - - openMoveSheet({ - kind: "item", - id: target.id, - label: target.label, - }); - return; - } - case "delete-doc": - case "delete-board": - case "delete-list-board": { - if (target.kind !== "item") { - return; - } - - openConfirmDialog({ - title: "Delete item?", - message: `"${target.label}" will be removed from the project tree.`, - confirmLabel: "Delete", - tone: "danger", - onConfirm: () => { - void deleteItem(target.id); - }, - }); - return; - } - default: - return; - } - }; - - const workspaceLongPress = createLongPressGesture({ - onLongPress: openWorkspaceActionSheet, - }); - - return ( - -
    -
    -
    -
    { - event.preventDefault(); - openWorkspaceActionSheet(); - }} - {...workspaceLongPress} - > - {/* Long-pressing the browser header exposes workspace-level actions on mobile. */} - Moku Work - {appShellData.activeProject().name} - {appShellData.activeServer().name} -
    - -
    - - - -
    -
    - -
    -
    - Workspace -
      - - {(item): JSX.Element => } - -
    -
    - -
    - Items -
      - -
    -
    -
    -
    - - - - - {(state): JSX.Element => ( - -
    - - )} - -
    -
    - -
    - -
    - -
    -
    - )} -
    - - - {(state): JSX.Element => ( - -
    - - -
    - -
    - - )} - - - - ); -}; diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx deleted file mode 100644 index 7d5c7ac..0000000 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ /dev/null @@ -1,1216 +0,0 @@ -// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx - -import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; -import { Portal } from "solid-js/web"; -import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons"; -import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; -import { useAppShellData } from "../data/app-shell.context"; -import { resolveAPIBase } from "../../../lib/api"; -import { - collectBranchNodeIds, - findTreeNodeDepth, - findTreeNodeLocation, - getPointerRelativeY, - isUuidString, - moveTreeNode, - resolveTreeDropTarget, - type NavTreeAdapter, - type NavTreeDropTarget, -} from "../shared/navTreeDnd"; -import { - createProjectFolderTarget, - createProjectSurfaceTarget, - createProjectTarget, - type ProjectItem, - type ProjectMenuTarget, -} from "../data/shell.data"; -import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController"; -import styles from "./ProjectSelector.module.scss"; - -type ProjectSelectorProps = { - compact?: boolean; - isOpen: boolean; - onToggle: () => void; - onClose: () => void; -}; - -type ProjectFolderNode = { - kind: "folder"; - id: string; - path: string; - label: string; - meta?: string; - children: ProjectTreeNode[]; -}; - -type ProjectLeafNode = { - kind: "project"; - item: ProjectItem; -}; - -type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; - -type PersistedProjectFolderRecord = { - id: string; - path: string; - label: string; - children: PersistedProjectFolderRecord[]; -}; - -type ProjectFoldersResponse = { - data?: { - folders?: PersistedProjectFolderRecord[]; - renamedFolder?: PersistedProjectFolderRecord; - movedFolder?: PersistedProjectFolderRecord; - previousFolderId?: string; - previousFolderPath?: string; - }; - error?: string; - message?: string; -}; - -type PendingProjectFolderDraft = { - parentId: string | null; - depth: number; -}; - -type PendingProjectFolderRename = { - folderId: string; - depth: number; -}; - -type ProjectDragTarget = NavTreeDropTarget; - -type ProjectDragState = { - draggedNodeId: string; - dropTarget: ProjectDragTarget | null; -}; - -type DragGhostPosition = { - x: number; - y: number; -}; - -const LONG_PRESS_MS = 320; - -const getProjectTreeNodeId = (node: ProjectTreeNode): string => - node.kind === "folder" ? node.id : node.item.id; - -const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] => - folders.map((folder) => ({ - kind: "folder", - id: folder.id, - path: folder.path, - label: folder.label, - children: buildPersistedFolderNodes(folder.children ?? []), - })); - -const buildProjectTree = ( - items: readonly ProjectItem[], - folders: readonly PersistedProjectFolderRecord[] = [], -): ProjectTreeNode[] => [ - // The selector still presents scaffold project leaves beside persisted folders. - // Keep that mixed root shape explicit here so the drag/drop logic can account - // for folder-only ordering when we persist sibling positions. - ...items.map((item) => ({ - kind: "project" as const, - item, - })), - ...buildPersistedFolderNodes(folders), -]; - -const countProjectFolderSiblingsBeforeIndex = ( - siblings: readonly ProjectTreeNode[], - index: number, -): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; - -const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => - Array.isArray(body.data?.folders) ? body.data.folders : []; - -const projectTreeAdapter: NavTreeAdapter = { - getNodeId: getProjectTreeNodeId, - isBranchNode: (node) => node.kind === "folder", - getChildren: (node) => (node.kind === "folder" ? node.children : []), - withChildren: (node, children) => - node.kind === "folder" - ? { - ...node, - children: [...children], - } - : node, -}; - -const ProjectFolderDraftRow = (props: { - depth: number; - value: string; - onInput: (value: string) => void; - onSubmit: () => void; - onCancel: () => void; -}): JSX.Element => { - let inputRef: HTMLInputElement | undefined; - - queueMicrotask(() => inputRef?.focus()); - - return ( -
  • -
    - - props.onInput(event.currentTarget.value)} - onBlur={props.onSubmit} - onKeyDown={(event): void => { - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - props.onCancel(); - event.currentTarget.blur(); - } - }} - /> -
    -
  • - ); -}; - -const ProjectFolderBranch = (props: { - nodes: readonly ProjectTreeNode[]; - depth: number; - parentId: string | null; - selectedProjectId: string; - isFolderCollapsed: (folderId: string) => boolean; - onToggleFolder: (folderId: string) => void; - onSelectProject: (projectId: string) => void; - onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void; - onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void; - onNodePointerDown: (event: PointerEvent, nodeId: string) => void; - onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void; - pendingFolderDraft: PendingProjectFolderDraft | null; - pendingFolderName: string; - onPendingFolderNameChange: (value: string) => void; - onSubmitPendingFolder: () => void; - onCancelPendingFolder: () => void; - pendingFolderRename: PendingProjectFolderRename | null; - pendingFolderRenameName: string; - onPendingFolderRenameChange: (value: string) => void; - onSubmitPendingFolderRename: () => void; - onCancelPendingFolderRename: () => void; - dragState: ProjectDragState | null; - isTreeClickSuppressed: () => boolean; -}): JSX.Element => ( - -); - -export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { - const appShellData = useAppShellData(); - const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject()); - const [drawerTop, setDrawerTop] = createSignal(0); - const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); - const [persistedFolders, setPersistedFolders] = createSignal([]); - const [projectTreeNodes, setProjectTreeNodes] = createSignal( - buildProjectTree(appShellData.projectItems(), persistedFolders()), - ); - const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); - const [pendingFolderName, setPendingFolderName] = createSignal(""); - const [pendingFolderRename, setPendingFolderRename] = createSignal(null); - const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); - const [dragState, setDragState] = createSignal(null); - const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 }); - const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); - let rootRef: HTMLDivElement | undefined; - let triggerRef: HTMLButtonElement | undefined; - let contextMenuRef: HTMLDivElement | undefined; - let longPressTimer: number | undefined; - let suppressClickTimer: number | undefined; - let lastSelectedProjectId: string | null = null; - let latestPersistedFoldersRequest = 0; - const contextMenu = createProjectContextMenuController(); - - const clearLongPressTimer = (): void => { - if (longPressTimer !== undefined) { - window.clearTimeout(longPressTimer); - longPressTimer = undefined; - } - }; - - const updateDragGhostPosition = (x: number, y: number): void => { - setDragGhostPosition({ x: x + 18, y: y + 18 }); - }; - - const draggedNode = (): ProjectTreeNode | null => { - const currentDragState = dragState(); - if (!currentDragState) { - return null; - } - - return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null; - }; - - const draggedNodeMeta = (): string => { - const node = draggedNode(); - if (!node) { - return ""; - } - - return node.kind === "folder" ? "Folder" : "Project"; - }; - - const suppressTreeClickTemporarily = (): void => { - setSuppressNextTreeClick(true); - - if (suppressClickTimer !== undefined) { - window.clearTimeout(suppressClickTimer); - } - - suppressClickTimer = window.setTimeout(() => { - setSuppressNextTreeClick(false); - suppressClickTimer = undefined; - }, 80); - }; - - const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); - - const toggleFolder = (folderId: string): void => { - setCollapsedFolderIds((current) => - current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], - ); - }; - - const syncProjectTree = (): void => { - const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders()); - const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter)); - - setProjectTreeNodes(nextTree); - setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId))); - }; - - const resetProjectTreeInteractionState = (): void => { - setCollapsedFolderIds([]); - setPendingFolderDraft(null); - setPendingFolderName(""); - setPendingFolderRename(null); - setPendingFolderRenameName(""); - setDragState(null); - }; - - const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter); - - const expandAllFolders = (): void => { - setCollapsedFolderIds([]); - }; - - const collapseAllFolders = (): void => { - setCollapsedFolderIds(folderIds()); - }; - - const totalFolderCount = (): number => folderIds().length; - - const areAllFoldersCollapsed = (): boolean => { - const folderCount = totalFolderCount(); - - return folderCount > 0 && collapsedFolderIds().length >= folderCount; - }; - - const toggleAllFolders = (): void => { - if (areAllFoldersCollapsed()) { - expandAllFolders(); - return; - } - - collapseAllFolders(); - }; - - const loadPersistedFolders = async (projectId: string): Promise => { - const requestId = latestPersistedFoldersRequest + 1; - latestPersistedFoldersRequest = requestId; - - if (!projectId) { - setPersistedFolders([]); - return; - } - - if (!isUuidString(projectId)) { - setPersistedFolders([]); - return; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { - headers: { - Accept: "application/json", - }, - }); - const body = (await response.json()) as ProjectFoldersResponse; - - if (requestId !== latestPersistedFoldersRequest) { - return; - } - - if (!response.ok) { - throw new Error(body.message || "Failed to load project folders."); - } - - setPersistedFolders(readPersistedFolders(body)); - } catch (error) { - if (requestId !== latestPersistedFoldersRequest) { - return; - } - - console.error(error); - setPersistedFolders([]); - } - }; - - createEffect(() => { - setSelectedProject(appShellData.activeProject()); - }); - - createEffect(() => { - syncProjectTree(); - }); - - createEffect(() => { - const projectId = selectedProject().id; - - if (lastSelectedProjectId === null) { - lastSelectedProjectId = projectId; - return; - } - - if (projectId === lastSelectedProjectId) { - return; - } - - lastSelectedProjectId = projectId; - resetProjectTreeInteractionState(); - }); - - createEffect(() => { - const projectId = selectedProject().id; - void loadPersistedFolders(projectId); - }); - - onMount(() => { - if (triggerRef) { - const updateDrawerTop = (): void => { - if (!triggerRef) { - return; - } - - setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8); - }; - - updateDrawerTop(); - - const observer = new ResizeObserver(() => { - updateDrawerTop(); - }); - - observer.observe(triggerRef); - window.addEventListener("resize", updateDrawerTop); - - onCleanup(() => { - observer.disconnect(); - window.removeEventListener("resize", updateDrawerTop); - }); - } - - const handlePointerDown = (event: PointerEvent): void => { - if (!props.isOpen || !rootRef) { - return; - } - - const target = event.target; - - if (target instanceof Node && rootRef.contains(target)) { - return; - } - - if (target instanceof Node && contextMenuRef?.contains(target)) { - return; - } - - props.onClose(); - }; - - const handlePointerUp = (): void => { - clearLongPressTimer(); - - const nextDragState = dragState(); - - if (!nextDragState?.dropTarget) { - if (nextDragState) { - suppressTreeClickTemporarily(); - } - setDragState(null); - return; - } - - suppressTreeClickTemporarily(); - - const currentNodes = projectTreeNodes(); - const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter); - const canPersistMove = isUuidString(selectedProject().id); - const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null; - const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter); - const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter); - const persistedParentLocation = previewLocation?.parentId - ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) - : null; - const persistedParentFolderPath = - persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null; - const previewSiblings = previewLocation?.parentId - ? persistedParentLocation?.node.kind === "folder" - ? persistedParentLocation.node.children - : [] - : previewNodes; - // The preview tree includes project leaves and folders, but the backend only - // stores sibling order for folders. Persist a folder-only index so the server - // can reapply the same position against the authoritative ordered tree. - const targetIndex = previewLocation - ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) - : 0; - - if ( - canPersistMove && - draggedLocation?.node.kind === "folder" && - draggedFolderPath && - (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") - ) { - void movePersistedFolder( - draggedFolderPath, - persistedParentFolderPath, - draggedLocation.node.id, - persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, - targetIndex, - ); - } else { - setProjectTreeNodes((current) => - moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter), - ); - } - - setDragState(null); - }; - - const handleEscape = (event: KeyboardEvent): void => { - if (event.key !== "Escape") { - return; - } - - clearLongPressTimer(); - - if (dragState()) { - setDragState(null); - return; - } - - if (!props.isOpen) { - return; - } - - props.onClose(); - triggerRef?.focus(); - }; - - const handlePointerMove = (event: PointerEvent): void => { - if (!dragState()) { - return; - } - - updateDragGhostPosition(event.clientX, event.clientY); - }; - - document.addEventListener("pointerdown", handlePointerDown); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("keydown", handleEscape); - - onCleanup(() => { - clearLongPressTimer(); - if (suppressClickTimer !== undefined) { - window.clearTimeout(suppressClickTimer); - } - document.removeEventListener("pointerdown", handlePointerDown); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("keydown", handleEscape); - }); - }); - - const toggleOpen = (): void => { - if (!props.isOpen) { - props.onToggle(); - return; - } - - props.onClose(); - }; - - const selectProject = (projectId: string): void => { - const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter); - - if (!location || location.node.kind !== "project") { - return; - } - - setSelectedProject({ id: location.node.item.id, name: location.node.item.name }); - props.onClose(); - }; - - const beginFolderDraft = (parentId: string | null, depth: number): void => { - if (parentId) { - setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); - } - - setPendingFolderRename(null); - setPendingFolderRenameName(""); - setPendingFolderName(""); - setPendingFolderDraft({ parentId, depth }); - }; - - const beginFolderRename = (folderId: string, label: string, depth: number): void => { - setPendingFolderDraft(null); - setPendingFolderName(""); - setPendingFolderRename({ folderId, depth }); - setPendingFolderRenameName(label); - }; - - const resolveFolderPath = (folderId: string): string | null => { - const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter); - return location && location.node.kind === "folder" ? location.node.path : null; - }; - - const submitPendingFolder = async (): Promise => { - const name = pendingFolderName().trim(); - const draft = pendingFolderDraft(); - const projectId = selectedProject().id; - - if (!draft) { - return; - } - - if (!name) { - setPendingFolderDraft(null); - setPendingFolderName(""); - return; - } - - if (!isUuidString(projectId)) { - cancelPendingFolder(); - return; - } - - const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; - if (draft.parentId && !parentFolderPath) { - cancelPendingFolder(); - return; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - parentFolderId: parentFolderPath, - }), - }); - - const body = (await response.json()) as ProjectFoldersResponse; - - if (!response.ok) { - throw new Error(body.message || "Failed to create project folder."); - } - - setPersistedFolders(readPersistedFolders(body)); - setPendingFolderDraft(null); - setPendingFolderName(""); - } catch (error) { - console.error(error); - } - }; - - const deletePersistedFolder = async (folderId: string): Promise => { - const projectId = selectedProject().id; - if (!folderId || !isUuidString(projectId)) { - return; - } - - const folderPath = resolveFolderPath(folderId); - if (!folderPath) { - return; - } - - try { - const response = await fetch( - `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`, - { - method: "DELETE", - headers: { - Accept: "application/json", - }, - }, - ); - - const body = (await response.json()) as ProjectFoldersResponse; - - if (!response.ok) { - throw new Error(body.message || "Failed to delete project folder."); - } - - setPersistedFolders(readPersistedFolders(body)); - setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); - } catch (error) { - console.error(error); - } - }; - - const movePersistedFolder = async ( - folderPath: string, - parentFolderPath: string | null, - folderStableId: string, - parentStableId: string | null, - targetIndex: number, - ): Promise => { - const projectId = selectedProject().id; - if (!folderPath || !folderStableId || !isUuidString(projectId)) { - return; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - folderId: folderPath, - folderNodeId: folderStableId, - parentFolderId: parentFolderPath, - parentNodeId: parentStableId, - targetIndex, - }), - }); - - const body = (await response.json()) as ProjectFoldersResponse; - - if (!response.ok) { - throw new Error(body.message || "Failed to move project folder."); - } - - setPersistedFolders(readPersistedFolders(body)); - } catch (error) { - console.error(error); - } - }; - - const submitPendingFolderRename = async (): Promise => { - const draft = pendingFolderRename(); - const name = pendingFolderRenameName().trim(); - const projectId = selectedProject().id; - - if (!draft) { - return; - } - - if (!name) { - setPendingFolderRename(null); - setPendingFolderRenameName(""); - return; - } - - if (!isUuidString(projectId)) { - cancelPendingFolderRename(); - return; - } - - const folderPath = resolveFolderPath(draft.folderId); - if (!folderPath) { - cancelPendingFolderRename(); - return; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - folderId: folderPath, - name, - }), - }); - - const body = (await response.json()) as ProjectFoldersResponse; - - if (!response.ok) { - throw new Error(body.message || "Failed to rename project folder."); - } - - setPersistedFolders(readPersistedFolders(body)); - setPendingFolderRename(null); - setPendingFolderRenameName(""); - } catch (error) { - console.error(error); - } - }; - - const cancelPendingFolder = (): void => { - setPendingFolderDraft(null); - setPendingFolderName(""); - }; - - const cancelPendingFolderRename = (): void => { - setPendingFolderRename(null); - setPendingFolderRenameName(""); - }; - - const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => { - switch (action.id) { - case "new-folder": - switch (target.kind) { - case "surface": - beginFolderDraft(null, 0); - return; - case "folder": - beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); - return; - case "project": { - const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; - beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); - return; - } - } - return; - case "delete-folder": - if (target.kind === "folder") { - void deletePersistedFolder(target.id); - } - return; - case "rename-folder": - if (target.kind === "folder") { - beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0); - } - return; - default: - return; - } - }; - - const handleSurfaceContextMenu = (event: MouseEvent): void => { - event.stopPropagation(); - contextMenu.openMenu(event, createProjectSurfaceTarget("Projects")); - }; - - const openRootCreateMenu = (element: HTMLElement): void => { - contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects")); - }; - - const treeControlLabel = (): string => - areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"; - - const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => { - if (event.button !== 0 || pendingFolderDraft()) { - return; - } - - updateDragGhostPosition(event.clientX, event.clientY); - clearLongPressTimer(); - longPressTimer = window.setTimeout(() => { - suppressTreeClickTemporarily(); - setDragState({ draggedNodeId: nodeId, dropTarget: null }); - }, LONG_PRESS_MS); - }; - - const handleNodePointerMove = ( - event: PointerEvent, - parentId: string | null, - index: number, - node: ProjectTreeNode, - ): void => { - const nextDragState = dragState(); - - if (!nextDragState) { - return; - } - - if (nextDragState.draggedNodeId === getProjectTreeNodeId(node)) { - return; - } - - const relativeY = getPointerRelativeY(event); - if (relativeY === null) { - return; - } - - setDragState({ - ...nextDragState, - dropTarget: resolveTreeDropTarget({ - parentId, - index, - node, - relativeY, - adapter: projectTreeAdapter, - }), - }); - }; - - return ( -
    - - - - <> - - -
    - - - { - event.stopPropagation(); - contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label)); - }} - onOpenProjectMenu={(event, item): void => { - event.stopPropagation(); - contextMenu.openMenu(event, createProjectTarget(item)); - }} - onNodePointerDown={handleNodePointerDown} - onNodePointerMove={handleNodePointerMove} - pendingFolderDraft={pendingFolderDraft()} - pendingFolderName={pendingFolderName()} - onPendingFolderNameChange={setPendingFolderName} - onSubmitPendingFolder={submitPendingFolder} - onCancelPendingFolder={cancelPendingFolder} - pendingFolderRename={pendingFolderRename()} - pendingFolderRenameName={pendingFolderRenameName()} - onPendingFolderRenameChange={setPendingFolderRenameName} - onSubmitPendingFolderRename={submitPendingFolderRename} - onCancelPendingFolderRename={cancelPendingFolderRename} - dragState={dragState()} - isTreeClickSuppressed={suppressNextTreeClick} - /> - - - - - - { - const state = contextMenu.menuState(); - return state ? { x: state.x, y: state.y } : null; - })()} - menuRef={(element) => { - contextMenuRef = element; - contextMenu.setMenuRef(element); - }} - onClose={contextMenu.closeMenu} - onSelect={handleContextActionSelect} - /> - - - {(node): JSX.Element => { - const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid; - - return ( - - - - ); - }} - - - ); -}; diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx deleted file mode 100644 index 3e92f5b..0000000 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ /dev/null @@ -1,1017 +0,0 @@ -// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx - -import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; -import { Portal } from "solid-js/web"; -import { ChevronLeft, ChevronRight, Folder, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons"; -import { useAppShellData } from "../data/app-shell.context"; -import { ProjectSelector } from "../ProjectSelector/ProjectSelector"; -import { - collectBranchNodeIds, - findTreeNodeDepth, - findTreeNodeLocation, - getPointerRelativeY, - isUuidString, - moveTreeNode, - resolveTreeDropTarget, - type NavTreeAdapter, - type NavTreeDropTarget, -} from "../shared/navTreeDnd"; -import { - createWorkspaceStaticTarget, - createWorkspaceSurfaceTarget, - createWorkspaceTreeTarget, - getWorkspaceNodeIcon, - getWorkspaceItemTypeDefinition, - workspaceSidebarHeaderActions, - workspaceStaticItems, - type WorkspaceContextMenuAction, - type WorkspaceContextMenuTarget, - type WorkspaceItemTypeId, - type WorkspaceStaticItem, - type WorkspaceTreeNode, -} from "../data/shell.data"; -import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData"; -import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu"; -import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController"; -import styles from "./WorkspaceSidebar.module.scss"; - -type WorkspaceSidebarProps = { - collapsed: boolean; - railCollapsed: boolean; - onToggleRailCollapse: () => void; -}; - -type PendingWorkspaceFolderDraft = { - parentId: string | null; - depth: number; -}; - -type WorkspaceDragTarget = NavTreeDropTarget; - -type WorkspaceDragState = { - draggedNodeId: string; - dropTarget: WorkspaceDragTarget | null; -}; - -type DragGhostPosition = { - x: number; - y: number; -}; - -type PendingWorkspaceFolderRename = { - folderId: string; - depth: number; -}; - -const LONG_PRESS_MS = 320; - -const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id; - -const countWorkspaceFolderSiblingsBeforeIndex = ( - siblings: readonly WorkspaceTreeNode[], - index: number, -): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; - -const workspaceTreeAdapter: NavTreeAdapter = { - getNodeId: getWorkspaceTreeNodeId, - isBranchNode: (node) => node.kind === "folder", - getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []), - withChildren: (node, children) => - node.kind === "folder" - ? { - ...node, - children: [...children], - } - : node, -}; - -const FolderDraftRow = (props: { - depth: number; - value: string; - onInput: (value: string) => void; - onSubmit: () => void; - onCancel: () => void; -}): JSX.Element => { - let inputRef: HTMLInputElement | undefined; - - queueMicrotask(() => inputRef?.focus()); - - return ( -
  • -
    - - props.onInput(event.currentTarget.value)} - onBlur={props.onSubmit} - onKeyDown={(event): void => { - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - props.onCancel(); - event.currentTarget.blur(); - } - }} - /> -
    -
  • - ); -}; - -const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"); - -const WorkspaceHomeEntry = (props: { - item: WorkspaceStaticItem; - onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; - onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; -}): JSX.Element => { - const Icon = props.item.icon; - const target = createWorkspaceStaticTarget(props.item); - - return ( -
  • - -
  • - ); -}; - -const WorkspaceTreeBranch = (props: { - nodes: readonly WorkspaceTreeNode[]; - parentId?: string | null; - depth?: number; - isFolderCollapsed: (folderId: string) => boolean; - onToggleFolder: (folderId: string) => void; - pendingFolderDraft: PendingWorkspaceFolderDraft | null; - pendingFolderName: string; - onPendingFolderNameChange: (value: string) => void; - onSubmitPendingFolder: () => void; - onCancelPendingFolder: () => void; - pendingFolderRename: PendingWorkspaceFolderRename | null; - pendingFolderRenameName: string; - onPendingFolderRenameChange: (value: string) => void; - onSubmitPendingFolderRename: () => void; - onCancelPendingFolderRename: () => void; - onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; - onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; - onNodePointerDown: (event: PointerEvent, nodeId: string) => void; - onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void; - dragState: WorkspaceDragState | null; - isTreeClickSuppressed: () => boolean; -}): JSX.Element => { - const depth = () => props.depth ?? 0; - const parentId = () => props.parentId ?? null; - - return ( -
      - -
    • -
      -
    • -
      - - {(node, indexAccessor): JSX.Element => { - const Icon = getWorkspaceNodeIcon(node); - const target = createWorkspaceTreeTarget(node); - const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false); - const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id; - const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id; - const dropIntent = (): WorkspaceDragTarget["intent"] | null => { - if (props.dragState?.dropTarget?.targetNodeId !== node.id) { - return null; - } - - return props.dragState.dropTarget.intent; - }; - - return ( -
    • - { - if (props.dragState || props.isTreeClickSuppressed()) { - return; - } - - if (node.kind !== "folder") { - return; - } - - props.onToggleFolder(node.id); - }} - onContextMenu={(event): void => { - event.stopPropagation(); - props.onOpenContextMenu(event, target); - }} - onPointerDown={(event): void => props.onNodePointerDown(event, node.id)} - onPointerMove={(event): void => - props.onNodePointerMove(event, parentId(), indexAccessor(), node) - } - onPointerEnter={(event): void => - props.onNodePointerMove(event, parentId(), indexAccessor(), node) - } - onKeyDown={(event): void => { - if (!isContextMenuKeyboardTrigger(event)) { - return; - } - - event.preventDefault(); - props.onOpenContextMenuFromKeyboard(event.currentTarget, target); - }} - > - - - - - {node.label} - - {node.meta} - - - } - > - - - - 0) || props.pendingFolderDraft?.parentId === node.id)}> - - -
    • - ); - }} -
      - - - - -
    - ); -}; - -export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { - const appShellData = useAppShellData(); - const activeProject = () => appShellData.activeProject(); - const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false); - const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); - const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); - const [pendingFolderName, setPendingFolderName] = createSignal(""); - const [pendingFolderRename, setPendingFolderRename] = createSignal(null); - const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); - const [dragState, setDragState] = createSignal(null); - const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 }); - const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); - let lastSelectedProjectId: string | null = null; - const { - workspaceTreeNodes, - setWorkspaceTreeNodes, - resolveFolderPath, - resolveItemPath, - createFolder, - renameFolder, - deleteFolder, - moveFolder, - createItem, - deleteItem, - moveItem, - } = useWorkspaceTreeData({ - activeProjectId: () => activeProject()?.id ?? "", - fallbackWorkspaceTree: () => appShellData.workspaceTree(), - }); - const contextMenu = createWorkspaceContextMenuController(); - let longPressTimer: number | undefined; - let suppressClickTimer: number | undefined; - const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail"); - const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject()); - const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); - const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter); - const totalFolderCount = (): number => folderIds().length; - const areAllFoldersCollapsed = (): boolean => { - const count = totalFolderCount(); - return count > 0 && collapsedFolderIds().length >= count; - }; - const workspaceFolderToggleLabel = (): string => - areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"; - const toggleFolder = (folderId: string): void => { - setCollapsedFolderIds((current) => - current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], - ); - }; - const expandAllFolders = (): void => { - setCollapsedFolderIds([]); - }; - const collapseAllFolders = (): void => { - setCollapsedFolderIds(folderIds()); - }; - const toggleAllFolders = (): void => { - if (areAllFoldersCollapsed()) { - expandAllFolders(); - return; - } - - collapseAllFolders(); - }; - const resetWorkspaceTreeInteractionState = (): void => { - setCollapsedFolderIds([]); - setPendingFolderDraft(null); - setPendingFolderName(""); - setPendingFolderRename(null); - setPendingFolderRenameName(""); - setDragState(null); - }; - const syncCollapsedFolderIds = (): void => { - const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter)); - setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id))); - }; - const updateDragGhostPosition = (x: number, y: number): void => { - setDragGhostPosition({ x: x + 18, y: y + 18 }); - }; - const draggedNode = (): WorkspaceTreeNode | null => { - const currentDragState = dragState(); - if (!currentDragState) { - return null; - } - - return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null; - }; - const draggedNodeMeta = (): string => { - const node = draggedNode(); - if (!node) { - return ""; - } - - if (node.kind === "folder") { - return "Folder"; - } - - return getWorkspaceItemTypeDefinition(node.itemType).label; - }; - const clearLongPressTimer = (): void => { - if (longPressTimer !== undefined) { - window.clearTimeout(longPressTimer); - longPressTimer = undefined; - } - }; - const suppressTreeClickTemporarily = (): void => { - setSuppressNextTreeClick(true); - - if (suppressClickTimer !== undefined) { - window.clearTimeout(suppressClickTimer); - } - - suppressClickTimer = window.setTimeout(() => { - setSuppressNextTreeClick(false); - suppressClickTimer = undefined; - }, 80); - }; - - createEffect(() => { - syncCollapsedFolderIds(); - }); - - createEffect(() => { - const projectId = activeProject()?.id ?? null; - - if (lastSelectedProjectId === null) { - lastSelectedProjectId = projectId; - return; - } - - if (projectId === lastSelectedProjectId) { - return; - } - - lastSelectedProjectId = projectId; - resetWorkspaceTreeInteractionState(); - }); - - onMount(() => { - const handlePointerMove = (event: PointerEvent): void => { - if (!dragState()) { - return; - } - - updateDragGhostPosition(event.clientX, event.clientY); - }; - - const handlePointerUp = (): void => { - clearLongPressTimer(); - - const nextDragState = dragState(); - - if (!nextDragState?.dropTarget) { - if (nextDragState) { - suppressTreeClickTemporarily(); - } - setDragState(null); - return; - } - - suppressTreeClickTemporarily(); - - const currentNodes = workspaceTreeNodes(); - const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); - const canPersistMove = isUuidString(activeProject()?.id ?? ""); - const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter); - const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); - const persistedParentLocation = previewLocation?.parentId - ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter) - : null; - const previewSiblings = previewLocation?.parentId - ? persistedParentLocation?.node.kind === "folder" - ? persistedParentLocation.node.children ?? [] - : [] - : previewNodes; - // The tree preview can include static/workspace items, but persisted ordering - // only applies to folder siblings. Convert the preview position into a folder- - // only index before sending it to the backend move endpoint. - const targetIndex = previewLocation - ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) - : 0; - - if ( - canPersistMove && - draggedLocation?.node.kind === "folder" && - (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") - ) { - void moveFolder( - draggedLocation.node.id, - persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, - targetIndex, - ); - } else if ( - canPersistMove && - draggedLocation?.node.kind === "item" && - resolveItemPath(draggedLocation.node.id) && - (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") - ) { - void moveItem( - draggedLocation.node.id, - persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, - previewLocation?.index ?? 0, - ); - } else { - setWorkspaceTreeNodes((current) => - moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter), - ); - } - - setDragState(null); - }; - - const handleEscape = (event: KeyboardEvent): void => { - if (event.key !== "Escape") { - return; - } - - clearLongPressTimer(); - - if (dragState()) { - setDragState(null); - } - }; - - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("keydown", handleEscape); - - onCleanup(() => { - clearLongPressTimer(); - if (suppressClickTimer !== undefined) { - window.clearTimeout(suppressClickTimer); - } - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("keydown", handleEscape); - }); - }); - - const beginFolderDraft = (parentId: string | null, depth: number): void => { - if (parentId) { - setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); - } - - setPendingFolderRename(null); - setPendingFolderRenameName(""); - setPendingFolderName(""); - setPendingFolderDraft({ parentId, depth }); - }; - - const beginFolderRename = (folderId: string, label: string, depth: number): void => { - setPendingFolderDraft(null); - setPendingFolderName(""); - setPendingFolderRename({ folderId, depth }); - setPendingFolderRenameName(label); - }; - - const submitPendingFolder = async (): Promise => { - const name = pendingFolderName().trim(); - const draft = pendingFolderDraft(); - const projectId = activeProject()?.id ?? ""; - - if (!draft) { - return; - } - - if (!name) { - setPendingFolderDraft(null); - setPendingFolderName(""); - return; - } - - if (!projectId || !isUuidString(projectId)) { - cancelPendingFolder(); - return; - } - - const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; - if (draft.parentId && !parentFolderPath) { - cancelPendingFolder(); - return; - } - - const created = await createFolder(name, draft.parentId); - if (created) { - setPendingFolderDraft(null); - setPendingFolderName(""); - } - }; - - const deletePersistedFolder = async (folderId: string): Promise => { - const deleted = await deleteFolder(folderId); - if (deleted) { - setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); - } - }; - - const submitPendingFolderRename = async (): Promise => { - const draft = pendingFolderRename(); - const name = pendingFolderRenameName().trim(); - const projectId = activeProject()?.id ?? ""; - - if (!draft) { - return; - } - - if (!name) { - setPendingFolderRename(null); - setPendingFolderRenameName(""); - return; - } - - if (!projectId || !isUuidString(projectId)) { - cancelPendingFolderRename(); - return; - } - - const folderPath = resolveFolderPath(draft.folderId); - if (!folderPath) { - cancelPendingFolderRename(); - return; - } - - const renamed = await renameFolder(draft.folderId, name); - if (renamed) { - setPendingFolderRename(null); - setPendingFolderRenameName(""); - } - }; - - const cancelPendingFolder = (): void => { - setPendingFolderDraft(null); - setPendingFolderName(""); - }; - - const cancelPendingFolderRename = (): void => { - setPendingFolderRename(null); - setPendingFolderRenameName(""); - }; - - const handleHeaderActionClick = (actionId: string): void => { - switch (actionId) { - case "toggle-workspace-folders": - toggleAllFolders(); - return; - case "search-workspace": - default: - return; - } - }; - - const openWorkspaceCreateMenu = (element: HTMLElement): void => { - contextMenu.openMenuFromElement(element, sidebarContextMenuTarget); - }; - - const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => { - switch (actionId) { - case "create-doc": - return "core.doc"; - case "create-board": - return "core.board.kanban"; - case "create-list-board": - return "core.board.list"; - default: - return null; - } - }; - - const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => { - const definition = getWorkspaceItemTypeDefinition(itemType); - await createItem(definition.defaultCreateLabel, itemType, parentId); - }; - - const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => { - if (event.button !== 0 || pendingFolderDraft()) { - return; - } - - updateDragGhostPosition(event.clientX, event.clientY); - clearLongPressTimer(); - longPressTimer = window.setTimeout(() => { - suppressTreeClickTemporarily(); - setDragState({ draggedNodeId: nodeId, dropTarget: null }); - }, LONG_PRESS_MS); - }; - - const handleNodePointerMove = ( - event: PointerEvent, - parentId: string | null, - index: number, - node: WorkspaceTreeNode, - ): void => { - const nextDragState = dragState(); - - if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) { - return; - } - - const relativeY = getPointerRelativeY(event); - if (relativeY === null) { - return; - } - - setDragState({ - ...nextDragState, - dropTarget: resolveTreeDropTarget({ - parentId, - index, - node, - relativeY, - adapter: workspaceTreeAdapter, - }), - }); - }; - - const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { - const createItemType = resolveCreateItemType(action.id); - if (createItemType) { - switch (target.kind) { - case "workspace": - case "home": - void createPersistedItem(createItemType, null); - return; - case "folder": - void createPersistedItem(createItemType, target.id); - return; - case "settings": - case "item": - return; - } - } - - switch (action.id) { - case "new-folder": - switch (target.kind) { - case "workspace": - case "home": - beginFolderDraft(null, 0); - return; - case "folder": - beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1); - return; - case "settings": - case "item": - return; - } - return; - case "delete-folder": - if (target.kind === "folder") { - void deletePersistedFolder(target.id); - } - return; - case "rename-folder": - if (target.kind === "folder") { - beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0); - } - return; - case "delete-doc": - case "delete-board": - case "delete-list-board": - if (target.kind === "item") { - void deleteItem(target.id); - } - return; - default: - return; - } - }; - - return ( - <> - - - { - const state = contextMenu.menuState(); - return state - ? { - x: state.x, - y: state.y, - } - : null; - })()} - menuRef={contextMenu.setMenuRef} - onClose={contextMenu.closeMenu} - onSelect={handleContextActionSelect} - /> - - - {(node): JSX.Element => { - const GhostIcon = getWorkspaceNodeIcon(node); - - return ( - - - - ); - }} - - - ); -}; diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx deleted file mode 100644 index 66198e7..0000000 --- a/Frontend/src/components/shell/data/app-shell.context.tsx +++ /dev/null @@ -1,375 +0,0 @@ -// 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; - name: string; - mode: "personal" | "organizational" | string; - access: string; - protocol: string; - host: string; - isBootstrapped: boolean; - materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string; - materializationError?: string; -}; - -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[]; -}; - -const normalizeInstallation = ( - installation: AppShellInstallation | null | undefined, -): AppShellInstallation | undefined => { - if (!installation) { - return undefined; - } - - const materializationStatus = installation.materializationStatus?.trim() - ? installation.materializationStatus - : installation.isBootstrapped - ? "succeeded" - : "not_started"; - const materializationError = installation.materializationError?.trim() || undefined; - - return { - ...installation, - materializationStatus, - materializationError, - }; -}; - -const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({ - installation: normalizeInstallation(payload?.installation), - admin: payload?.admin, - organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], - departments: Array.isArray(payload?.departments) ? payload.departments : [], - teams: Array.isArray(payload?.teams) ? payload.teams : [], - projects: Array.isArray(payload?.projects) ? payload.projects : [], - workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [], -}); - -type AppShellContextValue = { - status: Accessor<"idle" | "loading" | "success" | "error">; - error: Accessor; - installation: Accessor; - railItems: Accessor; - activeServer: Accessor; - activeProject: Accessor; - activeDepartment: Accessor; - projectItems: Accessor; - departmentItems: Accessor; - workspaceTree: Accessor; - activeUserProfile: Accessor; - reload: () => Promise; -}; - -const AppShellContext = createContext(); - -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"; - const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host; - - return payload.organizations.map((organization, index) => ({ - id: organization.id, - label: serverName || organization.name, - abbreviation: buildAbbreviation(serverName || 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"; - const serverName = installation.name || organization.name || installation.host; - - return { - id: installation.id, - name: serverName || fallbackActiveServer.name, - abbreviation: buildAbbreviation(serverName, 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", - groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects", - parentLabel: - payload.teams.find((team) => team.id === project.teamId)?.name || - payload.departments.find((department) => department.id === project.departmentId)?.name || - "Shared project", - meta: (() => { - const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length; - - return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined; - })(), - 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.installation?.name || 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(null); - - const load = async (): Promise => { - 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 } | string; - message?: string; - }; - const errorMessage = - typeof body.message === "string" - ? body.message - : typeof body.error === "string" - ? body.error - : body.error?.message; - - if (!response.ok || !body.data) { - throw new Error(errorMessage || "Failed to load app shell state."); - } - - setPayload(normalizeAppShellPayload(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, - installation: createMemo(() => payload()?.installation), - 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 {props.children}; -}; - -export const useAppShellData = (): AppShellContextValue => { - const context = useContext(AppShellContext); - - if (!context) { - throw new Error("useAppShellData must be used within AppShellDataProvider"); - } - - return context; -}; diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts deleted file mode 100644 index 0479831..0000000 --- a/Frontend/src/components/shell/data/shell.data.ts +++ /dev/null @@ -1,734 +0,0 @@ -// Path: Frontend/src/components/shell/data/shell.data.ts - -import type { Component } from "solid-js"; -import { - Bell, - CircleHelp, - FileText, - Folder, - Home, - Keyboard, - LayoutGrid, - ListCollapse, - LogOut, - Repeat, - Search, - Settings, - Shield, - User, -} from "../../../lib/icons"; - -type ShellIconProps = { - class?: string; - size?: number; - strokeWidth?: number; -}; - -export type ShellIcon = Component; - -export type RailItem = { - id: string; - label: string; - abbreviation: string; - kind: "personal" | "organization"; - active?: boolean; -}; - -export type ServerDockAction = { - id: string; - label: string; - icon: ShellIcon; -}; - -export type ActiveServer = { - id: string; - name: string; - abbreviation: string; - kind: "personal" | "organization"; - connectedLabel?: string; - subtitle?: string; - dockActions: readonly ServerDockAction[]; -}; - -export type ActiveProject = { - id: string; - name: string; -}; - -export type ActiveDepartment = { - id: string; - name: string; - teamName: string; -}; - -export type DepartmentItem = { - id: string; - name: string; - teams: readonly string[]; - active?: boolean; -}; - -export type ProjectItem = { - id: string; - name: string; - description: string; - groupLabel?: string; - parentLabel?: string; - meta?: string; - active?: boolean; -}; - -export type ProjectMenuTarget = - | { - id: string; - label: string; - kind: "surface"; - } - | { - id: string; - label: string; - kind: "folder"; - } - | { - id: string; - label: string; - kind: "project"; - }; - -export type ProjectContextMenuAction = { - id: string; - label: string; - tone?: "default" | "danger"; - shortcut?: WorkspaceContextMenuShortcut; - children?: readonly ProjectContextMenuAction[]; -}; - -export type ProjectContextMenuSection = { - id: string; - label?: string; - items: readonly ProjectContextMenuAction[]; -}; - -export type SidebarItem = { - id: string; - label: string; - icon: ShellIcon; - active?: boolean; - meta?: string; -}; - -export type WorkspaceStaticKind = "workspace" | "home" | "settings"; - -// Keep this open-ended so future server-driven or plugin-provided item types do -// not require a frontend source edit before they can be represented safely. -export type WorkspaceItemTypeId = string; - -export type WorkspaceStaticItem = SidebarItem & { - contextKind: WorkspaceStaticKind; -}; - -export type WorkspaceFolderNode = { - id: string; - path?: string; - label: string; - kind: "folder"; - icon: ShellIcon; - active?: boolean; - meta?: string; - children?: readonly WorkspaceTreeNode[]; -}; - -export type WorkspaceItemNode = { - id: string; - path?: string; - label: string; - kind: "item"; - itemType: WorkspaceItemTypeId; - active?: boolean; - meta?: string; - children?: undefined; -}; - -export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode; - -export type WorkspaceItemTypeDefinition = { - id: WorkspaceItemTypeId; - label: string; - shortLabel: string; - icon: ShellIcon; - noun: string; - actionPrefix: string; - defaultCreateLabel: string; - includeInWorkspaceCreate?: boolean; - description?: string; -}; - -export type SidebarHeaderAction = { - id: string; - label: string; - icon: ShellIcon; -}; - -export type TopBarAction = { - id: string; - label: string; - icon: ShellIcon; -}; - -export type MobileBottomNavItem = { - id: string; - label: string; - icon: ShellIcon; - active?: boolean; -}; - -export type WorkspaceContextMenuTarget = - | { - id: string; - label: string; - kind: WorkspaceStaticKind; - } - | { - id: string; - label: string; - kind: "folder"; - } - | { - id: string; - label: string; - kind: "item"; - itemType: WorkspaceItemTypeId; - }; - -export type WorkspaceContextMenuAction = { - id: string; - label: string; - tone?: "default" | "danger"; - shortcut?: WorkspaceContextMenuShortcut; - children?: readonly WorkspaceContextMenuAction[]; -}; - -export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift"; - -export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r"; - -export type WorkspaceContextMenuShortcut = { - modifiers?: readonly WorkspaceContextMenuShortcutModifier[]; - key: WorkspaceContextMenuShortcutKey; -}; - -export type WorkspaceContextMenuSection = { - id: string; - label?: string; - items: readonly WorkspaceContextMenuAction[]; -}; - -export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [ - { - id: "core.doc", - label: "Doc", - shortLabel: "Doc", - icon: FileText, - noun: "doc", - actionPrefix: "doc", - defaultCreateLabel: "New doc", - includeInWorkspaceCreate: true, - description: "Rich text documents and notes.", - }, - { - id: "core.board.kanban", - label: "Kanban board", - shortLabel: "Board", - icon: LayoutGrid, - noun: "board", - actionPrefix: "board", - defaultCreateLabel: "New board", - includeInWorkspaceCreate: true, - description: "Default board-style workspace item.", - }, - { - id: "core.board.list", - label: "List board", - shortLabel: "Board", - icon: LayoutGrid, - noun: "board", - actionPrefix: "list-board", - defaultCreateLabel: "New list board", - description: "Alternate first-party board view prepared for the future registry.", - }, -] as const; - -const workspaceItemTypeMap = new Map( - firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]), -); - -const createUnknownWorkspaceItemTypeDefinition = ( - itemType: WorkspaceItemTypeId, -): WorkspaceItemTypeDefinition => ({ - id: itemType, - label: "Item", - shortLabel: "Item", - icon: FileText, - noun: "item", - actionPrefix: "item", - defaultCreateLabel: "New item", - description: "Fallback definition for unknown or future workspace item types.", -}); - -export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => { - return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType); -}; - -export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon => - node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon; - -const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [ - { id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } }, - ...firstPartyWorkspaceItemTypes - .filter((definition) => definition.includeInWorkspaceCreate) - .map((definition) => ({ - id: `create-${definition.actionPrefix}`, - label: definition.defaultCreateLabel, - shortcut: - definition.id === "core.board.kanban" - ? ({ modifiers: ["alt"], key: "b" } as const) - : definition.id === "core.doc" - ? ({ modifiers: ["alt"], key: "d" } as const) - : undefined, - })), -]; - -export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => { - switch (target.kind) { - case "workspace": - case "home": - return "Workspace"; - case "settings": - return "Configuration"; - case "folder": - return "Folder"; - case "item": - return getWorkspaceItemTypeDefinition(target.itemType).shortLabel; - } -}; - -export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({ - id: `workspace-${workspace.id}`, - label: workspace.name, - kind: "workspace", -}); - -export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({ - id: item.id, - label: item.label, - kind: item.contextKind, -}); - -export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({ - id: node.id, - label: node.label, - ...(node.kind === "folder" - ? { kind: "folder" as const } - : { - kind: "item" as const, - itemType: node.itemType, - }), -}); - -export type NotificationItem = { - id: string; - title: string; - contextLabel: string; - timeLabel: string; - unread?: boolean; -}; - -export type ProfileMenuAction = { - id: string; - label: string; - icon: ShellIcon; - tone?: "default" | "danger"; -}; - -export type ProfileMenuSection = { - id: string; - items: readonly ProfileMenuAction[]; -}; - -export type ActiveUserProfile = { - name: string; - email: string; - roleLabel: string; - contextLabel: string; -}; - -export const personalDockActions: readonly ServerDockAction[] = [ - { id: "account", label: "Account", icon: User }, - { id: "settings", label: "Settings", icon: Settings }, -] as const; - -export const organizationAdminDockActions: readonly ServerDockAction[] = [ - { id: "members", label: "Members", icon: User }, - { id: "server", label: "Server", icon: Settings }, -] as const; - -// Server shell scaffold data -export const railItems: readonly RailItem[] = [ - { id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" }, - { id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true }, - { id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" }, -] as const; - -export const activeServer: ActiveServer = { - id: "organization-server", - name: "Organization Name", - abbreviation: "O", - kind: "organization", - connectedLabel: "12 connected", - dockActions: organizationAdminDockActions, -}; - -// Workspace framing scaffold data -export const activeProject: ActiveProject = { - id: "general", - name: "General", -}; - -export const activeDepartment: ActiveDepartment = { - id: "product", - name: "Product", - teamName: "Design Systems", -}; - -export const projectItems: readonly ProjectItem[] = [ - { - id: "general", - name: "General", - description: "Default shared project", - groupLabel: "Shared space", - parentLabel: "Workspace home", - meta: "1 workspace", - active: true, - }, - { - id: "operations", - name: "Operations", - description: "Cross-team planning and delivery", - groupLabel: "Team folders", - parentLabel: "Shared Services", - meta: "2 workspaces", - }, - { - id: "hiring", - name: "Hiring", - description: "Candidate pipeline and interview loops", - groupLabel: "Team folders", - parentLabel: "People Ops", - meta: "1 workspace", - }, -] as const; - -export const departmentItems: readonly DepartmentItem[] = [ - { id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true }, - { id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] }, - { id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] }, -] as const; - -// Sidebar and topbar scaffold data -// These static entries stay pinned in both desktop and mobile workspace navigation. -export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [ - { id: "home", label: "Home", icon: Home, active: true, contextKind: "home" }, - { id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" }, -] as const; - -// Freeform workspace tree scaffold: folders are structural, while non-folder -// nodes already flow through the future-safe itemType registry seam. -export const workspaceTree: readonly WorkspaceTreeNode[] = [ - { - id: "product-workspace", - label: "Product", - kind: "folder", - icon: Folder, - children: [ - { id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true }, - { id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" }, - { - id: "research-folder", - label: "Research", - kind: "folder", - icon: Folder, - children: [ - { id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" }, - { id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" }, - ], - }, - ], - }, - { - id: "design-folder", - label: "Design", - kind: "folder", - icon: Folder, - children: [ - { id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" }, - { id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" }, - ], - }, - { id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" }, -] as const; - -export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [ - { id: "search-workspace", label: "Search workspace", icon: Search }, - { id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse }, -] as const; - -export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [ - { id: "home", label: "Home", icon: Home, active: true }, - { id: "search", label: "Search", icon: Search }, - { id: "browse", label: "Browse", icon: Folder }, -] as const; - -// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the -// target kinds and action grouping should stay shared across workspace surfaces. -export const getWorkspaceContextMenuSections = ( - target: WorkspaceContextMenuTarget, -): readonly WorkspaceContextMenuSection[] => { - const createActions = getWorkspaceCreateActions(); - - const createSubmenuAction = { - id: "create", - label: "Create", - children: createActions, - } as const; - - switch (target.kind) { - case "workspace": - return [ - { - id: "create", - label: undefined, - items: [createSubmenuAction], - }, - { - id: "workspace", - label: undefined, - items: [ - { id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } }, - { id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, - ], - }, - ] as const; - case "home": - return [ - { - id: "create", - label: undefined, - items: [createSubmenuAction], - }, - { - id: "workspace", - label: undefined, - items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }], - }, - ] as const; - case "settings": - return [ - { - id: "settings", - label: undefined, - items: [ - { id: "open-settings", label: "Open settings", shortcut: { key: "enter" } }, - { id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } }, - ], - }, - ] as const; - case "folder": - return [ - { - id: "open", - items: [ - { id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, - { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, - ], - }, - { - id: "create", - label: undefined, - items: [createSubmenuAction], - }, - { - id: "organize", - label: undefined, - items: [ - { id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } }, - { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, - ], - }, - ] as const; - case "item": { - const definition = getWorkspaceItemTypeDefinition(target.itemType); - const actionPrefix = definition.actionPrefix; - const nounLabel = definition.noun; - - return [ - { - id: `${actionPrefix}-primary`, - items: [ - { id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } }, - { id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, - ], - }, - { - id: "organize", - label: undefined, - items: [ - { id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } }, - { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, - ], - }, - ] as const; - } - } -}; - -const getProjectCreateActions = (): readonly ProjectContextMenuAction[] => - [ - { id: "new-project", label: "New project" }, - { id: "new-folder", label: "New folder" }, - ] as const; - -const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] => - [ - { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }, - { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, - ] as const; - -export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({ - id: "project-surface", - label, - kind: "surface", -}); - -export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({ - id, - label, - kind: "folder", -}); - -export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({ - id: project.id, - label: project.name, - kind: "project", -}); - -export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => { - switch (target.kind) { - case "surface": - return "Projects"; - case "folder": - return "Folder"; - case "project": - return "Project"; - } -}; - -export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => { - const createActions = getProjectCreateActions(); - - switch (target.kind) { - case "surface": - return [ - { - id: "create", - items: createActions, - }, - ] as const; - case "folder": - return [ - { - id: "create", - items: createActions, - }, - { - id: "organize", - items: getProjectFolderDangerActions(), - }, - ] as const; - case "project": - return [ - { - id: "create", - items: createActions, - }, - ] as const; - } -}; - -export const topBarActions: readonly TopBarAction[] = [ - { id: "search", label: "Search", icon: Search }, -] as const; - -export const notificationItems: readonly NotificationItem[] = [ - { - id: "comment-design-systems", - title: "New comment on Design Systems", - contextLabel: "Product • Review thread updated", - timeLabel: "2m ago", - unread: true, - }, - { - id: "sprint-platform", - title: "Sprint updated in Platform", - contextLabel: "Engineering • Scope changed", - timeLabel: "15m ago", - unread: true, - }, - { - id: "member-joined", - title: "New member joined Operations", - contextLabel: "Organization Name • Access granted", - timeLabel: "1h ago", - }, - { - id: "daily-summary", - title: "Daily summary is ready", - contextLabel: "General • 8 updates across boards", - timeLabel: "Today, 8:00 AM", - }, -] as const; - -export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length; - -export const activeUserProfile: ActiveUserProfile = { - name: "Demo Account", - email: "demo@moku.work", - roleLabel: "Founder · Product", - contextLabel: "Organization Name • Design Systems", -}; - -export const profileMenuSections: readonly ProfileMenuSection[] = [ - { - id: "account", - items: [ - { id: "profile", label: "Profile", icon: User }, - { id: "account-settings", label: "Account Settings", icon: Settings }, - { id: "notifications", label: "Notifications", icon: Bell }, - { id: "security", label: "Security", icon: Shield }, - ], - }, - { - id: "preferences", - items: [ - { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard }, - { id: "theme-preferences", label: "Theme Preferences", icon: Settings }, - { id: "help-support", label: "Help & Support", icon: CircleHelp }, - ], - }, - { - id: "session", - items: [ - { id: "switch-account", label: "Switch Account", icon: Repeat }, - { id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" }, - ], - }, -] as const; diff --git a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts b/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts deleted file mode 100644 index 20057f9..0000000 --- a/Frontend/src/components/shell/shared/useWorkspaceTreeData.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { createEffect, createSignal, type Accessor, type Setter } from "solid-js"; -import { resolveAPIBase } from "../../../lib/api"; -import { Folder } from "../../../lib/icons"; -import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../data/shell.data"; -import { isUuidString } from "./navTreeDnd"; - -type PersistedWorkspaceTreeNodeRecord = { - id: string; - path: string; - label: string; - kind: "folder" | "item"; - itemType?: string; - children?: PersistedWorkspaceTreeNodeRecord[]; -}; - -type WorkspaceTreeResponse = { - data?: { - nodes?: PersistedWorkspaceTreeNodeRecord[]; - }; - error?: string; - message?: string; -}; - -type WorkspaceMutationResponse = { - error?: string; - message?: string; -}; - -const buildPersistedWorkspaceTreeNodes = ( - nodes: readonly PersistedWorkspaceTreeNodeRecord[], -): WorkspaceTreeNode[] => - nodes.map((node) => - node.kind === "folder" - ? { - id: node.id, - path: node.path, - label: node.label, - kind: "folder", - icon: Folder, - children: buildPersistedWorkspaceTreeNodes(node.children ?? []), - } - : { - id: node.id, - path: node.path, - label: node.label, - kind: "item", - itemType: node.itemType ?? "core.board.kanban", - } - ); - -const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] => - Array.isArray(body.data?.nodes) ? body.data.nodes : []; - -const findNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => { - for (const node of nodes) { - if (node.id === nodeId) { - return node; - } - - if (node.kind !== "folder") { - continue; - } - - const nestedMatch = findNodeById(node.children ?? [], nodeId); - if (nestedMatch) { - return nestedMatch; - } - } - - return null; -}; - -type UseWorkspaceTreeDataOptions = { - activeProjectId: Accessor; - fallbackWorkspaceTree: Accessor; -}; - -type UseWorkspaceTreeDataResult = { - workspaceTreeNodes: Accessor; - setWorkspaceTreeNodes: Setter; - resolveFolderPath: (folderId: string) => string | null; - resolveItemPath: (itemId: string) => string | null; - createFolder: (name: string, parentId: string | null) => Promise; - renameFolder: (folderId: string, name: string) => Promise; - deleteFolder: (folderId: string) => Promise; - moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise; - createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise; - deleteItem: (itemId: string) => Promise; - moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise; -}; - -export const useWorkspaceTreeData = ( - options: UseWorkspaceTreeDataOptions, -): UseWorkspaceTreeDataResult => { - const [persistedNodes, setPersistedNodes] = createSignal([]); - const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal( - options.fallbackWorkspaceTree(), - ); - let latestPersistedTreeRequest = 0; - - const syncWorkspaceTree = (): void => { - const nextTree = options.activeProjectId() - ? buildPersistedWorkspaceTreeNodes(persistedNodes()) - : options.fallbackWorkspaceTree(); - - setWorkspaceTreeNodes(nextTree); - }; - - const loadPersistedTree = async (projectId: string): Promise => { - const requestId = latestPersistedTreeRequest + 1; - latestPersistedTreeRequest = requestId; - - if (!projectId || !isUuidString(projectId)) { - setPersistedNodes([]); - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree`, { - headers: { - Accept: "application/json", - }, - }); - const body = (await response.json()) as WorkspaceTreeResponse; - - if (requestId !== latestPersistedTreeRequest) { - return false; - } - - if (!response.ok) { - throw new Error(body.message || "Failed to load project tree."); - } - - setPersistedNodes(readPersistedWorkspaceTreeNodes(body)); - return true; - } catch (error) { - if (requestId !== latestPersistedTreeRequest) { - return false; - } - - console.error(error); - setPersistedNodes([]); - return false; - } - }; - - const resolveFolderPath = (folderId: string): string | null => { - const node = findNodeById(workspaceTreeNodes(), folderId); - return node?.kind === "folder" ? node.path ?? null : null; - }; - - const resolveItemPath = (itemId: string): string | null => { - const node = findNodeById(workspaceTreeNodes(), itemId); - return node?.kind === "item" ? node.path ?? null : null; - }; - - const refreshAfterMutation = async (projectId: string, response: Response): Promise => { - const body = (await response.json()) as WorkspaceMutationResponse; - if (!response.ok) { - throw new Error(body.message || "Failed to persist project tree mutation."); - } - - return loadPersistedTree(projectId); - }; - - const createFolder = async (name: string, parentId: string | null): Promise => { - const projectId = options.activeProjectId(); - if (!projectId || !isUuidString(projectId)) { - return false; - } - - const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; - if (parentId && !parentFolderPath) { - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - parentFolderId: parentFolderPath, - }), - }); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const renameFolder = async (folderId: string, name: string): Promise => { - const projectId = options.activeProjectId(); - const folderPath = resolveFolderPath(folderId); - if (!projectId || !isUuidString(projectId) || !folderPath) { - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - folderId: folderPath, - name, - }), - }); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const deleteFolder = async (folderId: string): Promise => { - const projectId = options.activeProjectId(); - const folderPath = resolveFolderPath(folderId); - if (!projectId || !isUuidString(projectId) || !folderPath) { - return false; - } - - try { - const response = await fetch( - `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`, - { - method: "DELETE", - headers: { - Accept: "application/json", - }, - }, - ); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => { - const projectId = options.activeProjectId(); - const folderPath = resolveFolderPath(folderId); - const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; - if (!projectId || !isUuidString(projectId) || !folderPath) { - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders/move`, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - folderId: folderPath, - folderNodeId: folderId, - parentFolderId: parentFolderPath, - parentNodeId: parentId, - targetIndex, - }), - }); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const createItem = async ( - name: string, - itemType: WorkspaceItemTypeId, - parentId: string | null, - ): Promise => { - const projectId = options.activeProjectId(); - if (!projectId || !isUuidString(projectId)) { - return false; - } - - const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; - if (parentId && !parentFolderPath) { - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - itemType, - parentFolderId: parentFolderPath, - }), - }); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const deleteItem = async (itemId: string): Promise => { - const projectId = options.activeProjectId(); - const itemPath = resolveItemPath(itemId); - if (!projectId || !isUuidString(projectId) || !itemPath) { - return false; - } - - try { - const response = await fetch( - `${resolveAPIBase()}/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemPath)}`, - { - method: "DELETE", - headers: { - Accept: "application/json", - }, - }, - ); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => { - const projectId = options.activeProjectId(); - const itemPath = resolveItemPath(itemId); - const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; - if (!projectId || !isUuidString(projectId) || !itemPath) { - return false; - } - - try { - const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items/move`, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - itemId: itemPath, - itemNodeId: itemId, - parentFolderId: parentFolderPath, - parentNodeId: parentId, - targetIndex, - }), - }); - - return refreshAfterMutation(projectId, response); - } catch (error) { - console.error(error); - return false; - } - }; - - createEffect(() => { - syncWorkspaceTree(); - }); - - createEffect(() => { - void loadPersistedTree(options.activeProjectId()); - }); - - return { - workspaceTreeNodes, - setWorkspaceTreeNodes, - resolveFolderPath, - resolveItemPath, - createFolder, - renameFolder, - deleteFolder, - moveFolder, - createItem, - deleteItem, - moveItem, - }; -}; diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss similarity index 100% rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.module.scss diff --git a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx similarity index 96% rename from Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx rename to Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx index 43d6fd4..03edcf9 100644 --- a/Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.tsx +++ b/Frontend/src/components/top-bar/DepartmentSelector/DepartmentSelector.tsx @@ -1,7 +1,7 @@ import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { ChevronDown } from "../../../lib/icons"; -import { useAppShellData } from "../data/app-shell.context"; -import { type DepartmentItem } from "../data/shell.data"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { type DepartmentItem } from "../../app-shell/data/shell.data"; import styles from "./DepartmentSelector.module.scss"; export const DepartmentSelector = (): JSX.Element => { diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/NotificationsButton.module.scss rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.module.scss diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx similarity index 93% rename from Frontend/src/components/shell/TopBar/NotificationsButton.tsx rename to Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx index 00c8b41..434cfee 100644 --- a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx +++ b/Frontend/src/components/top-bar/TopBar/NotificationsButton.tsx @@ -1,6 +1,6 @@ import type { JSX } from "solid-js"; import { Bell } from "../../../lib/icons"; -import { unreadNotificationCount } from "../data/shell.data"; +import { unreadNotificationCount } from "../../app-shell/data/shell.data"; import styles from "./NotificationsButton.module.scss"; type NotificationsButtonProps = { diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.module.scss diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx similarity index 98% rename from Frontend/src/components/shell/TopBar/NotificationsMenu.tsx rename to Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx index 217864b..1003121 100644 --- a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx +++ b/Frontend/src/components/top-bar/TopBar/NotificationsMenu.tsx @@ -1,6 +1,6 @@ import { For, Show, type JSX } from "solid-js"; import { Bell, Settings } from "../../../lib/icons"; -import { notificationItems, unreadNotificationCount } from "../data/shell.data"; +import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data"; import styles from "./NotificationsMenu.module.scss"; type NotificationsMenuProps = { diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss b/Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/NotificationsNav.module.scss rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.module.scss diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.tsx b/Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx similarity index 100% rename from Frontend/src/components/shell/TopBar/NotificationsNav.tsx rename to Frontend/src/components/top-bar/TopBar/NotificationsNav.tsx diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.module.scss b/Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/ProfileMenu.module.scss rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.module.scss diff --git a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx similarity index 94% rename from Frontend/src/components/shell/TopBar/ProfileMenu.tsx rename to Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx index 62914c4..3689082 100644 --- a/Frontend/src/components/shell/TopBar/ProfileMenu.tsx +++ b/Frontend/src/components/top-bar/TopBar/ProfileMenu.tsx @@ -1,7 +1,7 @@ import { For, type JSX } from "solid-js"; import { User } from "../../../lib/icons"; -import { useAppShellData } from "../data/app-shell.context"; -import { profileMenuSections } from "../data/shell.data"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { profileMenuSections } from "../../app-shell/data/shell.data"; import styles from "./ProfileMenu.module.scss"; type ProfileMenuProps = { diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.module.scss b/Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/ThemeToggle.module.scss rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.module.scss diff --git a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx similarity index 87% rename from Frontend/src/components/shell/TopBar/ThemeToggle.tsx rename to Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx index 86403c9..bcfd830 100644 --- a/Frontend/src/components/shell/TopBar/ThemeToggle.tsx +++ b/Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx @@ -1,7 +1,7 @@ -// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx +// Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx import type { JSX } from "solid-js"; -import type { Theme } from "../../../theme/runtime"; +import type { Theme } from "../../../helper/themeRuntime"; import { Moon, Sun } from "../../../lib/icons"; import styles from "./ThemeToggle.module.scss"; diff --git a/Frontend/src/components/shell/TopBar/TopBar.module.scss b/Frontend/src/components/top-bar/TopBar/TopBar.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/TopBar.module.scss rename to Frontend/src/components/top-bar/TopBar/TopBar.module.scss diff --git a/Frontend/src/components/shell/TopBar/TopBar.tsx b/Frontend/src/components/top-bar/TopBar/TopBar.tsx similarity index 90% rename from Frontend/src/components/shell/TopBar/TopBar.tsx rename to Frontend/src/components/top-bar/TopBar/TopBar.tsx index 194b378..34c4806 100644 --- a/Frontend/src/components/shell/TopBar/TopBar.tsx +++ b/Frontend/src/components/top-bar/TopBar/TopBar.tsx @@ -1,8 +1,8 @@ -// Path: Frontend/src/components/shell/TopBar/TopBar.tsx +// Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx import { For, type JSX } from "solid-js"; -import type { Theme } from "../../../theme/runtime"; -import { topBarActions } from "../data/shell.data"; +import type { Theme } from "../../../helper/themeRuntime"; +import { topBarActions } from "../../app-shell/data/shell.data"; import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector"; import { NotificationsNav } from "./NotificationsNav"; import { ThemeToggle } from "./ThemeToggle"; diff --git a/Frontend/src/components/shell/TopBar/UserNav.module.scss b/Frontend/src/components/top-bar/TopBar/UserNav.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/UserNav.module.scss rename to Frontend/src/components/top-bar/TopBar/UserNav.module.scss diff --git a/Frontend/src/components/shell/TopBar/UserNav.tsx b/Frontend/src/components/top-bar/TopBar/UserNav.tsx similarity index 100% rename from Frontend/src/components/shell/TopBar/UserNav.tsx rename to Frontend/src/components/top-bar/TopBar/UserNav.tsx diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.module.scss b/Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss similarity index 100% rename from Frontend/src/components/shell/TopBar/UserNavButton.module.scss rename to Frontend/src/components/top-bar/TopBar/UserNavButton.module.scss diff --git a/Frontend/src/components/shell/TopBar/UserNavButton.tsx b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx similarity index 93% rename from Frontend/src/components/shell/TopBar/UserNavButton.tsx rename to Frontend/src/components/top-bar/TopBar/UserNavButton.tsx index 6d28932..3c4945a 100644 --- a/Frontend/src/components/shell/TopBar/UserNavButton.tsx +++ b/Frontend/src/components/top-bar/TopBar/UserNavButton.tsx @@ -1,4 +1,4 @@ -// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx +// Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx import type { JSX } from "solid-js"; import { User } from "../../../lib/icons"; diff --git a/Frontend/src/components/shell/TopBar/createDesktopMenuController.ts b/Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts similarity index 100% rename from Frontend/src/components/shell/TopBar/createDesktopMenuController.ts rename to Frontend/src/components/top-bar/TopBar/createDesktopMenuController.ts diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss new file mode 100644 index 0000000..cb1ab20 --- /dev/null +++ b/Frontend/src/components/workspace-home/WorkspaceHome.module.scss @@ -0,0 +1,25 @@ +/* Path: Frontend/src/components/workspace-home/WorkspaceHome.module.scss */ + +.viewport { + --workspace-content-max-width: var(--content-width-wide); + + min-width: 0; + min-height: 0; + display: grid; + align-content: start; + gap: var(--space-4); + padding: var(--space-5) var(--space-6); +} + +.title { + @include text-display; + font-family: var(--font-family-display); + max-width: 12ch; +} + +@include respond-down(mobile) { + .viewport { + gap: var(--space-4); + padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px)); + } +} diff --git a/Frontend/src/components/workspace-home/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome.tsx new file mode 100644 index 0000000..b1e770b --- /dev/null +++ b/Frontend/src/components/workspace-home/WorkspaceHome.tsx @@ -0,0 +1,15 @@ +// Path: Frontend/src/components/workspace-home/WorkspaceHome.tsx + +import { type JSX } from "solid-js"; +import { useAppShellData } from "../app-shell/data/app-shell.context"; +import styles from "./WorkspaceHome.module.scss"; + +export const WorkspaceHome = (): JSX.Element => { + const appShellData = useAppShellData(); + + return ( +
    +

    {appShellData.activeServer().name}

    +
    + ); +}; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx deleted file mode 100644 index 96ec8b9..0000000 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ /dev/null @@ -1,271 +0,0 @@ -// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx - -import { Show, createMemo, type JSX } from "solid-js"; -import { Portal } from "solid-js/web"; -import { ChevronLeft, ChevronRight } from "../../../lib/icons"; -import { useAppShellData } from "../../shell/data/app-shell.context"; -import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data"; -import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook"; -import styles from "./WorkspaceHome.module.scss"; -import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts"; - -type WorkspaceHomeProps = { - sidebarCollapsed: boolean; - onToggleSidebarCollapse: () => void; -}; - -export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { - const appShellData = useAppShellData(); - const { - instanceForm, - setInstanceForm, - modeForm, - setModeForm, - adminForm, - setAdminForm, - structureForm, - setStructureForm, - selectedPersona, - hasChosenPersona, - stepState, - isBootstrapStateResolved, - isWizardOpen, - setIsWizardOpen, - setIsFinishingBootstrapFlow, - fieldTooltip, - materializationState, - isMaterializationInFlight, - hasMaterializationFailed, - showBootstrapFinishingState, - materializationStatusLabel, - materializationMessage, - personaDefinition, - selectedPersonaIsAvailable, - usesCondensedBootstrapFlow, - activeWizardSteps, - bootstrapNamePlaceholder, - bootstrapStepCount, - currentStep, - currentWizardStepIndex, - wizardProgressFillWidth, - currentStepState, - isFirstStep, - canDismissWizard, - handleCurrentStepSubmit, - applyPersonaSelection, - statusLabel, - showFieldTooltip, - hideFieldTooltip, - stepStatusLabel, - navigateBack, - navigateToVisibleStep, - } = useWorkspaceHomeWizard(appShellData); - const isBootstrapPersisted = createMemo(() => appShellData.installation()?.isBootstrapped ?? false); - - const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"); - const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; - - return ( - <> -
    -
    -
    - -
    - -
    - {breadcrumb()} -
    - - - -
    -

    {isBootstrapPersisted() ? appShellData.activeServer().name : "Server"}

    - -
    - -
    -
    -
    -
    - - - -
    - - - - - ); -}; diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.module.scss b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss similarity index 100% rename from Frontend/src/components/shell/LeftRail/LeftRail.module.scss rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.module.scss diff --git a/Frontend/src/components/shell/LeftRail/LeftRail.tsx b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx similarity index 90% rename from Frontend/src/components/shell/LeftRail/LeftRail.tsx rename to Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx index 075e1d6..6332ddc 100644 --- a/Frontend/src/components/shell/LeftRail/LeftRail.tsx +++ b/Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx @@ -1,8 +1,8 @@ -// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx +// Path: Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx import { For, Show, type JSX } from "solid-js"; -import { useAppShellData } from "../data/app-shell.context"; -import { type RailItem } from "../data/shell.data"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { type RailItem } from "../../app-shell/data/shell.data"; import styles from "./LeftRail.module.scss"; type RailEntryProps = { diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts new file mode 100644 index 0000000..1a896bf --- /dev/null +++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts @@ -0,0 +1,91 @@ +// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts + +import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data"; + +export type MobileWorkspaceBrowserProps = { + open: boolean; + onClose: VoidFunction; +}; + +export type MobileWorkspaceDialogState = + | { + kind: "text"; + title: string; + message: string; + confirmLabel: string; + initialValue: string; + onConfirm: (value: string) => void; + } + | { + kind: "confirm"; + title: string; + message: string; + confirmLabel: string; + tone?: "danger"; + onConfirm: () => void; + }; + +export type MobileMoveTargetState = { + kind: "folder" | "item"; + id: string; + label: string; +}; + +export type MobileMoveDestination = { + id: string | null; + label: string; + depth: number; + meta?: string; +}; + +export const collectMoveDestinations = ( + nodes: readonly WorkspaceTreeNode[], + movingTarget: MobileMoveTargetState, + depth = 0, + ancestorBlocked = false, +): MobileMoveDestination[] => { + const destinations: MobileMoveDestination[] = []; + + for (const node of nodes) { + if (node.kind !== "folder") { + continue; + } + + const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id; + if (!ancestorBlocked && !isBlockedFolder) { + destinations.push({ + id: node.id, + label: node.label, + depth, + meta: "Folder", + }); + } + + destinations.push( + ...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder), + ); + } + + return destinations; +}; + +export const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + + if (node.kind !== "folder") { + continue; + } + + const nestedMatch = findTreeNodeById(node.children ?? [], nodeId); + if (nestedMatch) { + return nestedMatch; + } + } + + return null; +}; + +export const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger"; diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts new file mode 100644 index 0000000..1a845f2 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts @@ -0,0 +1,273 @@ +// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts + +import { createSignal } from "solid-js"; +import { createLongPressGesture } from "../../../helper/createLongPressGesture"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { + createWorkspaceSurfaceTarget, + getWorkspaceItemTypeDefinition, + type WorkspaceContextMenuAction, + type WorkspaceContextMenuTarget, + type WorkspaceItemTypeId, +} from "../../app-shell/data/shell.data"; +import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData"; +import { + collectMoveDestinations, + findTreeNodeById, + type MobileMoveDestination, + type MobileMoveTargetState, + type MobileWorkspaceDialogState, +} from "./MobileWorkspaceBrowser.data"; + +export const useMobileWorkspaceBrowser = () => { + const appShellData = useAppShellData(); + const [actionSheetTarget, setActionSheetTarget] = createSignal(null); + const [dialogState, setDialogState] = createSignal(null); + const [dialogValue, setDialogValue] = createSignal(""); + const [moveTarget, setMoveTarget] = createSignal(null); + const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({ + activeProjectId: () => appShellData.activeProject().id, + fallbackWorkspaceTree: () => appShellData.workspaceTree(), + }); + const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject()); + + const moveDestinations = (): MobileMoveDestination[] => { + const target = moveTarget(); + if (!target) { + return []; + } + + return [{ id: null, label: "Items root", depth: 0, meta: "Root" }, ...collectMoveDestinations(workspaceTreeNodes(), target)]; + }; + + const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => { + switch (actionId) { + case "create-doc": + return "core.doc"; + case "create-board": + return "core.board.kanban"; + case "create-list-board": + return "core.board.list"; + default: + return null; + } + }; + + const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => { + const definition = getWorkspaceItemTypeDefinition(itemType); + void createItem(definition.defaultCreateLabel, itemType, parentId); + }; + + const openActionSheet = (target: WorkspaceContextMenuTarget): void => { + setActionSheetTarget(target); + }; + + const closeActionSheet = (): void => { + setActionSheetTarget(null); + }; + + const closeMoveSheet = (): void => { + setMoveTarget(null); + }; + + const openWorkspaceActionSheet = (): void => { + openActionSheet(workspaceTarget()); + }; + + const closeDialog = (): void => { + setDialogState(null); + setDialogValue(""); + }; + + const openMoveSheet = (target: MobileMoveTargetState): void => { + setMoveTarget(target); + }; + + const openTextDialog = (config: Omit, "kind">): void => { + setDialogValue(config.initialValue); + setDialogState({ kind: "text", ...config }); + }; + + const openConfirmDialog = (config: Omit, "kind">): void => { + setDialogValue(""); + setDialogState({ kind: "confirm", ...config }); + }; + + const submitDialog = (): void => { + const state = dialogState(); + if (!state) { + return; + } + + if (state.kind === "text") { + const value = dialogValue().trim(); + if (!value) { + return; + } + + closeDialog(); + state.onConfirm(value); + return; + } + + closeDialog(); + state.onConfirm(); + }; + + const handleMoveDestinationSelect = (destinationId: string | null): void => { + const target = moveTarget(); + if (!target) { + return; + } + + const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null; + const targetIndex = destinationNode?.kind === "folder" + ? destinationNode.children?.length ?? 0 + : destinationId + ? 0 + : workspaceTreeNodes().length; + + closeMoveSheet(); + + if (target.kind === "folder") { + void moveFolder(target.id, destinationId, targetIndex); + return; + } + + void moveItem(target.id, destinationId, targetIndex); + }; + + const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { + const createItemType = resolveCreateItemType(action.id); + if (createItemType) { + switch (target.kind) { + case "workspace": + case "home": + createPersistedItem(createItemType, null); + return; + case "folder": + createPersistedItem(createItemType, target.id); + return; + case "settings": + case "item": + return; + } + } + + switch (action.id) { + case "new-folder": { + if (target.kind === "settings" || target.kind === "item") { + return; + } + + openTextDialog({ + title: "New folder", + message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.", + confirmLabel: "Create", + initialValue: "Untitled folder", + onConfirm: (name) => { + void createFolder(name, target.kind === "folder" ? target.id : null); + }, + }); + return; + } + case "rename-folder": { + if (target.kind !== "folder") { + return; + } + + openTextDialog({ + title: "Rename folder", + message: `Update the name for "${target.label}".`, + confirmLabel: "Save", + initialValue: target.label, + onConfirm: (name) => { + if (name === target.label) { + return; + } + void renameFolder(target.id, name); + }, + }); + return; + } + case "move-folder": { + if (target.kind !== "folder") { + return; + } + + openMoveSheet({ kind: "folder", id: target.id, label: target.label }); + return; + } + case "delete-folder": { + if (target.kind !== "folder") { + return; + } + + openConfirmDialog({ + title: "Delete folder?", + message: `"${target.label}" and everything inside it will be removed.`, + confirmLabel: "Delete", + tone: "danger", + onConfirm: () => { + void deleteFolder(target.id); + }, + }); + return; + } + case "move-doc": + case "move-board": + case "move-list-board": { + if (target.kind !== "item") { + return; + } + + openMoveSheet({ kind: "item", id: target.id, label: target.label }); + return; + } + case "delete-doc": + case "delete-board": + case "delete-list-board": { + if (target.kind !== "item") { + return; + } + + openConfirmDialog({ + title: "Delete item?", + message: `"${target.label}" will be removed from the project tree.`, + confirmLabel: "Delete", + tone: "danger", + onConfirm: () => { + void deleteItem(target.id); + }, + }); + return; + } + default: + return; + } + }; + + const workspaceLongPress = createLongPressGesture({ + onLongPress: openWorkspaceActionSheet, + }); + + return { + appShellData, + actionSheetTarget, + openActionSheet, + closeActionSheet, + dialogState, + dialogValue, + setDialogValue, + closeDialog, + submitDialog, + moveTarget, + moveDestinations, + closeMoveSheet, + handleMoveDestinationSelect, + workspaceTreeNodes, + handleActionSelect, + workspaceLongPress, + openWorkspaceActionSheet, + }; +}; diff --git a/Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss similarity index 100% rename from Frontend/src/components/shell/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss rename to Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.module.scss diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx new file mode 100644 index 0000000..15b4e2d --- /dev/null +++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx @@ -0,0 +1,140 @@ +// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx + +import { For, Show, type JSX } from "solid-js"; +import { ChevronRight } from "../../../lib/icons"; +import { createLongPressGesture } from "../../../helper/createLongPressGesture"; +import { + createWorkspaceStaticTarget, + createWorkspaceTreeTarget, + getWorkspaceNodeIcon, + type SidebarItem, + type WorkspaceContextMenuTarget, + type WorkspaceStaticItem, + type WorkspaceTreeNode, +} from "../../app-shell/data/shell.data"; +import styles from "./MobileWorkspaceBrowser.module.scss"; + +export const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => { + const depth = props.depth ?? 0; + const Icon = getWorkspaceNodeIcon(props.node); + const hasChildren = (props.node.children?.length ?? 0) > 0; + + return ( + + ); +}; + +export const StaticRow = (props: { item: SidebarItem }): JSX.Element => { + const Icon = props.item.icon; + + return ( + + ); +}; + +export const WorkspaceStaticRow = (props: { + item: WorkspaceStaticItem; + onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; +}): JSX.Element => { + const target = createWorkspaceStaticTarget(props.item); + const longPress = createLongPressGesture({ + onLongPress: () => { + props.onOpenActionSheet(target); + }, + }); + + return ( +
  • { + event.preventDefault(); + props.onOpenActionSheet(target); + }} + {...longPress} + > + +
  • + ); +}; + +export const WorkspaceTreeBranch = (props: { + nodes: readonly WorkspaceTreeNode[]; + depth?: number; + onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void; +}): JSX.Element => { + const depth = props.depth ?? 0; + + return ( + + {(node): JSX.Element => { + const target = createWorkspaceTreeTarget(node); + const longPress = createLongPressGesture({ + onLongPress: () => { + props.onOpenActionSheet(target); + }, + }); + + return ( +
  • { + event.preventDefault(); + props.onOpenActionSheet(target); + }} + {...longPress} + > + + + +
      + +
    +
    +
  • + ); + }} +
    + ); +}; diff --git a/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx new file mode 100644 index 0000000..780fc50 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.tsx @@ -0,0 +1,192 @@ +import { For, Show, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { Plus, X } from "../../../lib/icons"; +import { + workspaceStaticItems, +} from "../../app-shell/data/shell.data"; +import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet"; +import { useMobileWorkspaceBrowser } from "./MobileWorkspaceBrowser.hook"; +import { isDangerDialogState, type MobileWorkspaceBrowserProps } from "./MobileWorkspaceBrowser.data"; +import { WorkspaceStaticRow, WorkspaceTreeBranch } from "./MobileWorkspaceBrowser.parts"; +import styles from "./MobileWorkspaceBrowser.module.scss"; + +export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => { + const { + appShellData, + actionSheetTarget, + openActionSheet, + closeActionSheet, + dialogState, + dialogValue, + setDialogValue, + closeDialog, + submitDialog, + moveTarget, + moveDestinations, + closeMoveSheet, + handleMoveDestinationSelect, + workspaceTreeNodes, + handleActionSelect, + workspaceLongPress, + openWorkspaceActionSheet, + } = useMobileWorkspaceBrowser(); + + return ( + +
    +
    +
    +
    { + event.preventDefault(); + openWorkspaceActionSheet(); + }} + {...workspaceLongPress} + > + {/* Long-pressing the browser header exposes workspace-level actions on mobile. */} + Moku Work + {appShellData.activeProject().name} + {appShellData.activeServer().name} +
    + +
    + + + +
    +
    + +
    +
    + Workspace +
      + + {(item): JSX.Element => } + +
    +
    + +
    + Items +
      + +
    +
    +
    +
    + + + + + {(state): JSX.Element => ( + +
    + + )} + +
    +
    + +
    + +
    + +
    +
    + )} +
    + + + {(state): JSX.Element => ( + +
    + + +
    + + +
    + )} +
    + + + ); +}; diff --git a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx similarity index 99% rename from Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx index 089c8ee..c27c049 100644 --- a/Frontend/src/components/shell/ProjectContextMenu/ProjectContextMenu.tsx +++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/ProjectContextMenu.tsx @@ -7,7 +7,7 @@ import { type ProjectContextMenuAction, type ProjectMenuTarget, type WorkspaceContextMenuShortcut, -} from "../data/shell.data"; +} from "../../app-shell/data/shell.data"; import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss"; type ShortcutPlatform = "mac" | "windows"; diff --git a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts similarity index 97% rename from Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts rename to Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts index 3110220..5e5e5d5 100644 --- a/Frontend/src/components/shell/ProjectContextMenu/createProjectContextMenuController.ts +++ b/Frontend/src/components/workspace-navigation/ProjectContextMenu/createProjectContextMenuController.ts @@ -1,5 +1,5 @@ import { createEffect, createSignal, onCleanup } from "solid-js"; -import type { ProjectMenuTarget } from "../data/shell.data"; +import type { ProjectMenuTarget } from "../../app-shell/data/shell.data"; type ProjectContextMenuState = { target: ProjectMenuTarget; diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts new file mode 100644 index 0000000..69279ad --- /dev/null +++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts @@ -0,0 +1,144 @@ +// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts + +import type { JSX } from "solid-js"; +import type { + NavTreeAdapter, + NavTreeDropTarget, +} from "../shared/navTreeDnd"; +import type { ProjectItem } from "../../app-shell/data/shell.data"; + +export type ProjectSelectorProps = { + compact?: boolean; + isOpen: boolean; + onToggle: () => void; + onClose: () => void; +}; + +export type ProjectFolderNode = { + kind: "folder"; + id: string; + path: string; + label: string; + meta?: string; + children: ProjectTreeNode[]; +}; + +export type ProjectLeafNode = { + kind: "project"; + item: ProjectItem; +}; + +export type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; + +export type PersistedProjectFolderRecord = { + id: string; + path: string; + label: string; + children: PersistedProjectFolderRecord[]; +}; + +export type ProjectFoldersResponse = { + data?: { + folders?: PersistedProjectFolderRecord[]; + renamedFolder?: PersistedProjectFolderRecord; + movedFolder?: PersistedProjectFolderRecord; + previousFolderId?: string; + previousFolderPath?: string; + }; + error?: string; + message?: string; +}; + +export type PendingProjectFolderDraft = { + parentId: string | null; + depth: number; +}; + +export type PendingProjectFolderRename = { + folderId: string; + depth: number; +}; + +export type ProjectDragTarget = NavTreeDropTarget; + +export type ProjectDragState = { + draggedNodeId: string; + dropTarget: ProjectDragTarget | null; +}; + +export type DragGhostPosition = { + x: number; + y: number; +}; + +export type ProjectFolderBranchProps = { + nodes: readonly ProjectTreeNode[]; + depth: number; + parentId: string | null; + selectedProjectId: string; + isFolderCollapsed: (folderId: string) => boolean; + onToggleFolder: (folderId: string) => void; + onSelectProject: (projectId: string) => void; + onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void; + onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void; + onNodePointerDown: (event: PointerEvent, nodeId: string) => void; + onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void; + pendingFolderDraft: PendingProjectFolderDraft | null; + pendingFolderName: string; + onPendingFolderNameChange: (value: string) => void; + onSubmitPendingFolder: () => void; + onCancelPendingFolder: () => void; + pendingFolderRename: PendingProjectFolderRename | null; + pendingFolderRenameName: string; + onPendingFolderRenameChange: (value: string) => void; + onSubmitPendingFolderRename: () => void; + onCancelPendingFolderRename: () => void; + dragState: ProjectDragState | null; + isTreeClickSuppressed: () => boolean; +}; + +export const LONG_PRESS_MS = 320; + +export const getProjectTreeNodeId = (node: ProjectTreeNode): string => + node.kind === "folder" ? node.id : node.item.id; + +export const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] => + folders.map((folder) => ({ + kind: "folder", + id: folder.id, + path: folder.path, + label: folder.label, + children: buildPersistedFolderNodes(folder.children ?? []), + })); + +export const buildProjectTree = ( + items: readonly ProjectItem[], + folders: readonly PersistedProjectFolderRecord[] = [], +): ProjectTreeNode[] => [ + ...items.map((item) => ({ + kind: "project" as const, + item, + })), + ...buildPersistedFolderNodes(folders), +]; + +export const countProjectFolderSiblingsBeforeIndex = ( + siblings: readonly ProjectTreeNode[], + index: number, +): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; + +export const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => + Array.isArray(body.data?.folders) ? body.data.folders : []; + +export const projectTreeAdapter: NavTreeAdapter = { + getNodeId: getProjectTreeNodeId, + isBranchNode: (node) => node.kind === "folder", + getChildren: (node) => (node.kind === "folder" ? node.children : []), + withChildren: (node, children) => + node.kind === "folder" + ? { + ...node, + children: [...children], + } + : node, +}; diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts new file mode 100644 index 0000000..bf82f2d --- /dev/null +++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts @@ -0,0 +1,671 @@ +// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts + +import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { + createProjectFolderTarget, + createProjectSurfaceTarget, + createProjectTarget, + type ProjectItem, + type ProjectMenuTarget, +} from "../../app-shell/data/shell.data"; +import { + collectBranchNodeIds, + findTreeNodeDepth, + findTreeNodeLocation, + getPointerRelativeY, + isUuidString, + moveTreeNode, + resolveTreeDropTarget, +} from "../shared/navTreeDnd"; +import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController"; +import { + LONG_PRESS_MS, + buildProjectTree, + countProjectFolderSiblingsBeforeIndex, + projectTreeAdapter, + readPersistedFolders, + type DragGhostPosition, + type PendingProjectFolderDraft, + type PendingProjectFolderRename, + type PersistedProjectFolderRecord, + type ProjectDragState, + type ProjectFolderNode, + type ProjectSelectorProps, + type ProjectTreeNode, +} from "./ProjectSelector.data"; +import { + createProjectFolderRequest, + deleteProjectFolderRequest, + fetchProjectFolders, + isValidProjectFolderProjectId, + moveProjectFolderRequest, + renameProjectFolderRequest, +} from "./projectFolders.api"; + +export const useProjectSelector = (props: ProjectSelectorProps) => { + const appShellData = useAppShellData(); + const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject()); + const [drawerTop, setDrawerTop] = createSignal(0); + const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); + const [persistedFolders, setPersistedFolders] = createSignal([]); + const [projectTreeNodes, setProjectTreeNodes] = createSignal(buildProjectTree(appShellData.projectItems(), persistedFolders())); + const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); + const [pendingFolderName, setPendingFolderName] = createSignal(""); + const [pendingFolderRename, setPendingFolderRename] = createSignal(null); + const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); + const [dragState, setDragState] = createSignal(null); + const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 }); + const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); + let rootRef: HTMLDivElement | undefined; + let triggerRef: HTMLButtonElement | undefined; + let contextMenuRef: HTMLDivElement | undefined; + let longPressTimer: number | undefined; + let suppressClickTimer: number | undefined; + let lastSelectedProjectId: string | null = null; + let latestPersistedFoldersRequest = 0; + const contextMenu = createProjectContextMenuController(); + + const setRootRef = (element: HTMLDivElement | undefined): void => { + rootRef = element; + }; + + const setTriggerRef = (element: HTMLButtonElement | undefined): void => { + triggerRef = element; + }; + + const setContextMenuElement = (element: HTMLDivElement | undefined): void => { + contextMenuRef = element; + if (element) { + contextMenu.setMenuRef(element); + } + }; + + const clearLongPressTimer = (): void => { + if (longPressTimer !== undefined) { + window.clearTimeout(longPressTimer); + longPressTimer = undefined; + } + }; + + const updateDragGhostPosition = (x: number, y: number): void => { + setDragGhostPosition({ x: x + 18, y: y + 18 }); + }; + + const draggedNode = (): ProjectTreeNode | null => { + const currentDragState = dragState(); + if (!currentDragState) { + return null; + } + + return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null; + }; + + const draggedNodeMeta = (): string => { + const node = draggedNode(); + if (!node) { + return ""; + } + + return node.kind === "folder" ? "Folder" : "Project"; + }; + + const suppressTreeClickTemporarily = (): void => { + setSuppressNextTreeClick(true); + + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); + } + + suppressClickTimer = window.setTimeout(() => { + setSuppressNextTreeClick(false); + suppressClickTimer = undefined; + }, 80); + }; + + const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); + + const toggleFolder = (folderId: string): void => { + setCollapsedFolderIds((current) => (current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId])); + }; + + const syncProjectTree = (): void => { + const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders()); + const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter)); + + setProjectTreeNodes(nextTree); + setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId))); + }; + + const resetProjectTreeInteractionState = (): void => { + setCollapsedFolderIds([]); + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename(null); + setPendingFolderRenameName(""); + setDragState(null); + }; + + const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter); + + const expandAllFolders = (): void => { + setCollapsedFolderIds([]); + }; + + const collapseAllFolders = (): void => { + setCollapsedFolderIds(folderIds()); + }; + + const totalFolderCount = (): number => folderIds().length; + + const areAllFoldersCollapsed = (): boolean => { + const folderCount = totalFolderCount(); + return folderCount > 0 && collapsedFolderIds().length >= folderCount; + }; + + const toggleAllFolders = (): void => { + if (areAllFoldersCollapsed()) { + expandAllFolders(); + return; + } + + collapseAllFolders(); + }; + + const loadPersistedFolders = async (projectId: string): Promise => { + const requestId = latestPersistedFoldersRequest + 1; + latestPersistedFoldersRequest = requestId; + + if (!isValidProjectFolderProjectId(projectId) || !isUuidString(projectId)) { + setPersistedFolders([]); + return; + } + + try { + const body = await fetchProjectFolders(projectId); + + if (requestId !== latestPersistedFoldersRequest) { + return; + } + + setPersistedFolders(readPersistedFolders(body)); + } catch (error) { + if (requestId !== latestPersistedFoldersRequest) { + return; + } + + console.error(error); + setPersistedFolders([]); + } + }; + + createEffect(() => { + setSelectedProject(appShellData.activeProject()); + }); + + createEffect(() => { + syncProjectTree(); + }); + + createEffect(() => { + const projectId = selectedProject().id; + + if (lastSelectedProjectId === null) { + lastSelectedProjectId = projectId; + return; + } + + if (projectId === lastSelectedProjectId) { + return; + } + + lastSelectedProjectId = projectId; + resetProjectTreeInteractionState(); + }); + + createEffect(() => { + void loadPersistedFolders(selectedProject().id); + }); + + onMount(() => { + if (triggerRef) { + const updateDrawerTop = (): void => { + if (!triggerRef) { + return; + } + + setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8); + }; + + updateDrawerTop(); + + const observer = new ResizeObserver(() => { + updateDrawerTop(); + }); + + observer.observe(triggerRef); + window.addEventListener("resize", updateDrawerTop); + + onCleanup(() => { + observer.disconnect(); + window.removeEventListener("resize", updateDrawerTop); + }); + } + + const handlePointerDown = (event: PointerEvent): void => { + if (!props.isOpen || !rootRef) { + return; + } + + const target = event.target; + + if (target instanceof Node && rootRef.contains(target)) { + return; + } + + if (target instanceof Node && contextMenuRef?.contains(target)) { + return; + } + + props.onClose(); + }; + + const handlePointerUp = (): void => { + clearLongPressTimer(); + + const nextDragState = dragState(); + + if (!nextDragState?.dropTarget) { + if (nextDragState) { + suppressTreeClickTemporarily(); + } + setDragState(null); + return; + } + + suppressTreeClickTemporarily(); + + const currentNodes = projectTreeNodes(); + const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter); + const canPersistMove = isUuidString(selectedProject().id); + const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null; + const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget, projectTreeAdapter); + const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter); + const persistedParentLocation = previewLocation?.parentId ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null; + const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null; + const previewSiblings = previewLocation?.parentId ? persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.children : [] : previewNodes; + const targetIndex = previewLocation ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0; + + if (canPersistMove && draggedLocation?.node.kind === "folder" && draggedFolderPath && (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")) { + void movePersistedFolder( + draggedFolderPath, + persistedParentFolderPath, + draggedLocation.node.id, + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, + targetIndex, + ); + } else { + setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget!, projectTreeAdapter)); + } + + setDragState(null); + }; + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape") { + return; + } + + clearLongPressTimer(); + + if (dragState()) { + setDragState(null); + return; + } + + if (!props.isOpen) { + return; + } + + props.onClose(); + triggerRef?.focus(); + }; + + const handlePointerMove = (event: PointerEvent): void => { + if (!dragState()) { + return; + } + + updateDragGhostPosition(event.clientX, event.clientY); + }; + + document.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("keydown", handleEscape); + + onCleanup(() => { + clearLongPressTimer(); + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); + } + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("keydown", handleEscape); + }); + }); + + const toggleOpen = (): void => { + if (!props.isOpen) { + props.onToggle(); + return; + } + + props.onClose(); + }; + + const selectProject = (projectId: string): void => { + const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter); + + if (!location || location.node.kind !== "project") { + return; + } + + setSelectedProject({ id: location.node.item.id, name: location.node.item.name }); + props.onClose(); + }; + + const beginFolderDraft = (parentId: string | null, depth: number): void => { + if (parentId) { + setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); + } + + setPendingFolderRename(null); + setPendingFolderRenameName(""); + setPendingFolderName(""); + setPendingFolderDraft({ parentId, depth }); + }; + + const beginFolderRename = (folderId: string, label: string, depth: number): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename({ folderId, depth }); + setPendingFolderRenameName(label); + }; + + const resolveFolderPath = (folderId: string): string | null => { + const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter); + return location && location.node.kind === "folder" ? location.node.path : null; + }; + + const submitPendingFolder = async (): Promise => { + const name = pendingFolderName().trim(); + const draft = pendingFolderDraft(); + const projectId = selectedProject().id; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderDraft(null); + setPendingFolderName(""); + return; + } + + if (!isUuidString(projectId)) { + cancelPendingFolder(); + return; + } + + const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; + if (draft.parentId && !parentFolderPath) { + cancelPendingFolder(); + return; + } + + try { + const body = await createProjectFolderRequest(projectId, name, parentFolderPath); + + setPersistedFolders(readPersistedFolders(body)); + setPendingFolderDraft(null); + setPendingFolderName(""); + } catch (error) { + console.error(error); + } + }; + + const deletePersistedFolder = async (folderId: string): Promise => { + const projectId = selectedProject().id; + if (!folderId || !isUuidString(projectId)) { + return; + } + + const folderPath = resolveFolderPath(folderId); + if (!folderPath) { + return; + } + + try { + const body = await deleteProjectFolderRequest(projectId, folderPath); + + setPersistedFolders(readPersistedFolders(body)); + setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); + } catch (error) { + console.error(error); + } + }; + + const movePersistedFolder = async ( + folderPath: string, + parentFolderPath: string | null, + folderStableId: string, + parentStableId: string | null, + targetIndex: number, + ): Promise => { + const projectId = selectedProject().id; + if (!folderPath || !folderStableId || !isUuidString(projectId)) { + return; + } + + try { + const body = await moveProjectFolderRequest(projectId, { + folderId: folderPath, + folderNodeId: folderStableId, + parentFolderId: parentFolderPath, + parentNodeId: parentStableId, + targetIndex, + }); + + setPersistedFolders(readPersistedFolders(body)); + } catch (error) { + console.error(error); + } + }; + + const submitPendingFolderRename = async (): Promise => { + const draft = pendingFolderRename(); + const name = pendingFolderRenameName().trim(); + const projectId = selectedProject().id; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + return; + } + + if (!isUuidString(projectId)) { + cancelPendingFolderRename(); + return; + } + + const folderPath = resolveFolderPath(draft.folderId); + if (!folderPath) { + cancelPendingFolderRename(); + return; + } + + try { + const body = await renameProjectFolderRequest(projectId, folderPath, name); + + setPersistedFolders(readPersistedFolders(body)); + setPendingFolderRename(null); + setPendingFolderRenameName(""); + } catch (error) { + console.error(error); + } + }; + + const cancelPendingFolder = (): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + const cancelPendingFolderRename = (): void => { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + }; + + const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => { + switch (action.id) { + case "new-folder": + switch (target.kind) { + case "surface": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1); + return; + case "project": { + const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null; + beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0); + return; + } + } + return; + case "delete-folder": + if (target.kind === "folder") { + void deletePersistedFolder(target.id); + } + return; + case "rename-folder": + if (target.kind === "folder") { + beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0); + } + return; + default: + return; + } + }; + + const handleSurfaceContextMenu = (event: MouseEvent): void => { + event.stopPropagation(); + contextMenu.openMenu(event, createProjectSurfaceTarget("Projects")); + }; + + const openRootCreateMenu = (element: HTMLElement): void => { + contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects")); + }; + + const treeControlLabel = (): string => (areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"); + + const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => { + if (event.button !== 0 || pendingFolderDraft()) { + return; + } + + updateDragGhostPosition(event.clientX, event.clientY); + clearLongPressTimer(); + longPressTimer = window.setTimeout(() => { + suppressTreeClickTemporarily(); + setDragState({ draggedNodeId: nodeId, dropTarget: null }); + }, LONG_PRESS_MS); + }; + + const handleNodePointerMove = (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode): void => { + const nextDragState = dragState(); + + if (!nextDragState || nextDragState.draggedNodeId === (node.kind === "folder" ? node.id : node.item.id)) { + return; + } + + const relativeY = getPointerRelativeY(event); + if (relativeY === null) { + return; + } + + setDragState({ + ...nextDragState, + dropTarget: resolveTreeDropTarget({ + parentId, + index, + node, + relativeY, + adapter: projectTreeAdapter, + }), + }); + }; + + const openFolderMenu = (event: MouseEvent, folder: ProjectFolderNode): void => { + event.stopPropagation(); + contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label)); + }; + + const openProjectMenu = (event: MouseEvent, item: ProjectItem): void => { + event.stopPropagation(); + contextMenu.openMenu(event, createProjectTarget(item)); + }; + + const contextMenuPosition = () => { + const state = contextMenu.menuState(); + return state ? { x: state.x, y: state.y } : null; + }; + + return { + selectedProject, + drawerTop, + setRootRef, + setTriggerRef, + setContextMenuElement, + toggleOpen, + handleSurfaceContextMenu, + openRootCreateMenu, + treeControlLabel, + toggleAllFolders, + totalFolderCount, + areAllFoldersCollapsed, + projectTreeNodes, + isFolderCollapsed, + toggleFolder, + selectProject, + openFolderMenu, + openProjectMenu, + handleNodePointerDown, + handleNodePointerMove, + pendingFolderDraft, + pendingFolderName, + setPendingFolderName, + submitPendingFolder, + cancelPendingFolder, + pendingFolderRename, + pendingFolderRenameName, + setPendingFolderRenameName, + submitPendingFolderRename, + cancelPendingFolderRename, + dragState, + suppressNextTreeClick, + contextMenu, + contextMenuPosition, + handleContextActionSelect, + draggedNode, + dragGhostPosition, + draggedNodeMeta, + }; +}; diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss similarity index 100% rename from Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss rename to Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.module.scss diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx new file mode 100644 index 0000000..4e3e2a4 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx @@ -0,0 +1,213 @@ +// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx + +import { For, Show, type JSX } from "solid-js"; +import { ChevronRight, Folder, LayoutGrid } from "../../../lib/icons"; +import type { ProjectItem } from "../../app-shell/data/shell.data"; +import { + getProjectTreeNodeId, + type PendingProjectFolderDraft, + type PendingProjectFolderRename, + type ProjectDragState, + type ProjectFolderBranchProps, + type ProjectFolderNode, + type ProjectTreeNode, +} from "./ProjectSelector.data"; +import styles from "./ProjectSelector.module.scss"; + +export const ProjectFolderDraftRow = (props: { + depth: number; + value: string; + onInput: (value: string) => void; + onSubmit: () => void; + onCancel: () => void; +}): JSX.Element => { + let inputRef: HTMLInputElement | undefined; + + queueMicrotask(() => inputRef?.focus()); + + return ( +
  • +
    + + props.onInput(event.currentTarget.value)} + onBlur={props.onSubmit} + onKeyDown={(event): void => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + props.onCancel(); + event.currentTarget.blur(); + } + }} + /> +
    +
  • + ); +}; + +export const ProjectFolderBranch = (props: ProjectFolderBranchProps): JSX.Element => ( +
      + +
    • +
      +
    • +
      + + + {(node, indexAccessor): JSX.Element => { + const nodeId = (): string => getProjectTreeNodeId(node); + const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId(); + const dropIntent = (): "before" | "after" | "inside" | null => { + if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) { + return null; + } + + return props.dragState.dropTarget.intent; + }; + + if (node.kind === "folder") { + const isCollapsed = (): boolean => props.isFolderCollapsed(node.id); + const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id; + + return ( +
    • + { + if (props.dragState || props.isTreeClickSuppressed()) { + return; + } + + props.onToggleFolder(node.id); + }} + onContextMenu={(event): void => props.onOpenFolderMenu(event, node)} + onPointerDown={(event): void => props.onNodePointerDown(event, node.id)} + onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)} + onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)} + > + + + {node.label} + + {node.meta} + + + } + > + + + + 0 || props.pendingFolderDraft?.parentId === node.id)}> + + +
    • + ); + } + + return ( +
    • + +
    • + ); + }} +
      + + + + +
    +); diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx new file mode 100644 index 0000000..679b490 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx @@ -0,0 +1,222 @@ +// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.tsx + +import { Show, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons"; +import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu"; +import { useProjectSelector } from "./ProjectSelector.hook"; +import { ProjectFolderBranch } from "./ProjectSelector.parts"; +import { type ProjectSelectorProps } from "./ProjectSelector.data"; +import styles from "./ProjectSelector.module.scss"; + +export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { + const { + selectedProject, + drawerTop, + setRootRef, + setTriggerRef, + setContextMenuElement, + toggleOpen, + handleSurfaceContextMenu, + openRootCreateMenu, + treeControlLabel, + toggleAllFolders, + totalFolderCount, + areAllFoldersCollapsed, + projectTreeNodes, + isFolderCollapsed, + toggleFolder, + selectProject, + openFolderMenu, + openProjectMenu, + handleNodePointerDown, + handleNodePointerMove, + pendingFolderDraft, + pendingFolderName, + setPendingFolderName, + submitPendingFolder, + cancelPendingFolder, + pendingFolderRename, + pendingFolderRenameName, + setPendingFolderRenameName, + submitPendingFolderRename, + cancelPendingFolderRename, + dragState, + suppressNextTreeClick, + contextMenu, + contextMenuPosition, + handleContextActionSelect, + draggedNode, + dragGhostPosition, + draggedNodeMeta, + } = useProjectSelector(props); + + return ( +
    + + + + <> + + +
    + + + + + + + + + + + + {(node): JSX.Element => { + const GhostIcon = node.kind === "folder" ? Folder : LayoutGrid; + + return ( + + + + ); + }} + + + ); +}; diff --git a/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts new file mode 100644 index 0000000..213d9be --- /dev/null +++ b/Frontend/src/components/workspace-navigation/ProjectSelector/projectFolders.api.ts @@ -0,0 +1,109 @@ +import { resolveAPIBase } from "../../../lib/api"; +import type { ProjectFoldersResponse } from "./ProjectSelector.data"; + +const readProjectFoldersResponse = async (response: Response): Promise => + (await response.json()) as ProjectFoldersResponse; + +export const isValidProjectFolderProjectId = (projectId: string): boolean => + Boolean(projectId); + +export const fetchProjectFolders = async (projectId: string): Promise => { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { + headers: { Accept: "application/json" }, + }); + const body = await readProjectFoldersResponse(response); + + if (!response.ok) { + throw new Error(body.message || "Failed to load project folders."); + } + + return body; +}; + +export const createProjectFolderRequest = async ( + projectId: string, + name: string, + parentFolderId: string | null, +): Promise => { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, parentFolderId }), + }); + const body = await readProjectFoldersResponse(response); + + if (!response.ok) { + throw new Error(body.message || "Failed to create project folder."); + } + + return body; +}; + +export const deleteProjectFolderRequest = async ( + projectId: string, + folderId: string, +): Promise => { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + const body = await readProjectFoldersResponse(response); + + if (!response.ok) { + throw new Error(body.message || "Failed to delete project folder."); + } + + return body; +}; + +export const moveProjectFolderRequest = async ( + projectId: string, + payload: { + folderId: string; + folderNodeId: string; + parentFolderId: string | null; + parentNodeId: string | null; + targetIndex: number; + }, +): Promise => { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders/move`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const body = await readProjectFoldersResponse(response); + + if (!response.ok) { + throw new Error(body.message || "Failed to move project folder."); + } + + return body; +}; + +export const renameProjectFolderRequest = async ( + projectId: string, + folderId: string, + name: string, +): Promise => { + const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ folderId, name }), + }); + const body = await readProjectFoldersResponse(response); + + if (!response.ok) { + throw new Error(body.message || "Failed to rename project folder."); + } + + return body; +}; diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss similarity index 100% rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.module.scss rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.module.scss diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx similarity index 99% rename from Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx index 8b97f0f..79e353f 100644 --- a/Frontend/src/components/shell/WorkspaceContextMenu/WorkspaceContextMenu.tsx +++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/WorkspaceContextMenu.tsx @@ -7,7 +7,7 @@ import { type WorkspaceContextMenuAction, type WorkspaceContextMenuShortcut, type WorkspaceContextMenuTarget, -} from "../data/shell.data"; +} from "../../app-shell/data/shell.data"; import styles from "./WorkspaceContextMenu.module.scss"; type ShortcutPlatform = "mac" | "windows"; diff --git a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts similarity index 97% rename from Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts rename to Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts index e5f21a8..5143de7 100644 --- a/Frontend/src/components/shell/WorkspaceContextMenu/createWorkspaceContextMenuController.ts +++ b/Frontend/src/components/workspace-navigation/WorkspaceContextMenu/createWorkspaceContextMenuController.ts @@ -1,5 +1,5 @@ import { createEffect, createSignal, onCleanup } from "solid-js"; -import type { WorkspaceContextMenuTarget } from "../data/shell.data"; +import type { WorkspaceContextMenuTarget } from "../../app-shell/data/shell.data"; type WorkspaceContextMenuState = { target: WorkspaceContextMenuTarget; diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss similarity index 100% rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.module.scss diff --git a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx similarity index 99% rename from Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx rename to Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx index c4f9a01..754094e 100644 --- a/Frontend/src/components/shell/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx +++ b/Frontend/src/components/workspace-navigation/WorkspaceMobileActionSheet/WorkspaceMobileActionSheet.tsx @@ -7,7 +7,7 @@ import { type WorkspaceContextMenuAction, type WorkspaceContextMenuSection, type WorkspaceContextMenuTarget, -} from "../data/shell.data"; +} from "../../app-shell/data/shell.data"; import styles from "./WorkspaceMobileActionSheet.module.scss"; type WorkspaceMobileActionSheetProps = { diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts new file mode 100644 index 0000000..c7e3604 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts @@ -0,0 +1,57 @@ +// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.data.ts + +import type { NavTreeAdapter, NavTreeDropTarget } from "../shared/navTreeDnd"; +import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data"; + +export type WorkspaceSidebarProps = { + collapsed: boolean; + railCollapsed: boolean; + onToggleRailCollapse: () => void; +}; + +export type PendingWorkspaceFolderDraft = { + parentId: string | null; + depth: number; +}; + +export type WorkspaceDragTarget = NavTreeDropTarget; + +export type WorkspaceDragState = { + draggedNodeId: string; + dropTarget: WorkspaceDragTarget | null; +}; + +export type DragGhostPosition = { + x: number; + y: number; +}; + +export type PendingWorkspaceFolderRename = { + folderId: string; + depth: number; +}; + +export const LONG_PRESS_MS = 320; + +export const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id; + +export const countWorkspaceFolderSiblingsBeforeIndex = ( + siblings: readonly WorkspaceTreeNode[], + index: number, +): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; + +export const workspaceTreeAdapter: NavTreeAdapter = { + getNodeId: getWorkspaceTreeNodeId, + isBranchNode: (node) => node.kind === "folder", + getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []), + withChildren: (node, children) => + node.kind === "folder" + ? { + ...node, + children: [...children], + } + : node, +}; + +export const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => + event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"); diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts new file mode 100644 index 0000000..1c0cf7f --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts @@ -0,0 +1,534 @@ +// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.hook.ts + +import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { + createWorkspaceSurfaceTarget, + getWorkspaceItemTypeDefinition, + type WorkspaceContextMenuAction, + type WorkspaceContextMenuTarget, + type WorkspaceItemTypeId, + type WorkspaceTreeNode, +} from "../../app-shell/data/shell.data"; +import { + collectBranchNodeIds, + findTreeNodeDepth, + findTreeNodeLocation, + getPointerRelativeY, + isUuidString, + moveTreeNode, + resolveTreeDropTarget, +} from "../shared/navTreeDnd"; +import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData"; +import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController"; +import { + countWorkspaceFolderSiblingsBeforeIndex, + getWorkspaceTreeNodeId, + LONG_PRESS_MS, + workspaceTreeAdapter, + type DragGhostPosition, + type PendingWorkspaceFolderDraft, + type PendingWorkspaceFolderRename, + type WorkspaceDragTarget, + type WorkspaceDragState, +} from "./WorkspaceSidebar.data"; + +export const useWorkspaceSidebar = () => { + const appShellData = useAppShellData(); + const activeProject = () => appShellData.activeProject(); + const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false); + const [collapsedFolderIds, setCollapsedFolderIds] = createSignal([]); + const [pendingFolderDraft, setPendingFolderDraft] = createSignal(null); + const [pendingFolderName, setPendingFolderName] = createSignal(""); + const [pendingFolderRename, setPendingFolderRename] = createSignal(null); + const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal(""); + const [dragState, setDragState] = createSignal(null); + const [dragGhostPosition, setDragGhostPosition] = createSignal({ x: 0, y: 0 }); + const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false); + let lastSelectedProjectId: string | null = null; + const { + workspaceTreeNodes, + setWorkspaceTreeNodes, + resolveFolderPath, + resolveItemPath, + createFolder, + renameFolder, + deleteFolder, + moveFolder, + createItem, + deleteItem, + moveItem, + } = useWorkspaceTreeData({ + activeProjectId: () => activeProject()?.id ?? "", + fallbackWorkspaceTree: () => appShellData.workspaceTree(), + }); + const contextMenu = createWorkspaceContextMenuController(); + let longPressTimer: number | undefined; + let suppressClickTimer: number | undefined; + const sidebarContextMenuTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject()); + const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId); + const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter); + const totalFolderCount = (): number => folderIds().length; + const areAllFoldersCollapsed = (): boolean => { + const count = totalFolderCount(); + return count > 0 && collapsedFolderIds().length >= count; + }; + const workspaceFolderToggleLabel = (): string => + areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders"; + const toggleFolder = (folderId: string): void => { + setCollapsedFolderIds((current) => + current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId], + ); + }; + const expandAllFolders = (): void => { + setCollapsedFolderIds([]); + }; + const collapseAllFolders = (): void => { + setCollapsedFolderIds(folderIds()); + }; + const toggleAllFolders = (): void => { + if (areAllFoldersCollapsed()) { + expandAllFolders(); + return; + } + + collapseAllFolders(); + }; + const resetWorkspaceTreeInteractionState = (): void => { + setCollapsedFolderIds([]); + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename(null); + setPendingFolderRenameName(""); + setDragState(null); + }; + const syncCollapsedFolderIds = (): void => { + const availableFolderIds = new Set(collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter)); + setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id))); + }; + const updateDragGhostPosition = (x: number, y: number): void => { + setDragGhostPosition({ x: x + 18, y: y + 18 }); + }; + const draggedNode = (): WorkspaceTreeNode | null => { + const currentDragState = dragState(); + if (!currentDragState) { + return null; + } + + return findTreeNodeLocation(workspaceTreeNodes(), currentDragState.draggedNodeId, workspaceTreeAdapter)?.node ?? null; + }; + const draggedNodeMeta = (): string => { + const node = draggedNode(); + if (!node) { + return ""; + } + + if (node.kind === "folder") { + return "Folder"; + } + + return getWorkspaceItemTypeDefinition(node.itemType).label; + }; + const clearLongPressTimer = (): void => { + if (longPressTimer !== undefined) { + window.clearTimeout(longPressTimer); + longPressTimer = undefined; + } + }; + const suppressTreeClickTemporarily = (): void => { + setSuppressNextTreeClick(true); + + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); + } + + suppressClickTimer = window.setTimeout(() => { + setSuppressNextTreeClick(false); + suppressClickTimer = undefined; + }, 80); + }; + + createEffect(() => { + syncCollapsedFolderIds(); + }); + + createEffect(() => { + const projectId = activeProject()?.id ?? null; + + if (lastSelectedProjectId === null) { + lastSelectedProjectId = projectId; + return; + } + + if (projectId === lastSelectedProjectId) { + return; + } + + lastSelectedProjectId = projectId; + resetWorkspaceTreeInteractionState(); + }); + + onMount(() => { + const handlePointerMove = (event: PointerEvent): void => { + if (!dragState()) { + return; + } + + updateDragGhostPosition(event.clientX, event.clientY); + }; + + const handlePointerUp = (): void => { + clearLongPressTimer(); + + const nextDragState = dragState(); + + if (!nextDragState?.dropTarget) { + if (nextDragState) { + suppressTreeClickTemporarily(); + } + setDragState(null); + return; + } + + suppressTreeClickTemporarily(); + + const currentNodes = workspaceTreeNodes(); + const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); + const canPersistMove = isUuidString(activeProject()?.id ?? ""); + const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter); + const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); + const persistedParentLocation = previewLocation?.parentId + ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter) + : null; + const previewSiblings = previewLocation?.parentId + ? persistedParentLocation?.node.kind === "folder" + ? persistedParentLocation.node.children ?? [] + : [] + : previewNodes; + const targetIndex = previewLocation + ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) + : 0; + + if ( + canPersistMove && + draggedLocation?.node.kind === "folder" && + (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") + ) { + void moveFolder( + draggedLocation.node.id, + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, + targetIndex, + ); + } else if ( + canPersistMove && + draggedLocation?.node.kind === "item" && + resolveItemPath(draggedLocation.node.id) && + (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") + ) { + void moveItem( + draggedLocation.node.id, + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, + previewLocation?.index ?? 0, + ); + } else { + setWorkspaceTreeNodes((current) => + moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter), + ); + } + + setDragState(null); + }; + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape") { + return; + } + + clearLongPressTimer(); + + if (dragState()) { + setDragState(null); + } + }; + + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("keydown", handleEscape); + + onCleanup(() => { + clearLongPressTimer(); + if (suppressClickTimer !== undefined) { + window.clearTimeout(suppressClickTimer); + } + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("keydown", handleEscape); + }); + }); + + const beginFolderDraft = (parentId: string | null, depth: number): void => { + if (parentId) { + setCollapsedFolderIds((current) => current.filter((id) => id !== parentId)); + } + + setPendingFolderRename(null); + setPendingFolderRenameName(""); + setPendingFolderName(""); + setPendingFolderDraft({ parentId, depth }); + }; + + const beginFolderRename = (folderId: string, label: string, depth: number): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + setPendingFolderRename({ folderId, depth }); + setPendingFolderRenameName(label); + }; + + const submitPendingFolder = async (): Promise => { + const name = pendingFolderName().trim(); + const draft = pendingFolderDraft(); + const projectId = activeProject()?.id ?? ""; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderDraft(null); + setPendingFolderName(""); + return; + } + + if (!projectId || !isUuidString(projectId)) { + cancelPendingFolder(); + return; + } + + const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; + if (draft.parentId && !parentFolderPath) { + cancelPendingFolder(); + return; + } + + const created = await createFolder(name, draft.parentId); + if (created) { + setPendingFolderDraft(null); + setPendingFolderName(""); + } + }; + + const deletePersistedFolder = async (folderId: string): Promise => { + const deleted = await deleteFolder(folderId); + if (deleted) { + setCollapsedFolderIds((current) => current.filter((id) => id !== folderId)); + } + }; + + const submitPendingFolderRename = async (): Promise => { + const draft = pendingFolderRename(); + const name = pendingFolderRenameName().trim(); + const projectId = activeProject()?.id ?? ""; + + if (!draft) { + return; + } + + if (!name) { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + return; + } + + if (!projectId || !isUuidString(projectId)) { + cancelPendingFolderRename(); + return; + } + + const folderPath = resolveFolderPath(draft.folderId); + if (!folderPath) { + cancelPendingFolderRename(); + return; + } + + const renamed = await renameFolder(draft.folderId, name); + if (renamed) { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + } + }; + + const cancelPendingFolder = (): void => { + setPendingFolderDraft(null); + setPendingFolderName(""); + }; + + const cancelPendingFolderRename = (): void => { + setPendingFolderRename(null); + setPendingFolderRenameName(""); + }; + + const handleHeaderActionClick = (actionId: string): void => { + switch (actionId) { + case "toggle-workspace-folders": + toggleAllFolders(); + return; + case "search-workspace": + default: + return; + } + }; + + const openWorkspaceCreateMenu = (element: HTMLElement): void => { + contextMenu.openMenuFromElement(element, sidebarContextMenuTarget()); + }; + + const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => { + switch (actionId) { + case "create-doc": + return "core.doc"; + case "create-board": + return "core.board.kanban"; + case "create-list-board": + return "core.board.list"; + default: + return null; + } + }; + + const createPersistedItem = async (itemType: WorkspaceItemTypeId, parentId: string | null): Promise => { + const definition = getWorkspaceItemTypeDefinition(itemType); + await createItem(definition.defaultCreateLabel, itemType, parentId); + }; + + const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => { + if (event.button !== 0 || pendingFolderDraft()) { + return; + } + + updateDragGhostPosition(event.clientX, event.clientY); + clearLongPressTimer(); + longPressTimer = window.setTimeout(() => { + suppressTreeClickTemporarily(); + setDragState({ draggedNodeId: nodeId, dropTarget: null }); + }, LONG_PRESS_MS); + }; + + const handleNodePointerMove = ( + event: PointerEvent, + parentId: string | null, + index: number, + node: WorkspaceTreeNode, + ): void => { + const nextDragState = dragState(); + + if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) { + return; + } + + const relativeY = getPointerRelativeY(event); + if (relativeY === null) { + return; + } + + setDragState({ + ...nextDragState, + dropTarget: resolveTreeDropTarget({ + parentId, + index, + node, + relativeY, + adapter: workspaceTreeAdapter, + }), + }); + }; + + const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => { + const createItemType = resolveCreateItemType(action.id); + if (createItemType) { + switch (target.kind) { + case "workspace": + case "home": + void createPersistedItem(createItemType, null); + return; + case "folder": + void createPersistedItem(createItemType, target.id); + return; + case "settings": + case "item": + return; + } + } + + switch (action.id) { + case "new-folder": + switch (target.kind) { + case "workspace": + case "home": + beginFolderDraft(null, 0); + return; + case "folder": + beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1); + return; + case "settings": + case "item": + return; + } + return; + case "delete-folder": + if (target.kind === "folder") { + void deletePersistedFolder(target.id); + } + return; + case "rename-folder": + if (target.kind === "folder") { + beginFolderRename(target.id, target.label, findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0); + } + return; + case "delete-doc": + case "delete-board": + case "delete-list-board": + if (target.kind === "item") { + void deleteItem(target.id); + } + return; + default: + return; + } + }; + + return { + activeProject, + contextMenu, + sidebarContextMenuTarget, + isProjectDrawerOpen, + setIsProjectDrawerOpen, + workspaceTreeNodes, + isFolderCollapsed, + pendingFolderDraft, + pendingFolderName, + setPendingFolderName, + submitPendingFolder, + cancelPendingFolder, + pendingFolderRename, + pendingFolderRenameName, + setPendingFolderRenameName, + submitPendingFolderRename, + cancelPendingFolderRename, + handleNodePointerDown, + handleNodePointerMove, + dragState, + suppressNextTreeClick, + draggedNode, + dragGhostPosition, + draggedNodeMeta, + workspaceFolderToggleLabel, + areAllFoldersCollapsed, + totalFolderCount, + handleHeaderActionClick, + openWorkspaceCreateMenu, + handleContextActionSelect, + toggleFolder, + }; +}; diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss similarity index 100% rename from Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.module.scss rename to Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.module.scss diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx new file mode 100644 index 0000000..461763e --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx @@ -0,0 +1,287 @@ +// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.parts.tsx + +import { For, Show, type JSX } from "solid-js"; +import { ChevronRight, Folder } from "../../../lib/icons"; +import { + createWorkspaceStaticTarget, + createWorkspaceTreeTarget, + getWorkspaceNodeIcon, + type WorkspaceContextMenuTarget, + type WorkspaceStaticItem, + type WorkspaceTreeNode, +} from "../../app-shell/data/shell.data"; +import { + isContextMenuKeyboardTrigger, + type PendingWorkspaceFolderDraft, + type PendingWorkspaceFolderRename, + type WorkspaceDragState, + type WorkspaceDragTarget, +} from "./WorkspaceSidebar.data"; +import styles from "./WorkspaceSidebar.module.scss"; + +export const FolderDraftRow = (props: { + depth: number; + value: string; + onInput: (value: string) => void; + onSubmit: () => void; + onCancel: () => void; +}): JSX.Element => { + let inputRef: HTMLInputElement | undefined; + + queueMicrotask(() => inputRef?.focus()); + + return ( +
  • +
    + + props.onInput(event.currentTarget.value)} + onBlur={props.onSubmit} + onKeyDown={(event): void => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + props.onCancel(); + event.currentTarget.blur(); + } + }} + /> +
    +
  • + ); +}; + +export const WorkspaceHomeEntry = (props: { + item: WorkspaceStaticItem; + onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; + onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; +}): JSX.Element => { + const Icon = props.item.icon; + const target = createWorkspaceStaticTarget(props.item); + + return ( +
  • + +
  • + ); +}; + +export const WorkspaceTreeBranch = (props: { + nodes: readonly WorkspaceTreeNode[]; + parentId?: string | null; + depth?: number; + isFolderCollapsed: (folderId: string) => boolean; + onToggleFolder: (folderId: string) => void; + pendingFolderDraft: PendingWorkspaceFolderDraft | null; + pendingFolderName: string; + onPendingFolderNameChange: (value: string) => void; + onSubmitPendingFolder: () => void; + onCancelPendingFolder: () => void; + pendingFolderRename: PendingWorkspaceFolderRename | null; + pendingFolderRenameName: string; + onPendingFolderRenameChange: (value: string) => void; + onSubmitPendingFolderRename: () => void; + onCancelPendingFolderRename: () => void; + onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void; + onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void; + onNodePointerDown: (event: PointerEvent, nodeId: string) => void; + onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void; + dragState: WorkspaceDragState | null; + isTreeClickSuppressed: () => boolean; +}): JSX.Element => { + const depth = () => props.depth ?? 0; + const parentId = () => props.parentId ?? null; + + return ( +
      + +
    • +
      +
    • +
      + + {(node, indexAccessor): JSX.Element => { + const Icon = getWorkspaceNodeIcon(node); + const target = createWorkspaceTreeTarget(node); + const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false); + const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id; + const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id; + const dropIntent = (): WorkspaceDragTarget["intent"] | null => { + if (props.dragState?.dropTarget?.targetNodeId !== node.id) { + return null; + } + + return props.dragState.dropTarget.intent; + }; + + return ( +
    • + { + if (props.dragState || props.isTreeClickSuppressed()) { + return; + } + + if (node.kind !== "folder") { + return; + } + + props.onToggleFolder(node.id); + }} + onContextMenu={(event): void => { + event.stopPropagation(); + props.onOpenContextMenu(event, target); + }} + onPointerDown={(event): void => props.onNodePointerDown(event, node.id)} + onPointerMove={(event): void => + props.onNodePointerMove(event, parentId(), indexAccessor(), node) + } + onPointerEnter={(event): void => + props.onNodePointerMove(event, parentId(), indexAccessor(), node) + } + onKeyDown={(event): void => { + if (!isContextMenuKeyboardTrigger(event)) { + return; + } + + event.preventDefault(); + props.onOpenContextMenuFromKeyboard(event.currentTarget, target); + }} + > + + + + + {node.label} + + {node.meta} + + + } + > + + + + 0) || props.pendingFolderDraft?.parentId === node.id) + } + > + + +
    • + ); + }} +
      + + + + +
    + ); +}; diff --git a/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx new file mode 100644 index 0000000..591eae5 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,250 @@ +// Path: Frontend/src/components/workspace-navigation/WorkspaceSidebar/WorkspaceSidebar.tsx + +import { For, Show, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { ChevronLeft, ChevronRight, ListCollapse, Plus, UnfoldVertical } from "../../../lib/icons"; +import { ProjectSelector } from "../ProjectSelector/ProjectSelector"; +import { + getWorkspaceNodeIcon, + workspaceSidebarHeaderActions, + workspaceStaticItems, +} from "../../app-shell/data/shell.data"; +import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu"; +import type { WorkspaceSidebarProps } from "./WorkspaceSidebar.data"; +import { useWorkspaceSidebar } from "./WorkspaceSidebar.hook"; +import { WorkspaceHomeEntry, WorkspaceTreeBranch } from "./WorkspaceSidebar.parts"; +import styles from "./WorkspaceSidebar.module.scss"; + +export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { + const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail"); + const { + contextMenu, + sidebarContextMenuTarget, + isProjectDrawerOpen, + setIsProjectDrawerOpen, + workspaceTreeNodes, + isFolderCollapsed, + pendingFolderDraft, + pendingFolderName, + setPendingFolderName, + submitPendingFolder, + cancelPendingFolder, + pendingFolderRename, + pendingFolderRenameName, + setPendingFolderRenameName, + submitPendingFolderRename, + cancelPendingFolderRename, + handleNodePointerDown, + handleNodePointerMove, + dragState, + suppressNextTreeClick, + draggedNode, + dragGhostPosition, + draggedNodeMeta, + workspaceFolderToggleLabel, + areAllFoldersCollapsed, + totalFolderCount, + handleHeaderActionClick, + openWorkspaceCreateMenu, + handleContextActionSelect, + toggleFolder, + } = useWorkspaceSidebar(); + + return ( + <> + + + { + const state = contextMenu.menuState(); + return state + ? { + x: state.x, + y: state.y, + } + : null; + })()} + menuRef={contextMenu.setMenuRef} + onClose={contextMenu.closeMenu} + onSelect={handleContextActionSelect} + /> + + + {(node): JSX.Element => { + const GhostIcon = getWorkspaceNodeIcon(node); + + return ( + + + + ); + }} + + + ); +}; diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss new file mode 100644 index 0000000..73b7a0a --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.module.scss @@ -0,0 +1,81 @@ +.workspaceTopBar { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: calc(var(--control-size-md) - var(--space-3)); + padding: var(--space-5) var(--space-6) 0; +} + +.workspaceTopBarStart, +.workspaceTopBarEnd { + min-width: calc(var(--control-size-md) - 0.5rem); + display: inline-flex; + align-items: center; +} + +.workspaceTopBarEnd { + justify-content: flex-end; +} + +.workspaceTopBarCenter { + min-width: 0; + display: flex; + justify-content: center; +} + +.workspaceBreadcrumb { + @include text-caption; + min-width: 0; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspaceCollapseButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(var(--control-size-md) - 0.5rem); + height: calc(var(--control-size-md) - 0.5rem); + border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-surface) 94%, transparent); + color: var(--color-text-muted); + box-shadow: var(--shadow-soft); + transition: + background 160ms var(--easing-standard), + color 160ms var(--easing-standard), + border-color 160ms var(--easing-standard), + transform 180ms var(--easing-standard); +} + +.workspaceCollapseButton:hover, +.workspaceCollapseButton:focus-visible { + background: var(--color-surface-hover); + border-color: var(--color-border); + color: var(--color-text); +} + +.workspaceCollapseButton:hover { + transform: translateY(-1px); +} + +@include respond-down(mobile) { + .workspaceTopBar { + grid-template-columns: minmax(0, 1fr); + padding: var(--space-4) var(--space-4) 0; + } + + .workspaceTopBarStart, + .workspaceTopBarEnd, + .workspaceCollapseButton { + display: none; + } + + .workspaceTopBarCenter { + justify-content: flex-start; + } +} diff --git a/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx new file mode 100644 index 0000000..a4589e1 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/WorkspaceTopBar/WorkspaceTopBar.tsx @@ -0,0 +1,29 @@ +import { type JSX } from "solid-js"; +import { ChevronLeft, ChevronRight } from "../../../lib/icons"; +import styles from "./WorkspaceTopBar.module.scss"; + +type WorkspaceTopBarProps = { + sidebarCollapsed: boolean; + breadcrumb: string; + onToggleSidebarCollapse: () => void; +}; + +export const WorkspaceTopBar = (props: WorkspaceTopBarProps): JSX.Element => { + const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"); + + return ( +
    +
    + +
    + +
    + {props.breadcrumb} +
    + + + ); +}; diff --git a/Frontend/src/components/shell/shared/_tree-nav.scss b/Frontend/src/components/workspace-navigation/shared/_tree-nav.scss similarity index 100% rename from Frontend/src/components/shell/shared/_tree-nav.scss rename to Frontend/src/components/workspace-navigation/shared/_tree-nav.scss diff --git a/Frontend/src/components/shell/shared/navTreeDnd.ts b/Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts similarity index 100% rename from Frontend/src/components/shell/shared/navTreeDnd.ts rename to Frontend/src/components/workspace-navigation/shared/navTreeDnd.ts diff --git a/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts new file mode 100644 index 0000000..314d655 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/shared/useWorkspaceTreeData.ts @@ -0,0 +1,263 @@ +import { createEffect, createSignal, type Accessor, type Setter } from "solid-js"; +import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data"; +import { + buildPersistedWorkspaceTreeNodes, + findWorkspaceTreeNodeById, + readPersistedWorkspaceTreeNodes, +} from "./workspaceTree.adapters"; +import { + createWorkspaceFolderRequest, + createWorkspaceItemRequest, + deleteWorkspaceFolderRequest, + deleteWorkspaceItemRequest, + fetchWorkspaceTree, + isValidWorkspaceProjectId, + moveWorkspaceFolderRequest, + moveWorkspaceItemRequest, + readWorkspaceMutationResponse, + renameWorkspaceFolderRequest, +} from "./workspaceTree.api"; +import type { + PersistedWorkspaceTreeNodeRecord, + UseWorkspaceTreeDataOptions, + UseWorkspaceTreeDataResult, +} from "./workspaceTree.types"; + +export const useWorkspaceTreeData = ( + options: UseWorkspaceTreeDataOptions, +): UseWorkspaceTreeDataResult => { + const [persistedNodes, setPersistedNodes] = createSignal([]); + const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal( + options.fallbackWorkspaceTree(), + ); + let latestPersistedTreeRequest = 0; + + const syncWorkspaceTree = (): void => { + const nextTree = options.activeProjectId() + ? buildPersistedWorkspaceTreeNodes(persistedNodes()) + : options.fallbackWorkspaceTree(); + + setWorkspaceTreeNodes(nextTree); + }; + + const loadPersistedTree = async (projectId: string): Promise => { + const requestId = latestPersistedTreeRequest + 1; + latestPersistedTreeRequest = requestId; + + if (!isValidWorkspaceProjectId(projectId)) { + setPersistedNodes([]); + return false; + } + + try { + const body = await fetchWorkspaceTree(projectId); + + if (requestId !== latestPersistedTreeRequest) { + return false; + } + + if (body.error) { + throw new Error(body.message || "Failed to load project tree."); + } + + setPersistedNodes(readPersistedWorkspaceTreeNodes(body)); + return true; + } catch (error) { + if (requestId !== latestPersistedTreeRequest) { + return false; + } + + console.error(error); + setPersistedNodes([]); + return false; + } + }; + + const resolveFolderPath = (folderId: string): string | null => { + const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), folderId); + return node?.kind === "folder" ? node.path ?? null : null; + }; + + const resolveItemPath = (itemId: string): string | null => { + const node = findWorkspaceTreeNodeById(workspaceTreeNodes(), itemId); + return node?.kind === "item" ? node.path ?? null : null; + }; + + const refreshAfterMutation = async (projectId: string, response: Response): Promise => { + const body = await readWorkspaceMutationResponse(response); + if (!response.ok) { + throw new Error(body.message || "Failed to persist project tree mutation."); + } + + return loadPersistedTree(projectId); + }; + + const createFolder = async (name: string, parentId: string | null): Promise => { + const projectId = options.activeProjectId(); + if (!isValidWorkspaceProjectId(projectId)) { + return false; + } + + const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; + if (parentId && !parentFolderPath) { + return false; + } + + try { + const response = await createWorkspaceFolderRequest(projectId, name, parentFolderPath); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const renameFolder = async (folderId: string, name: string): Promise => { + const projectId = options.activeProjectId(); + const folderPath = resolveFolderPath(folderId); + if (!isValidWorkspaceProjectId(projectId) || !folderPath) { + return false; + } + + try { + const response = await renameWorkspaceFolderRequest(projectId, folderPath, name); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const deleteFolder = async (folderId: string): Promise => { + const projectId = options.activeProjectId(); + const folderPath = resolveFolderPath(folderId); + if (!isValidWorkspaceProjectId(projectId) || !folderPath) { + return false; + } + + try { + const response = await deleteWorkspaceFolderRequest(projectId, folderPath); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise => { + const projectId = options.activeProjectId(); + const folderPath = resolveFolderPath(folderId); + const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; + if (!isValidWorkspaceProjectId(projectId) || !folderPath) { + return false; + } + + try { + const response = await moveWorkspaceFolderRequest( + projectId, + folderPath, + folderId, + parentFolderPath, + parentId, + targetIndex, + ); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const createItem = async ( + name: string, + itemType: WorkspaceItemTypeId, + parentId: string | null, + ): Promise => { + const projectId = options.activeProjectId(); + if (!isValidWorkspaceProjectId(projectId)) { + return false; + } + + const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; + if (parentId && !parentFolderPath) { + return false; + } + + try { + const response = await createWorkspaceItemRequest(projectId, name, itemType, parentFolderPath); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const deleteItem = async (itemId: string): Promise => { + const projectId = options.activeProjectId(); + const itemPath = resolveItemPath(itemId); + if (!isValidWorkspaceProjectId(projectId) || !itemPath) { + return false; + } + + try { + const response = await deleteWorkspaceItemRequest(projectId, itemPath); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise => { + const projectId = options.activeProjectId(); + const itemPath = resolveItemPath(itemId); + const parentFolderPath = parentId ? resolveFolderPath(parentId) : null; + if (!isValidWorkspaceProjectId(projectId) || !itemPath) { + return false; + } + + try { + const response = await moveWorkspaceItemRequest( + projectId, + itemPath, + itemId, + parentFolderPath, + parentId, + targetIndex, + ); + + return refreshAfterMutation(projectId, response); + } catch (error) { + console.error(error); + return false; + } + }; + + createEffect(() => { + syncWorkspaceTree(); + }); + + createEffect(() => { + void loadPersistedTree(options.activeProjectId()); + }); + + return { + workspaceTreeNodes, + setWorkspaceTreeNodes, + resolveFolderPath, + resolveItemPath, + createFolder, + renameFolder, + deleteFolder, + moveFolder, + createItem, + deleteItem, + moveItem, + }; +}; diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts new file mode 100644 index 0000000..0600194 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.adapters.ts @@ -0,0 +1,50 @@ +import { Folder } from "../../../lib/icons"; +import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data"; +import type { PersistedWorkspaceTreeNodeRecord, WorkspaceTreeResponse } from "./workspaceTree.types"; + +export const buildPersistedWorkspaceTreeNodes = ( + nodes: readonly PersistedWorkspaceTreeNodeRecord[], +): WorkspaceTreeNode[] => + nodes.map((node) => + node.kind === "folder" + ? { + id: node.id, + path: node.path, + label: node.label, + kind: "folder", + icon: Folder, + children: buildPersistedWorkspaceTreeNodes(node.children ?? []), + } + : { + id: node.id, + path: node.path, + label: node.label, + kind: "item", + itemType: node.itemType ?? "core.board.kanban", + } + ); + +export const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] => + Array.isArray(body.data?.nodes) ? body.data.nodes : []; + +export const findWorkspaceTreeNodeById = ( + nodes: readonly WorkspaceTreeNode[], + nodeId: string, +): WorkspaceTreeNode | null => { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + + if (node.kind !== "folder") { + continue; + } + + const nestedMatch = findWorkspaceTreeNodeById(node.children ?? [], nodeId); + if (nestedMatch) { + return nestedMatch; + } + } + + return null; +}; diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts new file mode 100644 index 0000000..6448602 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.api.ts @@ -0,0 +1,129 @@ +import { resolveAPIBase } from "../../../lib/api"; +import { isUuidString } from "./navTreeDnd"; +import type { WorkspaceItemTypeId } from "../../app-shell/data/shell.data"; +import type { WorkspaceMutationResponse, WorkspaceTreeResponse } from "./workspaceTree.types"; + +export const isValidWorkspaceProjectId = (projectId: string): boolean => Boolean(projectId) && isUuidString(projectId); + +const requestWorkspaceTree = async (path: string, init?: RequestInit): Promise => + fetch(`${resolveAPIBase()}${path}`, init); + +export const fetchWorkspaceTree = async (projectId: string): Promise => { + const response = await requestWorkspaceTree(`/projects/${projectId}/tree`, { + headers: { + Accept: "application/json", + }, + }); + + return (await response.json()) as WorkspaceTreeResponse; +}; + +export const readWorkspaceMutationResponse = async (response: Response): Promise => + (await response.json()) as WorkspaceMutationResponse; + +export const createWorkspaceFolderRequest = (projectId: string, name: string, parentFolderId: string | null): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/folders`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + parentFolderId, + }), + }); + +export const renameWorkspaceFolderRequest = (projectId: string, folderId: string, name: string): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/folders`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + folderId, + name, + }), + }); + +export const deleteWorkspaceFolderRequest = (projectId: string, folderId: string): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, { + method: "DELETE", + headers: { + Accept: "application/json", + }, + }); + +export const moveWorkspaceFolderRequest = ( + projectId: string, + folderId: string, + folderNodeId: string, + parentFolderId: string | null, + parentNodeId: string | null, + targetIndex: number, +): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/folders/move`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + folderId, + folderNodeId, + parentFolderId, + parentNodeId, + targetIndex, + }), + }); + +export const createWorkspaceItemRequest = ( + projectId: string, + name: string, + itemType: WorkspaceItemTypeId, + parentFolderId: string | null, +): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/items`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + itemType, + parentFolderId, + }), + }); + +export const deleteWorkspaceItemRequest = (projectId: string, itemId: string): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemId)}`, { + method: "DELETE", + headers: { + Accept: "application/json", + }, + }); + +export const moveWorkspaceItemRequest = ( + projectId: string, + itemId: string, + itemNodeId: string, + parentFolderId: string | null, + parentNodeId: string | null, + targetIndex: number, +): Promise => + requestWorkspaceTree(`/projects/${projectId}/tree/items/move`, { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + itemId, + itemNodeId, + parentFolderId, + parentNodeId, + targetIndex, + }), + }); diff --git a/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts new file mode 100644 index 0000000..08db383 --- /dev/null +++ b/Frontend/src/components/workspace-navigation/shared/workspaceTree.types.ts @@ -0,0 +1,43 @@ +import type { Accessor, Setter } from "solid-js"; +import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../../app-shell/data/shell.data"; + +export type PersistedWorkspaceTreeNodeRecord = { + id: string; + path: string; + label: string; + kind: "folder" | "item"; + itemType?: string; + children?: PersistedWorkspaceTreeNodeRecord[]; +}; + +export type WorkspaceTreeResponse = { + data?: { + nodes?: PersistedWorkspaceTreeNodeRecord[]; + }; + error?: string; + message?: string; +}; + +export type WorkspaceMutationResponse = { + error?: string; + message?: string; +}; + +export type UseWorkspaceTreeDataOptions = { + activeProjectId: Accessor; + fallbackWorkspaceTree: Accessor; +}; + +export type UseWorkspaceTreeDataResult = { + workspaceTreeNodes: Accessor; + setWorkspaceTreeNodes: Setter; + resolveFolderPath: (folderId: string) => string | null; + resolveItemPath: (itemId: string) => string | null; + createFolder: (name: string, parentId: string | null) => Promise; + renameFolder: (folderId: string, name: string) => Promise; + deleteFolder: (folderId: string) => Promise; + moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise; + createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise; + deleteItem: (itemId: string) => Promise; + moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise; +}; diff --git a/Frontend/src/entry-client.tsx b/Frontend/src/entry-client.tsx index a725265..fa166d1 100644 --- a/Frontend/src/entry-client.tsx +++ b/Frontend/src/entry-client.tsx @@ -3,7 +3,7 @@ // @refresh reload import { mount, StartClient } from "@solidjs/start/client"; import type { JSX } from "solid-js"; -import { initializeThemeRuntime } from "./theme/runtime"; +import { initializeThemeRuntime } from "./helper/themeRuntime"; const getAppRoot = (): HTMLElement => { const appRoot = document.getElementById("app"); diff --git a/Frontend/src/entry-server.tsx b/Frontend/src/entry-server.tsx index 37888e3..0f1d7e8 100644 --- a/Frontend/src/entry-server.tsx +++ b/Frontend/src/entry-server.tsx @@ -3,7 +3,7 @@ // @refresh reload import type { JSX } from "solid-js"; import { createHandler, StartServer } from "@solidjs/start/server"; -import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime"; +import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./helper/themeRuntime"; import { THEME_MODE_NAMES } from "./theme/schema"; const themeBootstrapScript = ` diff --git a/Frontend/src/components/shell/createLongPressGesture.ts b/Frontend/src/helper/createLongPressGesture.ts similarity index 96% rename from Frontend/src/components/shell/createLongPressGesture.ts rename to Frontend/src/helper/createLongPressGesture.ts index 86d0a70..7073ef0 100644 --- a/Frontend/src/components/shell/createLongPressGesture.ts +++ b/Frontend/src/helper/createLongPressGesture.ts @@ -1,3 +1,5 @@ +// Path: Frontend/src/helper/createLongPressGesture.ts + import type { JSX } from "solid-js"; type PointerHandler = NonNullable["onPointerDown"]>; diff --git a/Frontend/src/theme/runtime.ts b/Frontend/src/helper/themeRuntime.ts similarity index 94% rename from Frontend/src/theme/runtime.ts rename to Frontend/src/helper/themeRuntime.ts index 93db250..4b8f796 100644 --- a/Frontend/src/theme/runtime.ts +++ b/Frontend/src/helper/themeRuntime.ts @@ -1,7 +1,13 @@ -// Path: Frontend/src/theme/runtime.ts +// Path: Frontend/src/helper/themeRuntime.ts -import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets"; -import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema"; +import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "../theme/presets"; +import { + createCssVariableMap, + isThemeModeName, + validateThemeDefinition, + type ThemeDefinition, + type ThemeModeName, +} from "../theme/schema"; export type Theme = ThemeModeName;