diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ab75d222..58de1ef37 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ env: # renovate: datasource=npm depName=npm NPM_VERSION: "11" # renovate: datasource=npm depName=@microbit-foundation/python-editor-v3-microbit registryUrl=https://npm.pkg.github.com - THEME_VERSION: 0.0.0-home.page.124 + THEME_VERSION: 0.0.0-home.page.125 jobs: stages: diff --git a/docs/analytics-events.md b/docs/analytics-events.md index 857b98e3d..8e3648be0 100644 --- a/docs/analytics-events.md +++ b/docs/analytics-events.md @@ -181,7 +181,8 @@ Projects page only. Fires when the user changes the sort field or direction. ### `idea_open` -User opened an idea from the documentation as a new project. +User opened an idea as a new project, from the home page idea dialog or the +editor's Ideas tab. | Param | Values | | ----- | -------------------------- | diff --git a/lang/ui.en.json b/lang/ui.en.json index c352d45b2..494fde5e7 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -245,7 +245,7 @@ }, "create-project-action": { "defaultMessage": "Create", - "description": "Confirm button of the dialog naming a new project" + "description": "Button creating a new project: confirms the dialog naming a new project, and creates one from the home page idea dialog" }, "create-python": { "defaultMessage": "Create a new Python file in this project", @@ -391,6 +391,10 @@ "defaultMessage": "For more support, see the Python Editor guide", "description": "Text with an external link to support/help content" }, + "guide-tab": { + "defaultMessage": "Guide", + "description": "Sidebar tab button text. Tab shows the guide for the idea the project was opened from." + }, "help": { "defaultMessage": "Help", "description": "Help menu label" diff --git a/src/common/AreaHeading.tsx b/src/common/AreaHeading.tsx index 63b9a9904..a4f00a182 100644 --- a/src/common/AreaHeading.tsx +++ b/src/common/AreaHeading.tsx @@ -9,7 +9,7 @@ import { Box } from "styled-system/jsx"; interface AreaHeadingProps { name: string; - description: ReactNode; + description?: ReactNode; } const AreaHeading = ({ name, description }: AreaHeadingProps) => ( @@ -17,7 +17,7 @@ const AreaHeading = ({ name, description }: AreaHeadingProps) => ( {name} - {description} + {description !== undefined && {description}} ); diff --git a/src/documentation/GuideArea.tsx b/src/documentation/GuideArea.tsx new file mode 100644 index 000000000..ad50c7480 --- /dev/null +++ b/src/documentation/GuideArea.tsx @@ -0,0 +1,58 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Text } from "@microbit/ui"; +import { FormattedMessage } from "react-intl"; +import { Stack } from "styled-system/jsx"; +import AreaHeading from "../common/AreaHeading"; +import { docStylesClass } from "../common/documentation-styles"; +import HeadedScrollablePanel from "../common/HeadedScrollablePanel"; +import Spinner from "../common/Spinner"; +import { useOpenIdeaSlug } from "../project/projects-hooks"; +import { useDocumentation } from "./documentation-hooks"; +import IdeaGuideContent from "./ideas/IdeaGuideContent"; +import { Idea } from "./ideas/model"; + +/** + * The guide for the idea the open project came from: its content without + * the program, which is in the editor. + */ +const GuideArea = () => { + const slug = useOpenIdeaSlug(); + const { ideas } = useDocumentation(); + if (ideas.status === "loading") { + return ; + } + const idea = + ideas.status === "ok" + ? ideas.content.find((idea) => idea.slug.current === slug) + : undefined; + if (!idea) { + return ( + + + + ); + } + return ; +}; + +const Guide = ({ idea }: { idea: Idea }) => ( + }> + + + + +); + +export default GuideArea; diff --git a/src/documentation/common/DocumentationContent.tsx b/src/documentation/common/DocumentationContent.tsx index 1846ac01c..f17214c37 100644 --- a/src/documentation/common/DocumentationContent.tsx +++ b/src/documentation/common/DocumentationContent.tsx @@ -43,6 +43,11 @@ interface DocumentationContextValue { parentSlug?: string; toolkitType?: string; title?: string; + /** + * Links to Reference and API, which navigate the editor's sidebar. False + * outside the editor, where they show as plain text. + */ + editorLinks?: boolean; } const DocumentationContext = React.createContext({}); @@ -67,10 +72,17 @@ const useCodeEmbedContext = () => { return { parentSlug, toolkitType, title }; }; +const useEditorLinks = () => + useContext(DocumentationContext).editorLinks !== false; + const DocumentationApiLinkMark = ( props: SerializerMarkProps ) => { const [, setState] = useRouterState(); + const editorLinks = useEditorLinks(); + if (!editorLinks) { + return <>{props.children}; + } return ( ) => { const [state, setState] = useRouterState(); + const editorLinks = useEditorLinks(); + if (!editorLinks) { + return <>{props.children}; + } return ( { + // Runtime value derived from the image reference. + const imagePadding = idea.image + ? getAspectRatioPadding(idea.image.asset._ref) + : undefined; + return ( + <> + {inEditor && idea.image && ( + + } + alt="" + borderTopRadius="lg" + /> + + )} + + + + + ); +}; + +export default IdeaGuideContent; diff --git a/src/documentation/ideas/idea-code.ts b/src/documentation/ideas/idea-code.ts new file mode 100644 index 000000000..c5982ffad --- /dev/null +++ b/src/documentation/ideas/idea-code.ts @@ -0,0 +1,19 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { PortableText } from "../../common/sanity"; +import { Idea } from "./model"; + +const isCode = ( + block: PortableText[number] +): block is { _type: "python"; main: string } => block._type === "python"; + +/** The idea's program: its content's first code block. */ +export const ideaCode = (idea: Idea): string | undefined => + idea.content?.find(isCode)?.main; + +/** The idea's content without its code, for the guide beside the editor. */ +export const withoutCode = (content: PortableText | undefined): PortableText => + (content ?? []).filter((block) => !isCode(block)); diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 5449ffc2d..7373f17dc 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -301,7 +301,7 @@ export class App { } } - async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas") { + async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas" | "Guide") { await this.page.getByRole("tab", { name: tabName }).click(); } diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index 9c6c3a5d2..c05ea8069 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -42,6 +42,29 @@ test.describe("home page", () => { await app.expectEditorContainText("from microbit import"); }); + test("opens an idea from its dialog as a project with its guide", async ({ + app, + homePage, + }) => { + const ideaName = "Emotion badge"; + await homePage.goto(); + await homePage.page.getByRole("button", { name: ideaName }).click(); + const dialog = homePage.page.getByRole("dialog"); + await expect( + dialog.getByRole("heading", { name: ideaName, exact: true }) + ).toBeVisible(); + + await dialog.getByRole("button", { name: "Create", exact: true }).click(); + + await expect(app.page).toHaveURL(appUrlPattern("project/guide")); + await app.expectProjectName(ideaName); + await expect( + app.page.getByRole("tab", { name: "Guide", selected: true }) + ).toBeVisible(); + await app.expectDocumentationTopLevelHeading(ideaName); + await app.expectEditorContainText("from microbit import"); + }); + test("returns home from the editor with the project listed", async ({ app, homePage, diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts index 98ed3223e..3f3339a28 100644 --- a/src/fs/projects-db.ts +++ b/src/fs/projects-db.ts @@ -17,6 +17,8 @@ export interface ProjectMeta { name: string | undefined; /** Last modified or opened, for ordering by recency. */ timestamp: number; + /** The idea the project was opened from, whose guide it shows. */ + ideaSlug?: string; } /** A project as listed on the pages: its metadata plus what it contains. */ diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ad1de444a..7ddcefce8 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -31,11 +31,11 @@ import { useSettings } from "../settings/settings"; import { createProjectsPageUrl } from "../urls"; import ActionCard from "./ActionCard"; import DefaultPageLayout from "./DefaultPageLayout"; +import IdeaDialog from "./IdeaDialog"; import ProjectIcon from "./ProjectIcon"; import HomepageBanner from "./HomepageBanner"; import HomepageFooter from "./HomepageFooter"; import { - useCreateProject, useImportProjectFiles, usePageProjects, useProjectPageActions, @@ -55,7 +55,14 @@ const HomePage = () => { const intl = useIntl(); const [{ languageId }] = useSettings(); const brand = useDeployment(); - const createProject = useCreateProject("home"); + const [selectedIdea, setSelectedIdea] = useState<{ + slug: string; + title: string; + }>(); + const selectIdea = useCallback( + (slug: string, title: string) => setSelectedIdea({ slug, title }), + [] + ); const importFiles = useImportProjectFiles(); const handleDrop = useCallback( (files: File[]) => void importFiles(files, "drop"), @@ -63,6 +70,12 @@ const HomePage = () => { ); return ( + {selectedIdea && ( + setSelectedIdea(undefined)} + /> + )} { } navigation /> diff --git a/src/pages/IdeaDialog.tsx b/src/pages/IdeaDialog.tsx new file mode 100644 index 000000000..acba418dd --- /dev/null +++ b/src/pages/IdeaDialog.tsx @@ -0,0 +1,92 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalFooter, + ModalHeader, + Text, +} from "@microbit/ui"; +import { FormattedMessage } from "react-intl"; +import { Box, Grid, Stack } from "styled-system/jsx"; +import { docStylesClass } from "../common/documentation-styles"; +import Spinner from "../common/Spinner"; +import { useDocumentation } from "../documentation/documentation-hooks"; +import { ideaCode } from "../documentation/ideas/idea-code"; +import IdeaGuideContent from "../documentation/ideas/IdeaGuideContent"; +import CodeMirrorView from "../editor/codemirror/CodeMirrorView"; +import { useOpenIdea } from "./project-page-actions"; + +interface IdeaDialogProps { + slug: string; + /** The idea's name, shown before its content loads and given to the project. */ + title: string; + onClose: () => void; +} + +/** + * An idea from the home page: its program beside text explaining it, and a + * way to open it as a new project. + */ +const IdeaDialog = ({ slug, title, onClose }: IdeaDialogProps) => { + const { ideas } = useDocumentation(); + const openIdea = useOpenIdea(); + const idea = + ideas.status === "ok" + ? ideas.content.find((idea) => idea.slug.current === slug) + : undefined; + const code = idea && ideaCode(idea); + return ( + + + + {title} + + + + {/* Always rendered, so the code stays in the right-hand column. */} + + {idea && } + + + {ideas.status === "loading" ? ( + + ) : code !== undefined ? ( + + ) : ( + + + + )} + + + + + + + + ); +}; + +export default IdeaDialog; diff --git a/src/pages/project-page-actions.tsx b/src/pages/project-page-actions.tsx index 0e1f95dd9..4e21a345a 100644 --- a/src/pages/project-page-actions.tsx +++ b/src/pages/project-page-actions.tsx @@ -17,6 +17,7 @@ import { useNavigation, } from "react-router"; import useActionFeedback from "../common/use-action-feedback"; +import { MAIN_FILE } from "../fs/fs"; import { useLogging } from "../logging/logging-hooks"; import { useProjectImporter } from "../project/project-hooks"; import { ImportSource } from "../project/project-import"; @@ -44,19 +45,41 @@ interface CreateProjectFailure { error: unknown; } +/** What the editor route's action reads from its form. */ +interface CreateProjectFields { + name: string; + /** An idea's program for main.py, with the idea's slug. */ + idea?: { code: string; slug: string }; +} + /** - * The editor route's action: creates a project named by the form and opens - * the editor on it at the same URL. Part of the navigation, so the router - * knows a create is in progress until the editor is showing. + * The editor route's action: creates a project from the form and opens the + * editor on it at the same URL. Part of the navigation, so the router knows + * a create is in progress until the editor is showing. */ export const createProjectAction = (projects: Projects) => async ({ request, }: ActionFunctionArgs): Promise => { - const name = (await request.formData()).get("name"); + const form = await request.formData(); + const field = (key: string) => { + const value = form.get(key); + return typeof value === "string" ? value : undefined; + }; + const name = field("name") ?? ""; + const code = field("code"); + const ideaSlug = field("ideaSlug"); try { - await projects.create(typeof name === "string" ? name : ""); + if (code !== undefined && ideaSlug !== undefined) { + await projects.createFromFiles( + name, + { [MAIN_FILE]: new TextEncoder().encode(code) }, + ideaSlug + ); + } else { + await projects.create(name); + } } catch (error) { return { error }; } @@ -124,6 +147,37 @@ const useAttempt = () => { */ export const useCreateProject = (surface: ProjectSurface) => { const logging = useLogging(); + const submit = useSubmitCreateProject(); + return useCallback( + async (name: string, state?: RouterState) => { + logging.event({ type: "project_create", detail: { surface } }); + await submit({ name }, state); + }, + [logging, submit, surface] + ); +}; + +/** + * Opens an idea as a new project with its program in main.py, showing its + * guide beside the editor. + */ +export const useOpenIdea = () => { + const logging = useLogging(); + const submit = useSubmitCreateProject(); + return useCallback( + async (name: string, slug: string, code: string) => { + logging.event({ type: "idea_open", detail: { id: slug } }); + await submit({ name, idea: { code, slug } }, { tab: "guide" }); + }, + [logging, submit] + ); +}; + +/** + * Submits to the editor route's action (createProjectAction), reporting a + * failure as a toast. + */ +const useSubmitCreateProject = () => { const reportError = useReportError(); const fetcher = useFetcher({ key: createProjectFetcherKey, @@ -136,14 +190,18 @@ export const useCreateProject = (surface: ProjectSurface) => { }, [failure, reportError]); const { submit } = fetcher; return useCallback( - async (name: string, state?: RouterState) => { - logging.event({ type: "project_create", detail: { surface } }); - await submit( - { name }, + (fields: CreateProjectFields, state?: RouterState) => + submit( + { + name: fields.name, + ...(fields.idea && { + code: fields.idea.code, + ideaSlug: fields.idea.slug, + }), + }, { method: "post", action: createEditorUrl(state) } - ); - }, - [logging, submit, surface] + ), + [submit] ); }; diff --git a/src/pages/resource-cards.tsx b/src/pages/resource-cards.tsx index ed94ca714..93bbc32cf 100644 --- a/src/pages/resource-cards.tsx +++ b/src/pages/resource-cards.tsx @@ -37,13 +37,12 @@ import { microbitOrgLessonUrl, microbitOrgProjectUrl, } from "../external-links"; -import { RouterState } from "../router-hooks"; import { Level } from "./LevelIndicator"; import ResourceCard from "./ResourceCard"; interface Idea { titleId: string; - /** The idea's slug in the editor's ideas tab. */ + /** The idea's slug in the CMS ideas. */ slug: string; imgSrc: string; } @@ -82,23 +81,17 @@ const ideas: Idea[] = [ }, ]; -/** - * Idea cards open the idea in a new project, so following one can't change - * the user's existing projects. - */ +/** Idea cards show the idea, by slug, for the user to open or not. */ export const createIdeaCards = ( intl: IntlShape, - createProject: (name: string, state: RouterState) => Promise + onSelect: (slug: string, title: string) => void ) => ideas.map((idea) => ( - void createProject(intl.formatMessage({ id: idea.titleId }), { - tab: "ideas", - slug: { id: idea.slug }, - }) + onSelect(idea.slug, intl.formatMessage({ id: idea.titleId })) } imgSrc={idea.imgSrc} // The ideas tab's images are 2:1; show them whole. diff --git a/src/project/projects-hooks.tsx b/src/project/projects-hooks.tsx index 11be741e9..a5d5afcc3 100644 --- a/src/project/projects-hooks.tsx +++ b/src/project/projects-hooks.tsx @@ -49,3 +49,19 @@ export const useProjectList = (): ProjectListEntry[] => { ); return useSyncExternalStore(subscribe, () => projects.projects); }; + +/** + * The idea the editor's open project was opened from, if any. Undefined in + * iframe controller mode, which has no projects. + */ +export const useOpenIdeaSlug = (): string | undefined => { + const projects = useProjectsIfAvailable(); + const subscribe = useCallback( + (listener: () => void) => { + projects?.addEventListener("open", listener); + return () => projects?.removeEventListener("open", listener); + }, + [projects] + ); + return useSyncExternalStore(subscribe, () => projects?.currentIdeaSlug); +}; diff --git a/src/project/projects.test.ts b/src/project/projects.test.ts index 9fe3d9045..85a0aa7b3 100644 --- a/src/project/projects.test.ts +++ b/src/project/projects.test.ts @@ -107,6 +107,42 @@ describe("Projects", () => { expect(fileNames(fs)).toEqual([MAIN_FILE, "helper.py"]); }); + it("knows the idea the open project came from", async () => { + const { fs, projects } = await setup(); + await projects.openCurrent(); + await fs.initialize(); + const plain = projects.currentId!; + const opened = vi.fn(); + projects.addEventListener("open", opened); + + await projects.createFromFiles( + "Dice", + { [MAIN_FILE]: new TextEncoder().encode("# dice") }, + "dice" + ); + expect(projects.currentIdeaSlug).toEqual("dice"); + expect(opened).toHaveBeenCalledTimes(1); + + await projects.open(plain); + expect(projects.currentIdeaSlug).toBeUndefined(); + expect(opened).toHaveBeenCalledTimes(2); + }); + + it("duplicates keep the idea", async () => { + const { db, fs, projects } = await setup(); + await projects.openCurrent(); + await fs.initialize(); + const idea = await projects.createFromFiles( + "Dice", + { [MAIN_FILE]: new TextEncoder().encode("# dice") }, + "dice" + ); + + const copy = await projects.duplicate(idea, "Dice copy"); + + expect((await db!.get(copy))?.ideaSlug).toEqual("dice"); + }); + it("renames the open project through the file system and others directly", async () => { const { db, fs, projects } = await setup(); await projects.openCurrent(); diff --git a/src/project/projects.ts b/src/project/projects.ts index c89c8c842..40dad662c 100644 --- a/src/project/projects.ts +++ b/src/project/projects.ts @@ -41,9 +41,17 @@ export class ProjectSaveErrorEvent extends Event { } } +/** The editor's open project changed. */ +export class ProjectOpenedEvent extends Event { + constructor() { + super("open"); + } +} + interface EventMap { change: ProjectsChangedEvent; saveerror: ProjectSaveErrorEvent; + open: ProjectOpenedEvent; } /** @@ -72,6 +80,7 @@ export class Projects extends TypedEventTarget { private readonly ready: Promise; private firstStorageResolved = false; private openId: string | undefined; + private openIdeaSlug: string | undefined; private openStorage: IndexedDBFSStorage | undefined; private opening: Promise | undefined; private cachedList: ProjectListEntry[] = []; @@ -125,6 +134,11 @@ export class Projects extends TypedEventTarget { return this.openId; } + /** The idea the open project was opened from, if any. */ + get currentIdeaSlug(): string | undefined { + return this.openIdeaSlug; + } + async refresh(): Promise { await this.ready; this.cachedList = this.db ? await this.db.listWithFileNames() : []; @@ -182,16 +196,17 @@ export class Projects extends TypedEventTarget { } /** - * Creates a project with the given files, e.g. from an imported hex, and - * opens it in the editor. + * Creates a project with the given files, e.g. from an imported hex or an + * idea, and opens it in the editor. */ async createFromFiles( name: string | undefined, - files: Record + files: Record, + ideaSlug?: string ): Promise { const db = await this.requireDb(); const id = generateId(); - await db.create({ id, name, timestamp: Date.now() }, files); + await db.create({ id, name, timestamp: Date.now(), ideaSlug }, files); await this.switchTo(id); await this.changed([id]); return id; @@ -215,7 +230,13 @@ export class Projects extends TypedEventTarget { await this.openStorage?.flush(); } const newId = generateId(); - await db.duplicate(id, { id: newId, name, timestamp: Date.now() }); + const { ideaSlug } = (await db.get(id)) ?? {}; + await db.duplicate(id, { + id: newId, + name, + timestamp: Date.now(), + ideaSlug, + }); await this.changed([newId]); return newId; } @@ -266,8 +287,10 @@ export class Projects extends TypedEventTarget { this.logging ); this.openId = id; + this.openIdeaSlug = (await db.get(id))?.ideaSlug; this.openStorage = indexed; setCurrentProjectId(this.session, id); + this.dispatchTypedEvent("open", new ProjectOpenedEvent()); if (this.firstStorageResolved) { await this.fs.switchStorage(storage); } else { @@ -282,6 +305,7 @@ export class Projects extends TypedEventTarget { */ private letGoOfOpenProject(): void { this.openId = undefined; + this.openIdeaSlug = undefined; this.openStorage = undefined; if (getCurrentProjectId(this.session)) { setCurrentProjectId(this.session, undefined); diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx index 62eb7d14f..2786580fe 100644 --- a/src/router-hooks.tsx +++ b/src/router-hooks.tsx @@ -15,9 +15,15 @@ import { useLogging } from "./logging/logging-hooks"; import { createEditorUrl, createIframeEditorUrl } from "./urls"; import { useIframeMode } from "./iframe-mode-hooks"; -export type TabName = "api" | "ideas" | "reference" | "project"; +export type TabName = "api" | "guide" | "ideas" | "reference" | "project"; -const tabNames: readonly string[] = ["api", "ideas", "reference", "project"]; +const tabNames: readonly string[] = [ + "api", + "guide", + "ideas", + "reference", + "project", +]; export const isTabName = (value: string | undefined): value is TabName => value !== undefined && tabNames.includes(value); diff --git a/src/workbench/SideBar.tsx b/src/workbench/SideBar.tsx index 335593577..04b2742a7 100644 --- a/src/workbench/SideBar.tsx +++ b/src/workbench/SideBar.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { Icon, usePrevious } from "@microbit/ui"; +import { usePrevious } from "@microbit/ui"; import { ReactNode, useCallback, @@ -14,23 +14,29 @@ import { } from "react"; import { TabList, TabPanel, TabPanels, Tabs } from "react-aria-components"; import { IconType } from "react-icons"; -import { RiHome2Line, RiLightbulbFlashLine } from "react-icons/ri"; +import { + RiBookOpenLine, + RiHome2Line, + RiLightbulbFlashLine, +} from "react-icons/ri"; import { VscFiles, VscLibrary } from "react-icons/vsc"; import { useIntl } from "react-intl"; import { Link as RouterLink } from "react-router"; import { css, cx } from "styled-system/css"; -import { Flex, styled, VStack } from "styled-system/jsx"; +import { Box, Flex, styled, VStack } from "styled-system/jsx"; import { button } from "styled-system/recipes"; import { SystemStyleObject } from "styled-system/types"; import ErrorBoundary from "../common/ErrorBoundary"; import PythonLogo from "../common/PythonLogo"; import ApiArea from "../documentation/ApiArea"; +import GuideArea from "../documentation/GuideArea"; import IdeasArea from "../documentation/IdeasArea"; import ReferenceArea from "../documentation/ReferenceArea"; import { flags } from "../flags"; import { useProjectsDatabaseActive } from "../fs/storage-status"; import { useIframeMode } from "../iframe-mode-hooks"; import ProjectArea from "../project/ProjectArea"; +import { useOpenIdeaSlug } from "../project/projects-hooks"; import { TabName, useRouterState } from "../router-hooks"; import SettingsMenu from "../settings/SettingsMenu"; import { createHomePageUrl } from "../urls"; @@ -48,7 +54,6 @@ export interface Pane { title: string; contents: ReactNode; color: string; - mb?: string; } interface SideBarProps { @@ -81,8 +86,22 @@ const SideBar = ({ }: SideBarProps) => { const intl = useIntl(); const [releaseDialog, setReleaseDialog] = useReleaseDialogState(); + // Projects opened from an idea on the home page have its guide instead of + // the list of ideas. + const hasGuide = useOpenIdeaSlug() !== undefined; const panes: Pane[] = useMemo(() => { - const result = [ + const result: Pane[] = [ + ...(hasGuide + ? [ + { + id: "guide" as const, + title: intl.formatMessage({ id: "guide-tab" }), + icon: RiBookOpenLine, + contents: , + color: "gray.75", + }, + ] + : []), { id: "reference" as const, title: intl.formatMessage({ id: "reference-tab" }), @@ -90,20 +109,23 @@ const SideBar = ({ contents: , color: "gray.75", }, - { - id: "ideas" as const, - title: intl.formatMessage({ id: "ideas-tab" }), - icon: RiLightbulbFlashLine, - contents: , - color: "gray.75", - }, + ...(hasGuide + ? [] + : [ + { + id: "ideas" as const, + title: intl.formatMessage({ id: "ideas-tab" }), + icon: RiLightbulbFlashLine, + contents: , + color: "gray.75", + }, + ]), { id: "api" as const, title: intl.formatMessage({ id: "api-tab" }), icon: PythonLogo as IconType, contents: , color: "gray.75", - mb: "auto", }, { id: "project" as const, @@ -119,7 +141,7 @@ const SideBar = ({ }, ]; return result; - }, [onSelectedFileChanged, selectedFile, intl]); + }, [hasGuide, onSelectedFileChanged, selectedFile, intl]); const [{ tab, slug, focus }, setParams] = useRouterState(); const tabPanelsRef = useRef(null); const setPanelFocus = () => { @@ -240,20 +262,9 @@ const SideBar = ({ branded background (a wrapper because react-aria's TabList may only contain tabs). */} - - - + + @@ -326,8 +338,8 @@ const SideBar = ({ }; /** - * Home, above the tabs and apart from them. Only where there is a home page, - * as for the header logo. + * Home, above the settings and help menus and styled as they are. Only where + * there is a home page, as for the header logo. */ const SideBarHomeLink = () => { const intl = useIntl(); @@ -342,11 +354,11 @@ const SideBarHomeLink = () => { aria-label={intl.formatMessage({ id: "home-action" })} className={cx( button({ variant: "sidebar", size: "lg" }), - css({ px: "0" }) + // As IconButton (px) and the menus' fontSize. + css({ px: "0", fontSize: "xl" }) )} > - {/* Sized as the tab icons rather than the menus below. */} - + ); }; diff --git a/src/workbench/SideBarTab.tsx b/src/workbench/SideBarTab.tsx index 1029d912f..ce42e71cf 100644 --- a/src/workbench/SideBarTab.tsx +++ b/src/workbench/SideBarTab.tsx @@ -12,7 +12,6 @@ import { cornerSize, Pane } from "./SideBar"; interface SideBarTabProps extends Pane { color: string; - mb?: string; handleTabClick: (id: Pane["id"]) => void; active: boolean; tabIndex: number; @@ -23,7 +22,6 @@ const SideBarTab = ({ icon, title, color, - mb, handleTabClick, active, }: SideBarTabProps) => { @@ -51,7 +49,6 @@ const SideBarTab = ({ p: "0", position: "relative", ml: "6px", - mb: mb ? "auto" : "0", borderRadius: "32px 0 0 32px", transition: "none", cursor: "pointer",