chore(sidebar): add common content and clarify navigation

This commit is contained in:
johnnyjoygh 2026-09-01 21:24:56 +08:00
parent de843f658a
commit ee3a0a2bba
7 changed files with 189 additions and 34 deletions

View file

@ -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 <InboxSidebarContent />;
if (kind === "settings") return <SettingsSidebarContent />;
if (kind === "memo") return <MemoDetailSidebarContent />;
if (kind === "common") return <CommonSidebarContent />;
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 = (
<DropdownMenuContent align="start" sideOffset={4} className="flex w-36 flex-col gap-0.5">
{scopeItems.map((item) => {
@ -437,7 +444,7 @@ const GlobalNavigation = () => {
}
}}
>
<Tooltip disabled={scopeRouteActive}>
<Tooltip disabled={scopeExpanded}>
{/* 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 = () => {
}
>
<ActiveScopeIcon className="size-4 shrink-0" strokeWidth={1.8} />
<NavPillLabel expanded={scopeRouteActive} label={activeScopeItem.label}>
<ChevronDownIcon
className="-mr-0.5 size-3 shrink-0 opacity-55 transition-transform duration-200 ease-out group-data-[popup-open]/scope:rotate-180 motion-reduce:transition-none"
strokeWidth={1.8}
/>
<NavPillLabel expanded={scopeExpanded} label={activeScopeItem.label}>
{scopeRouteActive && (
<ChevronDownIcon
className="-mr-0.5 size-3 shrink-0 opacity-55 transition-transform duration-200 ease-out group-data-[popup-open]/scope:rotate-180 motion-reduce:transition-none"
strokeWidth={1.8}
/>
)}
</NavPillLabel>
</DropdownMenuTrigger>
</TooltipTrigger>
@ -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 (
<Tooltip key={item.id} disabled={expanded}>
<TooltipTrigger

View file

@ -0,0 +1,71 @@
import { BookOpenIcon, BracesIcon, ExternalLinkIcon, GitForkIcon, InfoIcon, type LucideIcon } from "lucide-react";
import { Link, matchPath, useLocation } from "react-router-dom";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { MEMOS_API_DOCUMENTATION_URL, MEMOS_DOCUMENTATION_URL, MEMOS_GITHUB_URL } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
import { SIDEBAR_ROW_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
interface ResourceLink {
labelKey: "about.documents" | "about.api-docs" | "about.github-repository";
href: string;
icon: LucideIcon;
}
const RESOURCE_LINKS: ResourceLink[] = [
{ labelKey: "about.documents", href: MEMOS_DOCUMENTATION_URL, icon: BookOpenIcon },
{ labelKey: "about.api-docs", href: MEMOS_API_DOCUMENTATION_URL, icon: BracesIcon },
{ labelKey: "about.github-repository", href: MEMOS_GITHUB_URL, icon: GitForkIcon },
];
const CommonSidebarContent = () => {
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
const { setMobileOpen } = useAppSidebar();
const aboutActive = Boolean(matchPath(ROUTES.ABOUT, location.pathname));
return (
<div className={SIDEBAR_SECTION_STACK_CLASSES}>
{currentUser && (
<SidebarSection ariaLabel={t("common.about")}>
<Link
to={ROUTES.ABOUT}
aria-current={aboutActive ? "page" : undefined}
onClick={() => setMobileOpen(false)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(aboutActive))}
>
<SidebarRowIconSlot icon={InfoIcon} />
<span className="min-w-0 flex-1 truncate">{t("common.about")}</span>
</Link>
</SidebarSection>
)}
<SidebarSection label={t("common.resources")}>
{RESOURCE_LINKS.map((resource) => (
<a
key={resource.href}
href={resource.href}
target="_blank"
rel="noreferrer"
onClick={() => setMobileOpen(false)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses())}
>
<SidebarRowIconSlot icon={resource.icon} />
<span className="min-w-0 flex-1 truncate">{t(resource.labelKey)}</span>
<ExternalLinkIcon
aria-hidden="true"
className="size-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-foreground/70"
strokeWidth={1.8}
/>
</a>
))}
</SidebarSection>
</div>
);
};
export default CommonSidebarContent;

View file

@ -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. */

View file

@ -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";

View file

@ -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 },
];

View file

@ -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(
<MemoryRouter initialEntries={["/404"]}>
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={[path]}>
<AppSidebar />
</MemoryRouter>,
);
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", () => {
</MemoryRouter>,
);
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", () => {
</MemoryRouter>,
);
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");

View file

@ -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);
});