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 (
+
+ );
+};
+
+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);
});