diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 0e5cfa1..3799efa 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -559,8 +559,6 @@ export const getWorkspaceContextMenuSections = ( id: "organize", label: undefined, items: [ - { id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } }, - { id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } }, { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, ], }, @@ -582,8 +580,6 @@ export const getWorkspaceContextMenuSections = ( id: "organize", label: undefined, items: [ - { id: `duplicate-${actionPrefix}`, label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } }, - { id: `move-${actionPrefix}`, label: "Move", shortcut: { modifiers: ["meta"], key: "m" } }, { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, ], }, diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts new file mode 100644 index 0000000..80b831f --- /dev/null +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts @@ -0,0 +1,173 @@ +// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts + +export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure"; + +export type BootstrapStepDefinition = { + id: BootstrapStepKey; + title: string; + buttonLabel: string; +}; + +export type InstanceForm = { + protocol: "http" | "https"; + access: "local" | "remote"; + host: string; +}; + +export type ModeForm = { + mode: "personal" | "organizational"; + name: string; +}; + +export type AdminForm = { + displayName: string; + email: string; + password: string; +}; + +export type StructureForm = { + departmentName: string; + teamName: string; + projectName: string; +}; + +export type BootstrapPersona = "personal" | "enthusiast" | "team" | "organization"; + +export type BootstrapPersonaDefinition = { + id: BootstrapPersona; + title: string; + isAvailable: boolean; + bestFor: string; + bullets: readonly string[]; + defaults: { + protocol: InstanceForm["protocol"]; + access: InstanceForm["access"]; + host: string; + mode: ModeForm["mode"]; + namePlaceholder: string; + departmentName: string; + teamName: string; + projectName: string; + }; +}; + +export const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ + { id: "persona", title: "What are you setting up your server for?", buttonLabel: "Continue" }, + { id: "instance", title: "Connection details", buttonLabel: "Save and continue" }, + { id: "mode", title: "Server identity", buttonLabel: "Save and continue" }, + { id: "admin", title: "Admin account", buttonLabel: "Save and continue" }, + { id: "structure", title: "Initial structure", buttonLabel: "Submit" }, +]; + +export const defaultInstanceForm: InstanceForm = { + protocol: "http", + access: "local", + host: "localhost", +}; + +export const defaultModeForm: ModeForm = { + mode: "personal", + name: "", +}; + +export const defaultAdminForm: AdminForm = { + displayName: "Admin", + email: "admin@example.com", + password: "", +}; + +export const personalStructureDefaults = { + departmentName: "Default", + teamName: "Personal", +}; + +export const organizationalStructureDefaults = { + departmentName: "Department", + teamName: "Team", +}; + +export const defaultStructureForm: StructureForm = { + ...personalStructureDefaults, + projectName: "Project", +}; + +export const bootstrapPersonaDefinitions: readonly BootstrapPersonaDefinition[] = [ + { + id: "personal", + title: "Personal", + isAvailable: true, + bestFor: "Best for low maintenance, personal use", + bullets: ["Preconfigured for personal use", "Low setup time", "Easy to manage"], + defaults: { + protocol: "http", + access: "local", + host: "localhost", + mode: "personal", + namePlaceholder: "Personal Server", + departmentName: "Default", + teamName: "Personal", + projectName: "Project", + }, + }, + { + id: "enthusiast", + title: "Self Hosted Enthusiast", + isAvailable: true, + bestFor: "Best for people who want to customize their server", + bullets: ["Networking knowledge", "Comfortable with tinkering", "Willing to troubleshoot issues"], + defaults: { + protocol: "https", + access: "remote", + host: "moku.local", + mode: "personal", + namePlaceholder: "Personal Server", + departmentName: "Default", + teamName: "Personal", + projectName: "Project", + }, + }, + { + id: "team", + title: "Team", + isAvailable: true, + bestFor: "Best for low maintenance but for small team", + bullets: ["Built-in collaboration with low setup time", "Keeps the shared structure simple", "Good for a small product, design, or delivery team"], + defaults: { + protocol: "http", + access: "local", + host: "localhost", + mode: "organizational", + namePlaceholder: "Team Server", + departmentName: "Default", + teamName: "Core Team", + projectName: "Project", + }, + }, + { + id: "organization", + title: "Organization", + isAvailable: true, + bestFor: "Best for multiple teams and shared ownership", + bullets: ["SME to Organization", "Fine grained access control", "Better fit for teams with multiple departments"], + defaults: { + protocol: "https", + access: "remote", + host: "workspace.example.com", + mode: "organizational", + namePlaceholder: "Organization server name", + departmentName: "Operations", + teamName: "Platform Team", + projectName: "Moku", + }, + }, +]; + +export const workspaceHomeFieldTooltips = { + protocol: "Usually people use http for a local-only setup and https when the server will be reached over a domain or reverse proxy.", + access: "Usually people use local when Moku is only reached on the same machine or LAN, and remote when they plan to reach it from another network or public domain.", + host: "Examples people usually set here are localhost, moku.local, or a real domain like workspace.example.com depending on how they plan to reach the server.", + serverName: "This is the friendly name people usually give the server itself, for example Personal Server, Ronald's Server, Homelab, Studio, or Workspace.", + department: "Departments are the highest-level grouping for work. People usually use names like Default, Operations, Product, Design, or Engineering.", + team: "Teams sit inside a department. Common examples are Personal, Platform Team, Core Team, Delivery, or Design Systems.", + project: "Projects are the workspace or initiative people work inside. Common examples are Project, Shared Workspace, Moku, Client Portal, or Website Redesign.", +} as const; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts new file mode 100644 index 0000000..e847214 --- /dev/null +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts @@ -0,0 +1,542 @@ +// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts + +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import { resolveAPIBase } from "../../../lib/api"; +import { + bootstrapPersonaDefinitions, + bootstrapStepDefinitions, + defaultAdminForm, + defaultInstanceForm, + defaultModeForm, + defaultStructureForm, + organizationalStructureDefaults, + personalStructureDefaults, + type AdminForm, + type BootstrapPersona, + type BootstrapPersonaDefinition, + type BootstrapStepDefinition, + type BootstrapStepKey, + type InstanceForm, + type ModeForm, + type StructureForm, +} from "./WorkspaceHome.data"; + +type AppShellBootstrapAdapter = { + installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined; + status: () => string; + reload: () => Promise; +}; + +export type BootstrapSubmissionState = { + status: "idle" | "submitting" | "success" | "error"; + error: string; +}; + +export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed"; + +export type FieldTooltipState = { + text: string; + left: number; + top: number; + placement: "top" | "bottom"; +}; + +const initialSubmissionState = (): BootstrapSubmissionState => ({ + status: "idle", + error: "", +}); + +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) => { + const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); + const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); + const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); + const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); + const [selectedPersona, setSelectedPersona] = createSignal("enthusiast"); + const [hasChosenPersona, setHasChosenPersona] = createSignal(false); + const [stepState, setStepState] = createStore>({ + persona: initialSubmissionState(), + instance: initialSubmissionState(), + mode: initialSubmissionState(), + admin: initialSubmissionState(), + structure: initialSubmissionState(), + }); + const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); + const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); + const [isWizardOpen, setIsWizardOpen] = createSignal(false); + const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false); + const [currentStepIndex, setCurrentStepIndex] = createSignal(0); + const [fieldTooltip, setFieldTooltip] = createSignal(null); + + const installation = createMemo(() => appShellData.installation()); + const materializationState = createMemo(() => { + const status = installation()?.materializationStatus; + + switch (status) { + case "pending": + case "running": + case "failed": + case "succeeded": + case "not_started": + return status; + default: + return installation()?.isBootstrapped ? "succeeded" : "not_started"; + } + }); + const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false); + const isMaterializationInFlight = createMemo(() => materializationState() === "pending" || materializationState() === "running"); + const hasMaterializationFailed = createMemo(() => materializationState() === "failed"); + const showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed())); + const materializationStatusLabel = createMemo(() => { + switch (materializationState()) { + case "pending": + return "Materialization queued"; + case "running": + return "Materialization running"; + case "failed": + return "Materialization failed"; + case "succeeded": + return "Ready"; + default: + return "Not started"; + } + }); + const materializationMessage = createMemo(() => { + if (isMaterializationInFlight()) { + return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index."; + } + + if (hasMaterializationFailed()) { + return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly."; + } + + return ""; + }); + const personaDefinition = createMemo(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!); + const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable); + const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team"); + const activeBootstrapSteps = createMemo(() => { + if (usesCondensedBootstrapFlow()) { + return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!]; + } + + return bootstrapStepDefinitions; + }); + const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona")); + + createEffect(() => { + const defaults = personaDefinition().defaults; + + setInstanceForm({ + protocol: defaults.protocol, + access: defaults.access, + host: defaults.host, + }); + setModeForm("mode", defaults.mode); + setStructureForm({ + departmentName: defaults.departmentName, + teamName: defaults.teamName, + projectName: defaults.projectName, + }); + }); + + createEffect(() => { + if (modeForm.mode === "personal") { + setStructureForm("departmentName", personalStructureDefaults.departmentName); + setStructureForm("teamName", personalStructureDefaults.teamName); + return; + } + + if (structureForm.departmentName === personalStructureDefaults.departmentName) { + setStructureForm("departmentName", organizationalStructureDefaults.departmentName); + } + + if (structureForm.teamName === personalStructureDefaults.teamName) { + setStructureForm("teamName", organizationalStructureDefaults.teamName); + } + }); + + const resetWizardState = (): void => { + setSelectedPersona("enthusiast"); + setHasChosenPersona(false); + setInstanceForm({ ...defaultInstanceForm }); + setModeForm({ ...defaultModeForm }); + setAdminForm({ ...defaultAdminForm }); + setStructureForm({ ...defaultStructureForm }); + setStepState({ + persona: initialSubmissionState(), + instance: initialSubmissionState(), + mode: initialSubmissionState(), + admin: initialSubmissionState(), + structure: initialSubmissionState(), + }); + setCurrentStepIndex(0); + setIsFinishingBootstrapFlow(false); + }; + + createEffect(() => { + const shellStatus = appShellData.status(); + + if (shellStatus === "idle" || shellStatus === "loading") { + return; + } + + if (shellStatus !== "success") { + return; + } + + if (!isBootstrapPersisted()) { + setIsFinishingBootstrapFlow(false); + resetWizardState(); + } + + setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); + setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState()); + setIsBootstrapStateResolved(true); + }); + + createEffect(() => { + if (!isFinishingBootstrapFlow()) { + return; + } + + if (isMaterializationInFlight() || hasMaterializationFailed()) { + return; + } + + setIsFinishingBootstrapFlow(false); + setIsWizardOpen(false); + }); + + createEffect(() => { + if (!isBootstrapPersisted() || !isMaterializationInFlight()) { + return; + } + + let cancelled = false; + let timeoutId: number | undefined; + + const scheduleReload = (): void => { + timeoutId = window.setTimeout(async () => { + if (cancelled) { + return; + } + + await appShellData.reload(); + + if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) { + scheduleReload(); + } + }, materializationPollIntervalMs); + }; + + scheduleReload(); + + onCleanup(() => { + cancelled = true; + + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }); + }); + + const apiBase = (): string => resolveAPIBase(); + const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder; + const bootstrapStepCount = createMemo(() => activeWizardSteps().length); + const currentStep = createMemo(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!); + const currentWizardStepIndex = createMemo(() => { + const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id); + + return visibleIndex >= 0 ? visibleIndex : 0; + }); + const wizardProgressPercent = createMemo(() => { + const totalSteps = bootstrapStepCount(); + const activeIndex = Math.max(currentWizardStepIndex(), 0); + + if (totalSteps <= 1) { + return 100; + } + + return (activeIndex / (totalSteps - 1)) * 100; + }); + const wizardProgressFillWidth = createMemo(() => { + if (currentStepIndex() <= 0) { + return `${wizardProgressPercent()}%`; + } + + return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`; + }); + const currentStepState = createMemo(() => stepState[currentStep().id]); + const isFirstStep = (): boolean => currentStepIndex() === 0; + const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1; + const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight(); + + createEffect(() => { + setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1)); + }); + + const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise => { + 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)); + } + + setStepState(step, { + status: "success", + error: "", + }); + + return true; + } catch (error) { + setStepState(step, { + status: "error", + error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`, + }); + + return false; + } + }; + + const payloadForStep = (step: BootstrapStepKey): unknown => { + switch (step) { + case "instance": + return instanceForm; + case "mode": + return modeForm; + case "admin": + return adminForm; + case "structure": + return structureForm; + } + }; + + const applyPersonaSelection = (persona: BootstrapPersona): void => { + const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona); + + if (!definition?.isAvailable) { + return; + } + + setSelectedPersona(persona); + setHasChosenPersona(true); + setStepState("persona", { + status: "success", + error: "", + }); + setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1)); + }; + + const statusLabel = (state: BootstrapSubmissionState): string => { + switch (state.status) { + case "submitting": + return "Sending"; + case "error": + return "Request failed"; + default: + return ""; + } + }; + + const submitCurrentStep = async (): Promise => { + const step = currentStep().id; + + if (step === "persona") { + applyPersonaSelection(selectedPersona()); + return; + } + + if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") { + const didPersistInstanceDefaults = await submitStep("instance", instanceForm); + + if (!didPersistInstanceDefaults) { + return; + } + } + + const didSucceed = await submitStep(step, payloadForStep(step)); + + if (!didSucceed) { + return; + } + + if (step === "admin" && usesCondensedBootstrapFlow()) { + const didPersistStructureDefaults = await submitStep("structure", structureForm); + + if (!didPersistStructureDefaults) { + return; + } + } + + if (isLastStep()) { + await appShellData.reload(); + + const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed()); + setIsFinishingBootstrapFlow(shouldShowFinishingState); + setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); + setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState); + setIsBootstrapStateResolved(true); + return; + } + + setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1)); + }; + + const showFieldTooltip = (target: HTMLElement, text: string): void => { + const rect = target.getBoundingClientRect(); + const placement = rect.top > 96 ? "top" : "bottom"; + const viewportPadding = 20; + const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding); + const top = placement === "top" ? rect.top - 10 : rect.bottom + 10; + + setFieldTooltip({ text, left, top, placement }); + }; + + const hideFieldTooltip = (): void => { + setFieldTooltip(null); + }; + + const stepStatusLabel = (step: BootstrapStepDefinition): string => { + const state = stepState[step.id]; + + if (state.status === "success") { + return "Done"; + } + + if (state.status === "error") { + return "Needs retry"; + } + + return ""; + }; + + return { + instanceForm, + setInstanceForm, + modeForm, + setModeForm, + adminForm, + setAdminForm, + structureForm, + setStructureForm, + selectedPersona, + setSelectedPersona, + hasChosenPersona, + stepState, + isBootstrapStateResolved, + isBootstrapComplete, + isWizardOpen, + setIsWizardOpen, + isFinishingBootstrapFlow, + setIsFinishingBootstrapFlow, + fieldTooltip, + materializationState, + isMaterializationInFlight, + hasMaterializationFailed, + showBootstrapFinishingState, + materializationStatusLabel, + materializationMessage, + personaDefinition, + selectedPersonaIsAvailable, + usesCondensedBootstrapFlow, + activeWizardSteps, + bootstrapNamePlaceholder, + bootstrapStepCount, + currentStep, + currentWizardStepIndex, + wizardProgressFillWidth, + currentStepState, + isFirstStep, + canDismissWizard, + resetWizardState, + handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => { + event.preventDefault(); + void submitCurrentStep(); + }, + applyPersonaSelection, + statusLabel, + showFieldTooltip, + hideFieldTooltip, + stepStatusLabel, + navigateBack: (): void => { + setCurrentStepIndex((index) => Math.max(index - 1, 0)); + }, + navigateToVisibleStep: (index: number): void => { + setCurrentStepIndex(index + 1); + }, + }; +}; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss index 53e73fb..1624c99 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss @@ -1,3 +1,5 @@ +/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */ + .viewport, .wizardLayer { --workspace-content-max-width: var(--content-width-wide); @@ -19,14 +21,14 @@ 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: 0; + gap: var(--space-2); + min-height: calc(var(--control-size-md) - var(--space-3)); + padding: 0; } .workspaceTopBarStart, .workspaceTopBarEnd { - min-width: calc(var(--control-size-md) - 0.5rem); + min-width: calc(var(--control-size-md) - 0.5rem); display: inline-flex; align-items: center; } @@ -42,7 +44,7 @@ } .workspaceBreadcrumb { - @include text-caption; + @include text-caption; min-width: 0; color: var(--color-text-muted); white-space: nowrap; @@ -54,8 +56,8 @@ 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); + 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); @@ -93,18 +95,18 @@ } .heroStatus { - display: grid; - gap: var(--space-2); - justify-items: start; + display: grid; + gap: var(--space-2); + justify-items: start; } .heroStatusMessage { - max-width: 64ch; - color: var(--color-text-muted); + max-width: 64ch; + color: var(--color-text-muted); } .heroStatusMessage[data-status="failed"] { - color: var(--color-danger-text, var(--color-text)); + color: var(--color-danger-text, var(--color-text)); } .title { @@ -211,15 +213,15 @@ .statusBadge[data-status="pending"], .statusBadge[data-status="running"] { - color: var(--bootstrap-accent); - border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent); - background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary)); + color: var(--bootstrap-accent); + border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent); + background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary)); } .statusBadge[data-status="failed"] { - color: var(--color-danger-text, var(--color-text)); - border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); - background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent); + color: var(--color-danger-text, var(--color-text)); + border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); + background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent); } .statusBadge[data-status="error"] { @@ -268,11 +270,249 @@ color: var(--color-text-muted); } +.fieldLabelRow { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.fieldInfoButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); + cursor: help; + outline: none; +} + +.fieldInfoButton:hover, +.fieldInfoButton:focus-visible { + color: var(--color-text); +} + +.fieldTooltip { + position: fixed; + z-index: calc(var(--z-modal, 1000) + 4); + pointer-events: none; + transform: translateX(-50%); + max-width: min(18rem, calc(100vw - 2rem)); +} + +.fieldTooltip[data-placement="top"] { + transform: translate(-50%, -100%); +} + +.fieldTooltip[data-placement="bottom"] { + transform: translate(-50%, 0); +} + +.fieldTooltipBubble { + position: relative; + padding: 0.45rem 0.6rem; + border: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%); + box-shadow: var(--shadow-soft); + color: var(--color-text); + white-space: normal; + text-align: left; + line-height: 1.35; +} + +.fieldTooltipBubble::after { + content: ""; + position: absolute; + left: 50%; + width: 0.55rem; + height: 0.55rem; + background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%); + transform: translateX(-50%) rotate(45deg); +} + +.fieldTooltip[data-placement="top"] .fieldTooltipBubble::after { + top: calc(100% - 0.3rem); + border-right: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent); +} + +.fieldTooltip[data-placement="bottom"] .fieldTooltipBubble::after { + bottom: calc(100% - 0.3rem); + border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent); + border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent); +} + .fieldHelp { @include text-caption; color: var(--color-text-muted); } +.personaIntro { + display: grid; + gap: var(--space-2); +} + +.personaGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.personaCard { + appearance: none; + position: relative; + display: grid; + padding: var(--space-3); + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + border-radius: var(--radius-xl); + background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent); + text-align: left; + overflow: hidden; + transition: + transform 180ms var(--easing-standard), + border-color 160ms var(--easing-standard), + background 160ms var(--easing-standard), + box-shadow 160ms var(--easing-standard); +} + +.personaCard:hover, +.personaCard:focus-visible, +.personaCard[data-selected="true"] { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--bootstrap-accent) 32%, var(--color-border)); + background: color-mix(in srgb, var(--bootstrap-accent) 7%, var(--color-surface)); + box-shadow: var(--shadow-soft); +} + +.personaCard:focus-visible { + outline: none; + box-shadow: + var(--shadow-soft), + 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent); +} + +.personaCard[data-available="false"] { + opacity: 0.9; +} + +.personaCard[data-available="false"]:hover, +.personaCard[data-available="false"]:focus-visible, +.personaCard[data-available="false"][data-selected="true"] { + transform: none; + border-color: color-mix(in srgb, var(--color-border) 88%, transparent); + background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent); + box-shadow: none; +} + +.personaCardMedia { + position: relative; + display: flex; + align-items: center; + justify-content: center; + justify-self: center; + align-self: center; + width: min(100%, 16rem); + aspect-ratio: 1 / 1; + border: 1px dashed color-mix(in srgb, var(--color-border-strong) 40%, transparent); + border-radius: calc(var(--radius-xl) - var(--space-1)); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--color-surface-elevated) 92%, transparent), + color-mix(in srgb, var(--color-surface-secondary) 88%, transparent) + ); + transition: + filter 180ms var(--easing-standard), + opacity 180ms var(--easing-standard), + transform 180ms var(--easing-standard); +} + +.personaCardBody { + position: absolute; + inset: 0; + display: grid; + align-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + pointer-events: none; + z-index: 1; +} + +.personaCardTitle { + @include text-title; + margin: 0; + max-width: min(100%, 14rem); + padding: 0.35rem 0.65rem; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-surface) 84%, transparent); + backdrop-filter: blur(10px); + color: var(--color-text); +} + +.personaCardDetails { + display: grid; + gap: var(--space-2); + max-height: 0; + opacity: 0; + overflow: hidden; + align-self: end; + padding: var(--space-3); + border-radius: var(--radius-lg); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-surface) 18%, transparent), + color-mix(in srgb, var(--color-surface) 92%, transparent) + ); + backdrop-filter: blur(12px); + transition: + max-height 180ms var(--easing-standard), + opacity 160ms var(--easing-standard); +} + +.personaCard:hover .personaCardMedia, +.personaCard:focus-visible .personaCardMedia, +.personaCard[data-selected="true"] .personaCardMedia { + filter: brightness(0.72); + opacity: 0.92; + transform: scale(0.985); +} + +.personaCard:hover .personaCardDetails, +.personaCard:focus-visible .personaCardDetails, +.personaCard[data-selected="true"] .personaCardDetails { + max-height: 12rem; + opacity: 1; +} + +.personaCard[data-available="false"]:hover .personaCardMedia, +.personaCard[data-available="false"]:focus-visible .personaCardMedia, +.personaCard[data-available="false"][data-selected="true"] .personaCardMedia { + filter: brightness(0.82); + opacity: 0.96; + transform: none; +} + +.personaBestFor, +.personaBulletList { + margin: 0; + color: var(--color-text); +} + +.personaBulletList { + padding-left: 1rem; + display: grid; + gap: 0.25rem; +} + +.personaAvailability { + @include text-caption; + margin: 0; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + .field input, .field select { min-height: var(--control-size-md); @@ -370,7 +610,7 @@ .primaryButton:hover, .secondaryButton:hover, .wizardCloseButton:hover, -.wizardStepButton:hover { +.wizardProgressStep:hover { transform: translateY(-1px); } @@ -445,72 +685,93 @@ .wizardBody { display: grid; - grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr); gap: var(--space-4); min-height: 0; } -.wizardSidebar { +.wizardProgress { + position: relative; display: grid; - gap: var(--space-4); - align-content: start; -} - -.wizardSidebarSection { gap: var(--space-3); } -.wizardSteps { - display: grid; - gap: var(--space-2); +.wizardProgressTrack { + position: absolute; + left: calc((var(--control-size-md) - var(--space-2)) / 2); + right: calc((var(--control-size-md) - var(--space-2)) / 2); + top: calc((var(--control-size-md) - var(--space-2)) / 2); + height: 2px; + background: color-mix(in srgb, var(--color-border) 72%, transparent); + transform: translateY(-50%); + pointer-events: none; } -.wizardStepButton { - width: 100%; - display: grid; - grid-template-columns: auto minmax(0, 1fr); +.wizardProgressFill { + height: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--bootstrap-accent) 72%, white 8%); + transition: width 220ms var(--easing-standard); +} + +.wizardProgressSteps { + display: flex; align-items: center; - text-align: left; - padding: var(--space-2) var(--space-3); - border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); - background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent); + justify-content: space-between; + gap: 0; } -.wizardStepButton[data-active="true"] { - border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent); - background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface)); +.wizardProgressStep { + position: relative; + z-index: 1; + flex: 0 0 auto; + display: inline-flex; + justify-content: center; + justify-items: center; + text-align: center; + padding: 0; + border: 0; + background: transparent; } -.wizardStepButton:disabled { - opacity: 0.56; +.wizardProgressStep:disabled { cursor: not-allowed; transform: none; } -.wizardStepIndex { +.wizardProgressIndex { width: calc(var(--control-size-md) - var(--space-2)); height: calc(var(--control-size-md) - var(--space-2)); display: inline-flex; align-items: center; justify-content: center; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--color-surface) 80%, transparent); + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + color: var(--color-text-muted); + transition: + border-color 160ms var(--easing-standard), + background 160ms var(--easing-standard), + color 160ms var(--easing-standard), + box-shadow 160ms var(--easing-standard), + transform 180ms var(--easing-standard); +} + +.wizardProgressStep[data-active="true"] .wizardProgressIndex, +.wizardProgressStep[data-complete="true"] .wizardProgressIndex { + border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent); + background: color-mix(in srgb, var(--bootstrap-accent) 12%, var(--color-surface)); color: var(--color-text); } -.wizardStepCopy { - min-width: 0; - display: grid; - gap: 0.125rem; +.wizardProgressStep[data-active="true"] .wizardProgressIndex { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 14%, transparent); } -.wizardStepCopy strong { - @include text-label; -} - -.wizardStepCopy small { - @include text-caption; - color: var(--color-text-muted); +.wizardProgressStep:not(:disabled):hover .wizardProgressIndex, +.wizardProgressStep:not(:disabled):focus-visible .wizardProgressIndex { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--bootstrap-accent) 36%, var(--color-border)); + color: var(--color-text); } .wizardStepPanel { @@ -519,111 +780,115 @@ } .wizardFinishPanel { - display: grid; - gap: var(--space-4); - justify-items: stretch; - padding: var(--space-2) 0 0; - min-height: min(14rem, 32dvh); - align-content: center; + display: grid; + gap: var(--space-4); + justify-items: stretch; + padding: var(--space-2) 0 0; + min-height: min(14rem, 32dvh); + align-content: center; } .wizardFinishShell { - display: grid; - gap: var(--space-4); - width: min(100%, 40rem); - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; + display: grid; + gap: var(--space-4); + width: min(100%, 40rem); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .wizardFinishStatusRow { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: var(--space-3); - align-items: start; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-3); + align-items: start; } .wizardFinishIndicator { - width: 2.5rem; - height: 2.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); - background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent); + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); + background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent); } .wizardFinishIndicator[data-status="failed"] { - border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent); - background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent); + border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent); + background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent); } .wizardFinishSpinner { - width: 1.25rem; - height: 1.25rem; - border-radius: 999px; - border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); - border-top-color: var(--bootstrap-accent); - animation: wizardFinishSpin 900ms linear infinite; + width: 1.25rem; + height: 1.25rem; + border-radius: 999px; + border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); + border-top-color: var(--bootstrap-accent); + animation: wizardFinishSpin 900ms linear infinite; } .wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner { - border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent); - border-top-color: var(--color-danger-text, var(--color-text)); - animation: none; - transform: rotate(45deg); - border-radius: var(--radius-sm); - width: 1rem; - height: 1rem; + border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent); + border-top-color: var(--color-danger-text, var(--color-text)); + animation: none; + transform: rotate(45deg); + border-radius: var(--radius-sm); + width: 1rem; + height: 1rem; } .wizardFinishCopy { - display: grid; - gap: var(--space-1); - min-width: 0; + display: grid; + gap: var(--space-1); + min-width: 0; } .wizardFinishTitle { - @include text-title; - margin: 0; + @include text-title; + margin: 0; } .wizardFinishDescription, .wizardFinishMessage, .wizardFinishHint { - margin: 0; - color: var(--color-text-muted); + margin: 0; + color: var(--color-text-muted); } .wizardFinishMessage[data-status="failed"] { - color: var(--color-danger-text, var(--color-text)); + color: var(--color-danger-text, var(--color-text)); } .wizardFinishHint { - @include text-caption; + @include text-caption; } .wizardFinishActions { - display: flex; - gap: var(--space-3); - flex-wrap: wrap; - padding-top: var(--space-1); + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + padding-top: var(--space-1); } @keyframes wizardFinishSpin { - from { - transform: rotate(0deg); - } + from { + transform: rotate(0deg); + } - to { - transform: rotate(360deg); - } + to { + transform: rotate(360deg); + } } @include respond-down(tablet) { + .personaGrid { + grid-template-columns: 1fr; + } + .summaryGrid, .wizardBody { grid-template-columns: 1fr; @@ -669,21 +934,20 @@ } .wizardHeader, - .wizardBody, - .wizardSidebar { + .wizardBody { gap: var(--space-3); } - .wizardSteps { - grid-auto-flow: column; - grid-auto-columns: minmax(10rem, 1fr); + .wizardProgressSteps { + justify-content: flex-start; + gap: var(--space-8); overflow-x: auto; padding-bottom: var(--space-1); scrollbar-width: thin; } - .wizardStepButton { - min-width: 10rem; + .wizardProgressStep { + min-width: calc(var(--control-size-md) - var(--space-2)); } .wizardFormActions { diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx new file mode 100644 index 0000000..74f5128 --- /dev/null +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.parts.tsx @@ -0,0 +1,285 @@ +import { For, Show, type JSX } from "solid-js"; +import { CircleHelp } from "../../../lib/icons"; +import { + organizationalStructureDefaults, + workspaceHomeFieldTooltips, + type AdminForm, + type BootstrapPersona, + type BootstrapPersonaDefinition, + type BootstrapStepDefinition, + type BootstrapStepKey, + type InstanceForm, + type ModeForm, + type StructureForm, +} from "./WorkspaceHome.data"; +import styles from "./WorkspaceHome.module.scss"; + +type BootstrapSubmissionState = { + status: "idle" | "submitting" | "success" | "error"; + error: string; +}; + +type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed"; + +type TooltipHandlers = { + onShowTooltip: (target: HTMLElement, text: string) => void; + onHideTooltip: () => void; +}; + +type FieldLabelWithTooltipProps = TooltipHandlers & { + label: string; + tooltip?: string; +}; + +export const FieldLabelWithTooltip = (props: FieldLabelWithTooltipProps): JSX.Element => ( + + {props.label} + + + + +); + +type BootstrapFinishingStateProps = { + materializationState: MaterializationState; + statusLabel: string; + message: string; + isInFlight: boolean; + hasFailed: boolean; + onClose: () => void; +}; + +export const BootstrapFinishingState = (props: BootstrapFinishingStateProps): JSX.Element => ( +
+
+
+ +
{props.statusLabel}
+ +

