From ee3a0a2bba44e15e4cadf9112d8c103901fba3df Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Tue, 1 Sep 2026 21:24:56 +0800 Subject: [PATCH] chore(sidebar): add common content and clarify navigation --- web/src/components/AppSidebar/AppSidebar.tsx | 27 ++++-- .../AppSidebar/CommonSidebarContent.tsx | 71 ++++++++++++++ web/src/components/AppSidebar/routes.ts | 4 +- web/src/lib/constants.ts | 5 + web/src/pages/About.tsx | 16 +++- web/tests/app-sidebar-logo.test.tsx | 94 +++++++++++++++---- web/tests/app-sidebar-routes.test.ts | 6 +- 7 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 web/src/components/AppSidebar/CommonSidebarContent.tsx diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 90c2cc4f..b31d964f 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -49,6 +49,7 @@ import { State } from "@/types/proto/api/v1/common_pb"; import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "../MemosLogo"; +import CommonSidebarContent from "./CommonSidebarContent"; import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes"; import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection"; @@ -289,6 +290,7 @@ const RouteSidebarContent = () => { if (kind === "inbox") return ; if (kind === "settings") return ; if (kind === "memo") return ; + if (kind === "common") return ; return null; }; @@ -299,7 +301,6 @@ interface GlobalNavItem { icon: LucideIcon; active: boolean; count?: number; - alwaysExpanded?: boolean; } /** @@ -390,7 +391,6 @@ const GlobalNavigation = () => { path: ROUTES.EXPLORE, icon: EarthIcon, active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo", - alwaysExpanded: true, }, { id: "about", @@ -401,6 +401,13 @@ const GlobalNavigation = () => { }, ]; + // Keep one textual anchor in the compact navigator. A real active destination + // takes precedence; routes outside this navigation default to its first item + // without presenting that fallback as the current page. + const activeNavigatorItemId = currentUser && scopeRouteActive ? "scope" : items.find((item) => item.active)?.id; + const expandedNavigatorItemId = activeNavigatorItemId ?? (currentUser ? "scope" : items[0]?.id); + const scopeExpanded = expandedNavigatorItemId === "scope"; + const scopeMenuContent = ( {scopeItems.map((item) => { @@ -437,7 +444,7 @@ const GlobalNavigation = () => { } }} > - + {/* The tooltip anchors to a wrapper span rather than the button: a disabled tooltip stamps data-trigger-disabled on its trigger element, and Base UI's shared floating logic would read that as the MENU trigger being disabled. */} @@ -453,11 +460,13 @@ const GlobalNavigation = () => { } > - - + + {scopeRouteActive && ( + + )} @@ -468,7 +477,7 @@ const GlobalNavigation = () => { )} {items.map((item) => { const Icon = item.icon; - const expanded = item.active || !!item.alwaysExpanded; + const expanded = item.id === expandedNavigatorItemId; return ( { + const t = useTranslate(); + const location = useLocation(); + const currentUser = useCurrentUser(); + const { setMobileOpen } = useAppSidebar(); + const aboutActive = Boolean(matchPath(ROUTES.ABOUT, location.pathname)); + + return ( +
+ {currentUser && ( + + setMobileOpen(false)} + className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(aboutActive))} + > + + {t("common.about")} + + + )} + + + {RESOURCE_LINKS.map((resource) => ( + setMobileOpen(false)} + className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses())} + > + + {t(resource.labelKey)} + + ))} + +
+ ); +}; + +export default CommonSidebarContent; diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts index 40b3a147..5dfb90af 100644 --- a/web/src/components/AppSidebar/routes.ts +++ b/web/src/components/AppSidebar/routes.ts @@ -2,7 +2,7 @@ import { matchPath } from "react-router-dom"; import { isMemoScopeRoute, type MemoScope, resolveMemoScope } from "@/lib/memo-views"; import { ROUTES } from "@/router/routes"; -export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "empty"; +export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "common"; export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all"; export type RouteComposePlacement = "remembered-space" | "unassigned"; @@ -23,7 +23,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => { if (matchPath(ROUTES.INBOX, normalizedPath)) return "inbox"; if (matchPath(ROUTES.SETTING, normalizedPath)) return "settings"; if (matchPath("/memos/:uid", normalizedPath) || matchPath(`${ROUTES.SHARED_MEMO}/:token`, normalizedPath)) return "memo"; - return "empty"; + return "common"; }; /** Routes whose collections are filtered by the remembered All / Space scope. */ diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 528f4bc9..fb2feff9 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -8,5 +8,10 @@ export const DEFAULT_LIST_MEMOS_PAGE_SIZE = 16; // Loads that finish faster than this never render the spinner, avoiding a flash on fast/self-hosted networks. export const LOADING_INDICATOR_DELAY_MS = 250; +export const MEMOS_WEBSITE_URL = "https://usememos.com/"; +export const MEMOS_DOCUMENTATION_URL = "https://usememos.com/docs"; +export const MEMOS_API_DOCUMENTATION_URL = "https://usememos.com/docs/api"; +export const MEMOS_GITHUB_URL = "https://github.com/usememos/memos"; + // Official companion browser extension for saving web content to Memos. export const WEB_CLIPPER_URL = "https://github.com/usememos/web-clipper"; diff --git a/web/src/pages/About.tsx b/web/src/pages/About.tsx index 6737dc15..22db304b 100644 --- a/web/src/pages/About.tsx +++ b/web/src/pages/About.tsx @@ -1,7 +1,13 @@ import { ExternalLinkIcon, ScissorsIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useInstance } from "@/contexts/InstanceContext"; -import { WEB_CLIPPER_URL } from "@/lib/constants"; +import { + MEMOS_API_DOCUMENTATION_URL, + MEMOS_DOCUMENTATION_URL, + MEMOS_GITHUB_URL, + MEMOS_WEBSITE_URL, + WEB_CLIPPER_URL, +} from "@/lib/constants"; import { useTranslate } from "@/utils/i18n"; const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/"; @@ -63,13 +69,13 @@ const About = () => { } const projectLinks = [ - { label: t("about.official-website"), note: t("about.official-website-note"), href: "https://usememos.com/" }, - { label: t("about.documents"), note: t("about.documents-note"), href: "https://usememos.com/docs" }, - { label: t("about.api-docs"), note: t("about.api-docs-note"), href: "https://usememos.com/docs/api" }, + { label: t("about.official-website"), note: t("about.official-website-note"), href: MEMOS_WEBSITE_URL }, + { label: t("about.documents"), note: t("about.documents-note"), href: MEMOS_DOCUMENTATION_URL }, + { label: t("about.api-docs"), note: t("about.api-docs-note"), href: MEMOS_API_DOCUMENTATION_URL }, { label: t("about.github-repository"), note: t("about.github-repository-note"), - href: "https://github.com/usememos/memos", + href: MEMOS_GITHUB_URL, }, { label: t("about.web-clipper"), note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon }, ]; diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index f330f674..6c3809c3 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, screen, render as testingLibraryRender } from "@testing-library/react"; +import { fireEvent, screen, render as testingLibraryRender, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar"; @@ -13,6 +13,7 @@ const authState = vi.hoisted(() => ({ const sidebarState = vi.hoisted(() => ({ memoScope: "home" as "home" | "explore", mobileOpen: false, + setMobileOpen: vi.fn(), })); const globalEditorState = vi.hoisted(() => ({ canOpen: true, @@ -70,7 +71,7 @@ vi.mock("@/contexts/AppSidebarContext", () => ({ memoDetail: undefined, setMemoDetail: vi.fn(), mobileOpen: sidebarState.mobileOpen, - setMobileOpen: vi.fn(), + setMobileOpen: sidebarState.setMobileOpen, quickFindOpen: false, setQuickFindOpen: vi.fn(), memoScope: sidebarState.memoScope, @@ -163,12 +164,24 @@ const expectCollapsedNavPill = (pill: HTMLElement, label: string) => { expect(labelTrack).toHaveTextContent(label); }; -const expectActiveNavPill = (pill: HTMLElement, label: string) => { - expect(pill).toHaveAttribute("aria-current", "page"); - expect(pill.querySelector('span[aria-hidden="true"]')).toBeNull(); +const expectExpandedNavPill = (pill: HTMLElement, label: string) => { + expect(pill).toHaveClass("h-[30px]", "px-[7px]"); + const labelTrack = pill.querySelector("span.grid"); + expect(labelTrack).toHaveClass("grid-cols-[1fr]", "pl-2"); + expect(labelTrack).not.toHaveAttribute("aria-hidden"); expect(pill).toHaveTextContent(label); }; +const expectActiveNavPill = (pill: HTMLElement, label: string) => { + expectExpandedNavPill(pill, label); + expect(pill).toHaveAttribute("aria-current", "page"); +}; + +const expectDefaultNavPill = (pill: HTMLElement, label: string) => { + expectExpandedNavPill(pill, label); + expect(pill).not.toHaveAttribute("aria-current"); +}; + describe("App sidebar logo", () => { beforeEach(() => { authState.currentUser = { name: "users/test" }; @@ -176,6 +189,7 @@ describe("App sidebar logo", () => { authState.notifications = []; sidebarState.memoScope = "home"; sidebarState.mobileOpen = false; + sidebarState.setMobileOpen.mockClear(); globalEditorState.canOpen = true; globalEditorState.openEditor.mockClear(); spaceState.spaces = []; @@ -362,32 +376,66 @@ describe("App sidebar logo", () => { expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/explore"); expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about"); + const navigation = within(screen.getByRole("navigation", { name: "Primary" })); + expectActiveNavPill(navigation.getByRole("link", { name: "common.explore" }), "common.explore"); + const about = navigation.getByRole("link", { name: "common.about" }); + expect(about).toHaveAttribute("href", "/about"); + expectCollapsedNavPill(about, "common.about"); expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull(); expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); }); - it("does not inherit collection content on a route without a collection", () => { + it.each(["/403", "/404", "/unknown"])("uses the common sidebar without inheriting collection content on %s", (path) => { render( - + , ); + expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about"); + expect(screen.getByRole("link", { name: "common.about" })).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("heading", { name: "common.resources", level: 2 })).toBeInTheDocument(); + const documentationLink = screen.getByRole("link", { name: "about.documents" }); + expect(documentationLink).toHaveAttribute("href", "https://usememos.com/docs"); + expect(documentationLink).toHaveAttribute("target", "_blank"); + expect(documentationLink).toHaveAttribute("rel", "noreferrer"); + fireEvent.click(documentationLink); + expect(sidebarState.setMobileOpen).toHaveBeenCalledWith(false); + expect(screen.getByRole("link", { name: "about.api-docs" })).toHaveAttribute("href", "https://usememos.com/docs/api"); + expect(screen.getByRole("link", { name: "about.github-repository" })).toHaveAttribute("href", "https://github.com/usememos/memos"); expect(screen.queryByText("Calendar")).not.toBeInTheDocument(); expect(screen.queryByRole("region", { name: "common.statistics" })).not.toBeInTheDocument(); expect(screen.queryByText("common.views")).not.toBeInTheDocument(); expect(screen.queryByText("Tags")).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/"); - expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments"); + const navigation = within(screen.getByRole("navigation", { name: "Primary" })); + expectDefaultNavPill(navigation.getByRole("button", { name: "common.home" }), "common.home"); + const attachments = navigation.getByRole("link", { name: "common.attachments" }); + expect(attachments).toHaveAttribute("href", "/attachments"); + expectCollapsedNavPill(attachments, "common.attachments"); expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument(); expect(screen.getByText("User menu").closest("footer")).not.toBeNull(); }); + it.each(["/about", "/About/"])("marks the common About link active on %s", (path) => { + render( + + + , + ); + + expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("heading", { name: "common.resources", level: 2 })).toBeInTheDocument(); + + const navigation = within(screen.getByRole("navigation", { name: "Primary" })); + const scopeTrigger = navigation.getByRole("button", { name: "common.home" }); + expectDefaultNavPill(scopeTrigger, "common.home"); + expect(scopeTrigger.querySelector(".lucide-chevron-down")).not.toBeInTheDocument(); + expectCollapsedNavPill(navigation.getByRole("link", { name: "common.attachments" }), "common.attachments"); + }); + it("uses a visitor sidebar for a guest on a route without contextual content", () => { authState.currentUser = undefined; render( @@ -396,11 +444,16 @@ describe("App sidebar logo", () => { , ); - expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/explore"); + const navigation = within(screen.getByRole("navigation", { name: "Primary" })); + const explore = navigation.getByRole("link", { name: "common.explore" }); + expect(explore).toHaveAttribute("href", "/explore"); + expectDefaultNavPill(explore, "common.explore"); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.attachments" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.inbox" })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about"); + const about = navigation.getByRole("link", { name: "common.about" }); + expect(about).toHaveAttribute("href", "/about"); + expectCollapsedNavPill(about, "common.about"); expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull(); }); @@ -412,7 +465,9 @@ describe("App sidebar logo", () => { , ); - expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page"); + const navigation = within(screen.getByRole("navigation", { name: "Primary" })); + expectCollapsedNavPill(navigation.getByRole("link", { name: "common.explore" }), "common.explore"); + expectActiveNavPill(navigation.getByRole("link", { name: "common.about" }), "common.about"); expect(screen.queryByText("Calendar")).not.toBeInTheDocument(); }); @@ -439,6 +494,9 @@ describe("App sidebar logo", () => { expect(screen.queryByRole("button", { name: "common.all" })).not.toBeInTheDocument(); const scopeTrigger = screen.getByRole("button", { name: "common.home" }); + expectActiveNavPill(scopeTrigger, "common.home"); + expect(scopeTrigger.querySelector(".lucide-chevron-down")).toBeInTheDocument(); + expectCollapsedNavPill(screen.getByRole("link", { name: "common.attachments" }), "common.attachments"); fireEvent.click(scopeTrigger); expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "common.explore" })).toBeInTheDocument(); @@ -511,7 +569,9 @@ describe("App sidebar logo", () => { ); const scopeTrigger = screen.getByRole("button", { name: "common.explore" }); - expectCollapsedNavPill(scopeTrigger, "common.explore"); + expectDefaultNavPill(scopeTrigger, "common.explore"); + expect(scopeTrigger.querySelector(".lucide-chevron-down")).not.toBeInTheDocument(); + expectCollapsedNavPill(screen.getByRole("link", { name: "common.attachments" }), "common.attachments"); fireEvent.click(scopeTrigger); expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore"); @@ -526,7 +586,9 @@ describe("App sidebar logo", () => { ); const scopeTrigger = screen.getByRole("button", { name: "common.explore" }); - expectCollapsedNavPill(scopeTrigger, "common.explore"); + expectDefaultNavPill(scopeTrigger, "common.explore"); + expect(scopeTrigger.querySelector(".lucide-chevron-down")).not.toBeInTheDocument(); + expectCollapsedNavPill(screen.getByRole("link", { name: "common.attachments" }), "common.attachments"); fireEvent.click(scopeTrigger); expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore"); diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts index 5d75df9e..94edac3d 100644 --- a/web/tests/app-sidebar-routes.test.ts +++ b/web/tests/app-sidebar-routes.test.ts @@ -22,8 +22,10 @@ describe("sidebar route content", () => { ["/Memos/ABC/", "memo"], ["/memos/shares/token", "memo"], ["/Memos/Shares/token/", "memo"], - ["/about", "empty"], - ["/404", "empty"], + ["/about", "common"], + ["/403", "common"], + ["/404", "common"], + ["/unknown", "common"], ])("maps %s to %s content", (path, kind) => { expect(getSidebarRouteKind(path)).toBe(kind); });