{props.message}

+
+ +

This window will close automatically when setup is complete.

+
+
+ +
+ +
+
+
+); + +type BootstrapWizardProgressProps = { + steps: readonly BootstrapStepDefinition[]; + currentStepId: BootstrapStepKey; + currentWizardStepIndex: number; + stepState: Record; + bootstrapStepCount: number; + wizardProgressFillWidth: string; + stepStatusLabel: (step: BootstrapStepDefinition) => string; + onSelectStep: (index: number) => void; +}; + +export const BootstrapWizardProgress = (props: BootstrapWizardProgressProps): JSX.Element => ( +
+ +); + +type BootstrapPersonaStepProps = { + personas: readonly BootstrapPersonaDefinition[]; + hasChosenPersona: boolean; + selectedPersona: BootstrapPersona; + selectedPersonaIsAvailable: boolean; + onSelectPersona: (persona: BootstrapPersona) => void; +}; + +export const BootstrapPersonaStep = (props: BootstrapPersonaStepProps): JSX.Element => ( + <> +
+ + {(persona): JSX.Element => ( + + )} + +
+ +

Only Self Hosted Enthusiast is wired up right now. The other setup paths will come next.

+
+ +); + +type BootstrapInstanceStepProps = TooltipHandlers & { + instanceForm: InstanceForm; + onProtocolChange: (value: InstanceForm["protocol"]) => void; + onAccessChange: (value: InstanceForm["access"]) => void; + onHostChange: (value: string) => void; +}; + +export const BootstrapInstanceStep = (props: BootstrapInstanceStepProps): JSX.Element => ( + <> + + + + +); + +type BootstrapModeStepProps = TooltipHandlers & { + modeForm: ModeForm; + structureForm: StructureForm; + usesCondensedBootstrapFlow: boolean; + selectedPersona: BootstrapPersona; + namePlaceholder: string; + onNameChange: (value: string) => void; + onProjectNameChange: (value: string) => void; + onTeamNameChange: (value: string) => void; +}; + +export const BootstrapModeStep = (props: BootstrapModeStepProps): JSX.Element => ( + <> + + + + + + + + +); + +type BootstrapAdminStepProps = { + adminForm: AdminForm; + onDisplayNameChange: (value: string) => void; + onEmailChange: (value: string) => void; + onPasswordChange: (value: string) => void; +}; + +export const BootstrapAdminStep = (props: BootstrapAdminStepProps): JSX.Element => ( + <> + + + + +); + +type BootstrapStructureStepProps = TooltipHandlers & { + mode: ModeForm["mode"]; + structureForm: StructureForm; + onDepartmentNameChange: (value: string) => void; + onTeamNameChange: (value: string) => void; + onProjectNameChange: (value: string) => void; +}; + +export const BootstrapStructureStep = (props: BootstrapStructureStepProps): JSX.Element => ( + <> + + + + +); diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index 97500d8..96ec8b9 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx @@ -1,172 +1,13 @@ // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx -import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"; +import { Show, createMemo, type JSX } from "solid-js"; import { Portal } from "solid-js/web"; -import { createStore } from "solid-js/store"; -import { resolveAPIBase } from "../../../lib/api"; import { ChevronLeft, ChevronRight } from "../../../lib/icons"; import { useAppShellData } from "../../shell/data/app-shell.context"; +import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data"; +import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook"; import styles from "./WorkspaceHome.module.scss"; - -type BootstrapStepKey = "instance" | "mode" | "admin" | "structure"; - -type BootstrapStepDefinition = { - id: BootstrapStepKey; - title: string; - buttonLabel: string; -}; - -type BootstrapSubmissionState = { - status: "idle" | "submitting" | "success" | "error"; - error: string; -}; - -type InstanceForm = { - protocol: "http" | "https"; - access: "local" | "remote"; - host: string; -}; - -type ModeForm = { - mode: "personal" | "organizational"; - name: string; -}; - -type AdminForm = { - displayName: string; - email: string; - password: string; -}; - -type StructureForm = { - departmentName: string; - teamName: string; - projectName: string; -}; - -type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed"; - -const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ - { - id: "instance", - title: "Instance shape", - buttonLabel: "Save and continue", - }, - { - id: "mode", - title: "Server mode", - buttonLabel: "Save and continue", - }, - { - id: "admin", - title: "Admin account", - buttonLabel: "Save and continue", - }, - { - id: "structure", - title: "Initial structure", - buttonLabel: "Submit", - }, -]; - -const defaultInstanceForm: InstanceForm = { - protocol: "http", - access: "local", - host: "localhost", -}; - -const defaultModeForm: ModeForm = { - mode: "personal", - name: "", -}; - -const defaultAdminForm: AdminForm = { - displayName: "Admin", - email: "admin@example.com", - password: "", -}; - -const personalStructureDefaults = { - departmentName: "Default", - teamName: "Personal", -}; - -const organizationalStructureDefaults = { - departmentName: "Department", - teamName: "Team", -}; - -const defaultStructureForm: StructureForm = { - ...personalStructureDefaults, - projectName: "Project", -}; - -const initialSubmissionState = (): BootstrapSubmissionState => ({ - status: "idle", - error: "", -}); - -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(", ")})`; -}; +import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts"; type WorkspaceHomeProps = { sidebarCollapsed: boolean; @@ -175,301 +16,61 @@ type WorkspaceHomeProps = { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const appShellData = useAppShellData(); - const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); - const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); - const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); - const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); - const [stepState, setStepState] = createStore>({ - instance: initialSubmissionState(), - mode: initialSubmissionState(), - admin: initialSubmissionState(), - structure: initialSubmissionState(), - }); - const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); - const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); - const [isWizardOpen, setIsWizardOpen] = createSignal(false); - const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false); - const [currentStepIndex, setCurrentStepIndex] = createSignal(0); - const installation = createMemo(() => appShellData.installation()); - const materializationState = createMemo(() => { - const status = installation()?.materializationStatus; + 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); - switch (status) { - case "pending": - case "running": - case "failed": - case "succeeded": - case "not_started": - return status; - default: - return installation()?.isBootstrapped ? "succeeded" : "not_started"; - } - }); - const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false); - const isMaterializationInFlight = createMemo( - () => materializationState() === "pending" || materializationState() === "running", - ); - const hasMaterializationFailed = createMemo(() => materializationState() === "failed"); - const showBootstrapFinishingState = createMemo( - () => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()), - ); - const materializationStatusLabel = createMemo(() => { - switch (materializationState()) { - case "pending": - return "Materialization queued"; - case "running": - return "Materialization running"; - case "failed": - return "Materialization failed"; - case "succeeded": - return "Ready"; - default: - return "Not started"; - } - }); - const materializationMessage = createMemo(() => { - if (isMaterializationInFlight()) { - return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index."; - } - - if (hasMaterializationFailed()) { - return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly."; - } - - return ""; - }); - - createEffect(() => { - if (modeForm.mode === "personal") { - setStructureForm("departmentName", personalStructureDefaults.departmentName); - setStructureForm("teamName", personalStructureDefaults.teamName); - return; - } - - if (structureForm.departmentName === personalStructureDefaults.departmentName) { - setStructureForm("departmentName", organizationalStructureDefaults.departmentName); - } - - if (structureForm.teamName === personalStructureDefaults.teamName) { - setStructureForm("teamName", organizationalStructureDefaults.teamName); - } - }); - - createEffect(() => { - const shellStatus = appShellData.status(); - - if (shellStatus === "idle" || shellStatus === "loading") { - return; - } - - if (shellStatus !== "success") { - return; - } - - if (!isBootstrapPersisted()) { - setIsFinishingBootstrapFlow(false); - resetWizardState(); - } - - setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); - setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState()); - setIsBootstrapStateResolved(true); - }); - - createEffect(() => { - if (!isFinishingBootstrapFlow()) { - return; - } - - if (isMaterializationInFlight() || hasMaterializationFailed()) { - return; - } - - setIsFinishingBootstrapFlow(false); - setIsWizardOpen(false); - }); - - createEffect(() => { - if (!isBootstrapPersisted() || !isMaterializationInFlight()) { - return; - } - - let cancelled = false; - let timeoutId: number | undefined; - - const scheduleReload = (): void => { - timeoutId = window.setTimeout(async () => { - if (cancelled) { - return; - } - - // The final bootstrap step only persists relational state. Poll while the - // worker is materializing the POSIX skeleton so the page can transition from - // queued/running to ready/failed without a manual refresh. - await appShellData.reload(); - - if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) { - scheduleReload(); - } - }, materializationPollIntervalMs); - }; - - scheduleReload(); - - onCleanup(() => { - cancelled = true; - - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } - }); - }); - - const sidebarToggleLabel = (): string => - props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"; + const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"); const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; - const apiBase = (): string => resolveAPIBase(); - const bootstrapTargetLabel = (): string => - modeForm.mode === "personal" ? "Personal server" : "Organization server"; - const bootstrapNamePlaceholder = (): string => - modeForm.mode === "personal" ? "Personal server name" : "Organization server name"; - const currentStep = createMemo( - () => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!, - ); - const currentStepState = createMemo(() => stepState[currentStep().id]); - const isFirstStep = (): boolean => currentStepIndex() === 0; - const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; - const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight(); - - const resetWizardState = (): void => { - setInstanceForm({ ...defaultInstanceForm }); - setModeForm({ ...defaultModeForm }); - setAdminForm({ ...defaultAdminForm }); - setStructureForm({ ...defaultStructureForm }); - setStepState({ - instance: initialSubmissionState(), - mode: initialSubmissionState(), - admin: initialSubmissionState(), - structure: initialSubmissionState(), - }); - setCurrentStepIndex(0); - setIsFinishingBootstrapFlow(false); - }; - - const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise => { - 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)); - } - - setStepState(step, { - status: "success", - error: "", - }); - - return true; - } catch (error) { - setStepState(step, { - status: "error", - error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`, - }); - - return false; - } - }; - - const payloadForStep = (step: BootstrapStepKey): unknown => { - switch (step) { - case "instance": - return instanceForm; - case "mode": - return modeForm; - case "admin": - return adminForm; - case "structure": - return structureForm; - } - }; - - const submitCurrentStep = async (): Promise => { - const step = currentStep().id; - const didSucceed = await submitStep(step, payloadForStep(step)); - - if (!didSucceed) { - return; - } - - if (isLastStep()) { - await appShellData.reload(); - - const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed()); - setIsFinishingBootstrapFlow(shouldShowFinishingState); - setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); - setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState); - setIsBootstrapStateResolved(true); - return; - } - - setCurrentStepIndex((index) => Math.min(index + 1, bootstrapStepDefinitions.length - 1)); - }; - - const handleCurrentStepSubmit: JSX.EventHandler = (event): void => { - event.preventDefault(); - void submitCurrentStep(); - }; - - const statusLabel = (state: BootstrapSubmissionState): string => { - switch (state.status) { - case "submitting": - return "Sending"; - case "success": - return "Saved"; - case "error": - return "Request failed"; - default: - return "Ready"; - } - }; - - const stepStatusLabel = (step: BootstrapStepDefinition): string => { - const state = stepState[step.id]; - - if (state.status === "success") { - return "Done"; - } - - if (state.status === "error") { - return "Needs retry"; - } - - return ""; - }; return ( <>
-
@@ -482,20 +83,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
-

{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}

- -
- -
-
+

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

+ +
+ +
+
@@ -508,7 +109,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {

- Bootstrap {bootstrapTargetLabel()} + Bootstrap Server

@@ -527,251 +128,141 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { -
-
- -
- {materializationStatusLabel()} -
- -

- {materializationMessage()} -

-
- -

This window will close automatically when setup is complete.

-
-
- -
- -
-
-
+ { + setIsFinishingBootstrapFlow(false); + setIsWizardOpen(false); + }} + /> } >
- + + +
-
-
- {`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`} -

{currentStep().title}

-
-
- {statusLabel(currentStepState())} -
-
- -
- - <> - - - - + +
+ +
+ {`Step ${currentWizardStepIndex() + 1} of ${bootstrapStepCount()}`} +
+
+ +
+ {statusLabel(currentStepState())} +
+
+
- - <> - - - - - - - <> - - - - + + + + setInstanceForm("protocol", value)} + onAccessChange={(value): void => setInstanceForm("access", value)} + onHostChange={(value): void => setInstanceForm("host", value)} + onShowTooltip={showFieldTooltip} + onHideTooltip={hideFieldTooltip} + /> + + + + setModeForm("name", value)} + onProjectNameChange={(value): void => setStructureForm("projectName", value)} + onTeamNameChange={(value): void => setStructureForm("teamName", value)} + onShowTooltip={showFieldTooltip} + onHideTooltip={hideFieldTooltip} + /> + + + + setAdminForm("displayName", value)} + onEmailChange={(value): void => setAdminForm("email", value)} + onPasswordChange={(value): void => setAdminForm("password", value)} + /> + + + + setStructureForm("departmentName", value)} + onTeamNameChange={(value): void => setStructureForm("teamName", value)} + onProjectNameChange={(value): void => setStructureForm("projectName", value)} + onShowTooltip={showFieldTooltip} + onHideTooltip={hideFieldTooltip} + /> + + + +
+ + +
+
+ + + +

{currentStepState().error}

- - - <> - - - - - - -
- - -
- - - -

{currentStepState().error}

-
-
+ + + {(tooltip): JSX.Element => ( +
+
{tooltip().text}
+
+ )} +