chore(web): add static navigation for default routes

- Show auth-aware common destinations when a route has no contextual sidebar.
- Restore About as a standalone page and route all About entries to it.
This commit is contained in:
johnnyjoygh 2026-08-05 21:46:34 +08:00
parent 83eab44695
commit d14a542002
8 changed files with 275 additions and 275 deletions

View file

@ -1,140 +0,0 @@
import { ExternalLinkIcon, ScissorsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useInstance } from "@/contexts/InstanceContext";
import { WEB_CLIPPER_URL } from "@/lib/constants";
import { useTranslate } from "@/utils/i18n";
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
const GITHUB_RELEASE_URL_PREFIX = "https://github.com/usememos/memos/releases/tag/v";
const DEFAULT_TITLE = "Memos";
const DEFAULT_TAGLINE = "Capture first. Keep it yours.";
const DEFAULT_LOGO = "/logo.webp";
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
const isSemver = (version: string) => /^\d+\.\d+\.\d+/.test(version);
const Chip = ({ href, children }: { href?: string; children: React.ReactNode }) => {
const className = "inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 font-mono text-xs text-muted-foreground";
if (href) {
return (
<a className={`${className} hover:bg-accent hover:text-foreground`} href={href} target="_blank" rel="noreferrer">
{children}
</a>
);
}
return <span className={className}>{children}</span>;
};
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-[10px] font-semibold uppercase tracking-[0.09em] text-muted-foreground/55">{children}</h3>
);
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const AboutDialog = ({ open, onOpenChange }: Props) => {
const t = useTranslate();
const { profile, generalSetting } = useInstance();
const customProfile = generalSetting.customProfile;
const instanceTitle = customProfile?.title || DEFAULT_TITLE;
const instanceTagline = customProfile?.description || DEFAULT_TAGLINE;
const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO;
const isCustomBranded = instanceTitle !== DEFAULT_TITLE;
const hasSemver = isSemver(profile.version);
const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : "";
const versionLabel = hasSemver ? `v${profile.version}` : profile.version;
const hasCommitSha = isCommitSha(profile.commit);
const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : "";
const buildRows: { label: string; value: React.ReactNode }[] = [];
if (profile.version) {
buildRows.push({ label: t("common.version"), value: <Chip href={releaseUrl || undefined}>{versionLabel}</Chip> });
}
if (shortCommit) {
buildRows.push({ label: t("about.commit"), value: <Chip href={commitUrl}>{shortCommit}</Chip> });
}
buildRows.push({ label: t("about.license"), value: <Chip href="https://github.com/usememos/memos/blob/main/LICENSE">MIT</Chip> });
if (isCustomBranded) {
buildRows.push({
label: t("about.distribution"),
value: <span className="text-[13px] text-muted-foreground">{t("about.powered-by")}</span>,
});
}
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.github-repository"),
note: t("about.github-repository-note"),
href: "https://github.com/usememos/memos",
},
{ label: t("about.web-clipper"), note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon },
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="xl" className="overflow-hidden border-border/70 p-0! shadow-xl">
<DialogTitle className="sr-only">{t("common.about")}</DialogTitle>
<div className="px-5 pb-5 pt-5 sm:px-6 sm:pb-6 sm:pt-6">
<header className="flex items-start gap-3 pr-8">
<img className="size-10 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
<div className="min-w-0 pt-0.5">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-[16px] font-semibold tracking-[-0.01em] text-foreground">{instanceTitle}</h2>
{profile.demo && <Badge variant="warning">{t("about.demo")}</Badge>}
</div>
<p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{instanceTagline}</p>
</div>
</header>
<section className="mt-6">
<SectionLabel>{t("about.build")}</SectionLabel>
<dl className="mt-2 border-t border-border/70">
{buildRows.map((row) => (
<div key={row.label} className="grid grid-cols-[96px_1fr] items-center border-b border-border/60 py-2">
<dt className="text-[12px] text-muted-foreground">{row.label}</dt>
<dd className="m-0 flex min-w-0 items-center">{row.value}</dd>
</div>
))}
</dl>
</section>
<section className="mt-6">
<SectionLabel>{t("about.project")}</SectionLabel>
<nav aria-label={t("about.project-links")} className="mt-2 border-t border-border/70">
{projectLinks.map((link) => (
<a
key={link.href}
className="group flex items-center justify-between gap-4 border-b border-border/60 py-2.5"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="flex min-w-0 items-center gap-2">
{link.icon && <link.icon className="size-3.5 shrink-0 text-muted-foreground" />}
<span className="truncate text-[13px] font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
{link.label}
</span>
<span className="hidden truncate text-xs text-muted-foreground sm:inline">{link.note}</span>
</span>
<ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</a>
))}
</nav>
</section>
</div>
</DialogContent>
</Dialog>
);
};
export default AboutDialog;

View file

@ -61,7 +61,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MemosLogo from "../MemosLogo"; import MemosLogo from "../MemosLogo";
import { getSidebarRouteKind } from "./routes"; import { getSidebarRouteKind } from "./routes";
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "./SidebarRow"; import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "./SidebarRow";
import SidebarSectionHeader from "./SidebarSectionHeader"; import SidebarSectionHeader from "./SidebarSectionHeader";
import TagsSection from "./TagsSection"; import TagsSection from "./TagsSection";
@ -395,7 +395,6 @@ interface GlobalNavItem {
icon: LucideIcon; icon: LucideIcon;
active: boolean; active: boolean;
count?: number; count?: number;
opensDialog?: boolean;
} }
const GlobalNavigation = () => { const GlobalNavigation = () => {
@ -404,7 +403,7 @@ const GlobalNavigation = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { data: notifications = [] } = useNotifications(); const { data: notifications = [] } = useNotifications();
const { memoDetail, memoScope, setAboutOpen, setMemoScope, setMobileOpen } = useAppSidebar(); const { memoDetail, memoScope, setMemoScope, setMobileOpen } = useAppSidebar();
const { filters } = useMemoFilterContext(); const { filters } = useMemoFilterContext();
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length; const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
const routeKind = getSidebarRouteKind(location.pathname); const routeKind = getSidebarRouteKind(location.pathname);
@ -464,7 +463,7 @@ const GlobalNavigation = () => {
icon: EarthIcon, icon: EarthIcon,
active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo", active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo",
}, },
{ id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: false, opensDialog: true }, { id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: false },
]; ];
const scopeTrigger = ( const scopeTrigger = (
@ -556,20 +555,7 @@ const GlobalNavigation = () => {
)} )}
</> </>
); );
const content = item.opensDialog ? ( const content = (
<button
key={item.id}
type="button"
aria-label={item.label}
className={itemClassName}
onClick={() => {
setMobileOpen(false);
setAboutOpen(true);
}}
>
{itemContent}
</button>
) : (
<Link <Link
key={item.id} key={item.id}
to={item.path} to={item.path}
@ -594,10 +580,80 @@ const GlobalNavigation = () => {
); );
}; };
const StaticNavigation = () => {
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
const { data: notifications = [] } = useNotifications();
const { setMobileOpen } = useAppSidebar();
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
const primaryItems: Array<{ id: string; label: string; path: string; icon: LucideIcon }> = currentUser
? [
{ id: "home", label: t("common.home"), path: ROUTES.HOME, icon: HouseIcon },
{ id: "explore", label: t("common.explore"), path: ROUTES.EXPLORE, icon: EarthIcon },
]
: [{ id: "explore", label: t("common.explore"), path: ROUTES.EXPLORE, icon: EarthIcon }];
const personalItems: Array<{ id: string; label: string; path: string; icon: LucideIcon; count?: number }> = currentUser
? [
{ id: "attachments", label: t("common.attachments"), path: ROUTES.ATTACHMENTS, icon: PaperclipIcon },
{ id: "inbox", label: t("common.inbox"), path: ROUTES.INBOX, icon: BellIcon, count: unreadCount },
]
: [];
const renderLink = (item: (typeof primaryItems)[number] | (typeof personalItems)[number]) => {
const Icon = item.icon;
const count = "count" in item ? item.count : undefined;
return (
<Link
key={item.id}
to={item.path}
onClick={() => setMobileOpen(false)}
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
>
<Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{count}</span>}
</Link>
);
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<nav className={cn("pt-1", SIDEBAR_HORIZONTAL_PADDING)} aria-label="Primary">
<div className="space-y-0.5">{primaryItems.map(renderLink)}</div>
{personalItems.length > 0 && (
<>
<div className="mx-2 my-2 border-t border-border/70" />
<div className="space-y-0.5">{personalItems.map(renderLink)}</div>
</>
)}
</nav>
<nav className={cn("mt-auto pb-2", SIDEBAR_HORIZONTAL_PADDING)} aria-label="Utility">
<Link
to={ROUTES.ABOUT}
onClick={() => setMobileOpen(false)}
aria-current={location.pathname === ROUTES.ABOUT ? "page" : undefined}
className={cn(
SIDEBAR_ROW_CLASSES,
location.pathname === ROUTES.ABOUT
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground",
)}
>
<InfoIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate">{t("common.about")}</span>
</Link>
</nav>
</div>
);
};
const AppSidebar = ({ className }: { className?: string }) => { const AppSidebar = ({ className }: { className?: string }) => {
const t = useTranslate(); const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { setMobileOpen, setQuickFindOpen } = useAppSidebar(); const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
const staticMode = getSidebarRouteKind(location.pathname) === "empty";
return ( return (
<aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}> <aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}>
<div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}> <div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}>
@ -620,14 +676,22 @@ const AppSidebar = ({ className }: { className?: string }) => {
<SearchIcon className="size-4" strokeWidth={1.8} /> <SearchIcon className="size-4" strokeWidth={1.8} />
</Button> </Button>
</div> </div>
{staticMode ? (
<StaticNavigation />
) : (
<>
<GlobalNavigation /> <GlobalNavigation />
<div className="mx-3 mt-2 border-t border-border/70" /> <div className="mx-3 mt-2 border-t border-border/70" />
<div className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_HORIZONTAL_PADDING)}> <div
className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_HORIZONTAL_PADDING)}
>
<RouteSidebarContent /> <RouteSidebarContent />
</div> </div>
</>
)}
<footer className="shrink-0 border-t border-border/70"> <footer className="shrink-0 border-t border-border/70">
{currentUser ? ( {currentUser ? (
<UserMenu /> <UserMenu showAbout={!staticMode} />
) : ( ) : (
<Link <Link
to={ROUTES.AUTH} to={ROUTES.AUTH}

View file

@ -34,13 +34,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface Props { interface Props {
collapsed?: boolean; collapsed?: boolean;
showAbout?: boolean;
} }
const UserMenu = (props: Props) => { const UserMenu = (props: Props) => {
const { collapsed } = props; const { collapsed, showAbout = true } = props;
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { setAboutOpen, setMobileOpen } = useAppSidebar(); const { setMobileOpen } = useAppSidebar();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { userGeneralSetting, refetchSettings, logout } = useAuth(); const { userGeneralSetting, refetchSettings, logout } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name); const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
@ -109,11 +110,6 @@ const UserMenu = (props: Props) => {
navigateTo(path); navigateTo(path);
}; };
const handleAboutOpen = () => {
setMobileOpen(false);
setAboutOpen(true);
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@ -183,10 +179,12 @@ const UserMenu = (props: Props) => {
))} ))}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuItem onClick={handleAboutOpen}> {showAbout && (
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.ABOUT)}>
<InfoIcon className="size-4 text-muted-foreground" /> <InfoIcon className="size-4 text-muted-foreground" />
{t("common.about")} {t("common.about")}
</DropdownMenuItem> </DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.SETTING)}> <DropdownMenuItem onClick={() => navigateFromMenu(Routes.SETTING)}>
<SettingsIcon className="size-4 text-muted-foreground" /> <SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")} {t("common.settings")}

View file

@ -14,8 +14,6 @@ export interface MemoDetailSidebarDescriptor {
} }
interface AppSidebarContextValue { interface AppSidebarContextValue {
aboutOpen: boolean;
setAboutOpen: (open: boolean) => void;
attachmentSection: AttachmentSection; attachmentSection: AttachmentSection;
setAttachmentSection: (section: AttachmentSection) => void; setAttachmentSection: (section: AttachmentSection) => void;
inboxFilter: InboxFilter; inboxFilter: InboxFilter;
@ -34,7 +32,6 @@ const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);
export const AppSidebarProvider = ({ children }: { children: ReactNode }) => { export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
const location = useLocation(); const location = useLocation();
const [aboutOpen, setAboutOpen] = useState(false);
const [attachmentSection, setAttachmentSection] = useState<AttachmentSection>("all"); const [attachmentSection, setAttachmentSection] = useState<AttachmentSection>("all");
const [inboxFilter, setInboxFilter] = useState<InboxFilter>("all"); const [inboxFilter, setInboxFilter] = useState<InboxFilter>("all");
const [memoDetail, setMemoDetailState] = useState<MemoDetailSidebarDescriptor>(); const [memoDetail, setMemoDetailState] = useState<MemoDetailSidebarDescriptor>();
@ -54,8 +51,6 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
const value = useMemo( const value = useMemo(
() => ({ () => ({
aboutOpen,
setAboutOpen,
attachmentSection, attachmentSection,
setAttachmentSection, setAttachmentSection,
inboxFilter, inboxFilter,
@ -69,7 +64,7 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
memoScope, memoScope,
setMemoScope, setMemoScope,
}), }),
[aboutOpen, attachmentSection, inboxFilter, memoDetail, setMemoDetail, mobileOpen, quickFindOpen, memoScope], [attachmentSection, inboxFilter, memoDetail, setMemoDetail, mobileOpen, quickFindOpen, memoScope],
); );
return <AppSidebarContext.Provider value={value}>{children}</AppSidebarContext.Provider>; return <AppSidebarContext.Provider value={value}>{children}</AppSidebarContext.Provider>;

View file

@ -1,8 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom"; import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
import AboutDialog from "@/components/AboutDialog";
import AppSidebar, { MobileAppHeader, MobileAppSidebar, QuickFindDialog } from "@/components/AppSidebar"; import AppSidebar, { MobileAppHeader, MobileAppSidebar, QuickFindDialog } from "@/components/AppSidebar";
import { AppSidebarProvider, useAppSidebar } from "@/contexts/AppSidebarContext"; import { AppSidebarProvider } from "@/contexts/AppSidebarContext";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
@ -28,16 +27,6 @@ const DemoBanner = () => {
); );
}; };
const AppDialogs = () => {
const { aboutOpen, setAboutOpen } = useAppSidebar();
return (
<>
<QuickFindDialog />
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} />
</>
);
};
const RootLayoutContent = () => { const RootLayoutContent = () => {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -81,7 +70,7 @@ const RootLayoutContent = () => {
{profile.demo && <DemoBanner />} {profile.demo && <DemoBanner />}
<Outlet /> <Outlet />
</main> </main>
<AppDialogs /> <QuickFindDialog />
</div> </div>
</AppSidebarProvider> </AppSidebarProvider>
); );

View file

@ -1,22 +1,129 @@
import { useEffect } from "react"; import { ExternalLinkIcon, ScissorsIcon } from "lucide-react";
import { Navigate } from "react-router-dom"; import { Badge } from "@/components/ui/badge";
import { useAppSidebar } from "@/contexts/AppSidebarContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useAuth } from "@/contexts/AuthContext"; import { WEB_CLIPPER_URL } from "@/lib/constants";
import useCurrentUser from "@/hooks/useCurrentUser"; import { useTranslate } from "@/utils/i18n";
import { ROUTES } from "@/router/routes";
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
const GITHUB_RELEASE_URL_PREFIX = "https://github.com/usememos/memos/releases/tag/v";
const DEFAULT_TITLE = "Memos";
const DEFAULT_TAGLINE = "Capture first. Keep it yours.";
const DEFAULT_LOGO = "/logo.webp";
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
const isSemver = (version: string) => /^\d+\.\d+\.\d+/.test(version);
const Chip = ({ href, children }: { href?: string; children: React.ReactNode }) => {
const className = "inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 font-mono text-xs text-muted-foreground";
if (href) {
return (
<a className={`${className} hover:bg-accent hover:text-foreground`} href={href} target="_blank" rel="noreferrer">
{children}
</a>
);
}
return <span className={className}>{children}</span>;
};
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<h2 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">{children}</h2>
);
const About = () => { const About = () => {
const currentUser = useCurrentUser(); const t = useTranslate();
const { setAboutOpen } = useAppSidebar(); const { profile, generalSetting } = useInstance();
const { isInitialized } = useAuth();
useEffect(() => { const customProfile = generalSetting.customProfile;
if (isInitialized) setAboutOpen(true); const instanceTitle = customProfile?.title || DEFAULT_TITLE;
}, [isInitialized, setAboutOpen]); const instanceTagline = customProfile?.description || DEFAULT_TAGLINE;
const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO;
const isCustomBranded = instanceTitle !== DEFAULT_TITLE;
if (!isInitialized) return null; const hasSemver = isSemver(profile.version);
const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : "";
const versionLabel = hasSemver ? `v${profile.version}` : profile.version;
const hasCommitSha = isCommitSha(profile.commit);
const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : "";
return <Navigate to={currentUser ? ROUTES.HOME : ROUTES.EXPLORE} replace />; const buildRows: { label: string; value: React.ReactNode }[] = [];
if (profile.version) {
buildRows.push({ label: t("common.version"), value: <Chip href={releaseUrl || undefined}>{versionLabel}</Chip> });
}
if (shortCommit) {
buildRows.push({ label: t("about.commit"), value: <Chip href={commitUrl}>{shortCommit}</Chip> });
}
buildRows.push({ label: t("about.license"), value: <Chip href="https://github.com/usememos/memos/blob/main/LICENSE">MIT</Chip> });
if (isCustomBranded) {
buildRows.push({
label: t("about.distribution"),
value: <span className="text-[13px] text-muted-foreground">{t("about.powered-by")}</span>,
});
}
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.github-repository"),
note: t("about.github-repository-note"),
href: "https://github.com/usememos/memos",
},
{ label: t("about.web-clipper"), note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon },
];
return (
<section className="min-h-full w-full">
<div className="mx-auto w-full max-w-2xl py-6 sm:py-8">
<header>
<img className="size-10 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
<div className="mt-4 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
{profile.demo && <Badge variant="warning">{t("about.demo")}</Badge>}
</div>
<p className="mt-1 max-w-md text-[26px] font-light leading-snug tracking-[-0.015em] text-foreground">{instanceTagline}</p>
</header>
<section className="mt-9">
<SectionLabel>{t("about.build")}</SectionLabel>
<dl className="mt-2.5 border-t border-border">
{buildRows.map((row) => (
<div key={row.label} className="grid grid-cols-[110px_1fr] items-center border-b border-border/60 py-2">
<dt className="text-[13px] text-muted-foreground">{row.label}</dt>
<dd className="m-0 flex min-w-0 items-center">{row.value}</dd>
</div>
))}
</dl>
</section>
<section className="mt-9">
<SectionLabel>{t("about.project")}</SectionLabel>
<nav aria-label={t("about.project-links")} className="mt-2.5 border-t border-border">
{projectLinks.map((link) => (
<a
key={link.href}
className="group flex items-center justify-between gap-4 border-b border-border/60 py-2.5"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="flex min-w-0 items-center gap-2">
{link.icon && <link.icon className="size-3.5 shrink-0 text-muted-foreground" />}
<span className="truncate text-[13px] font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
{link.label}
</span>
<span className="hidden truncate text-xs text-muted-foreground sm:inline">{link.note}</span>
</span>
<ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground group-hover:text-foreground" />
</a>
))}
</nav>
</section>
</div>
</section>
);
}; };
export default About; export default About;

View file

@ -1,7 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AboutDialog from "@/components/AboutDialog";
import About from "@/pages/About"; import About from "@/pages/About";
const mockInstance = { const mockInstance = {
@ -14,28 +12,11 @@ const mockInstance = {
}, },
generalSetting: {} as { customProfile?: { title: string; description: string; logoUrl: string } }, generalSetting: {} as { customProfile?: { title: string; description: string; logoUrl: string } },
}; };
const pageState = vi.hoisted(() => ({
authInitialized: false,
currentUser: { name: "users/test" } as { name: string } | undefined,
setAboutOpen: vi.fn(),
}));
vi.mock("@/contexts/InstanceContext", () => ({ vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => mockInstance, useInstance: () => mockInstance,
})); }));
vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({ setAboutOpen: pageState.setAboutOpen }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: pageState.authInitialized }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => pageState.currentUser,
}));
vi.mock("@/utils/i18n", () => ({ vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => useTranslate: () => (key: string) =>
( (
@ -46,13 +27,10 @@ vi.mock("@/utils/i18n", () => ({
)[key] ?? key, )[key] ?? key,
})); }));
const renderAbout = () => render(<AboutDialog open onOpenChange={vi.fn()} />); const renderAbout = () => render(<About />);
describe("<AboutDialog>", () => { describe("<About>", () => {
beforeEach(() => { beforeEach(() => {
pageState.authInitialized = false;
pageState.currentUser = { name: "users/test" };
pageState.setAboutOpen.mockReset();
mockInstance.profile = { mockInstance.profile = {
version: "0.25.0", version: "0.25.0",
commit: "0123456789abcdef0123456789abcdef01234567", commit: "0123456789abcdef0123456789abcdef01234567",
@ -67,7 +45,7 @@ describe("<AboutDialog>", () => {
document.documentElement.removeAttribute("data-theme"); document.documentElement.removeAttribute("data-theme");
}); });
it("renders the identity hero with linked version and commit chips", () => { it("renders the identity hero with linked version, commit, and license chips", () => {
renderAbout(); renderAbout();
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
@ -80,7 +58,7 @@ describe("<AboutDialog>", () => {
expect(screen.getByRole("link", { name: "MIT" })).toHaveAttribute("href", "https://github.com/usememos/memos/blob/main/LICENSE"); expect(screen.getByRole("link", { name: "MIT" })).toHaveAttribute("href", "https://github.com/usememos/memos/blob/main/LICENSE");
}); });
it("links to the usememos.com homepage, docs, API docs, and GitHub repo", () => { it("links to the project homepage, docs, API docs, GitHub, and Web Clipper", () => {
renderAbout(); renderAbout();
expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/"); expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/");
@ -131,40 +109,12 @@ describe("<AboutDialog>", () => {
expect(screen.getByText("Powered by Memos")).toBeInTheDocument(); expect(screen.getByText("Powered by Memos")).toBeInTheDocument();
}); });
it("renders in a compact dialog instead of a page surface", () => { it("renders as a page without nested mobile padding", () => {
renderAbout(); const { container } = renderAbout();
expect(screen.getByRole("dialog")).toHaveClass("p-0!"); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); const contentWrapper = container.querySelector("section > div");
}); expect(contentWrapper).toHaveClass("w-full");
expect(contentWrapper).not.toHaveClass("px-4");
it("waits for auth initialization before redirecting the compatibility route", async () => {
const view = render(
<MemoryRouter initialEntries={["/about"]}>
<Routes>
<Route path="/about" element={<About />} />
<Route path="/" element={<div>Home route</div>} />
<Route path="/explore" element={<div>Explore route</div>} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByText("Home route")).not.toBeInTheDocument();
expect(screen.queryByText("Explore route")).not.toBeInTheDocument();
expect(pageState.setAboutOpen).not.toHaveBeenCalled();
pageState.authInitialized = true;
view.rerender(
<MemoryRouter initialEntries={["/about"]}>
<Routes>
<Route path="/about" element={<About />} />
<Route path="/" element={<div>Home route</div>} />
<Route path="/explore" element={<div>Explore route</div>} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByText("Home route")).toBeInTheDocument());
expect(pageState.setAboutOpen).toHaveBeenCalledWith(true);
}); });
}); });

View file

@ -6,7 +6,6 @@ import AppSidebar, { MobileAppHeader } from "@/components/AppSidebar";
const authState = vi.hoisted(() => ({ currentUser: { name: "users/test" } as { name: string } | undefined })); const authState = vi.hoisted(() => ({ currentUser: { name: "users/test" } as { name: string } | undefined }));
const sidebarState = vi.hoisted(() => ({ const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived", memoScope: "home" as "home" | "explore" | "archived",
setAboutOpen: vi.fn(),
})); }));
vi.mock("@/components/MemosLogo", () => ({ vi.mock("@/components/MemosLogo", () => ({
@ -27,8 +26,6 @@ vi.mock("@/components/AppSidebar/TagsSection", () => ({
vi.mock("@/contexts/AppSidebarContext", () => ({ vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({ useAppSidebar: () => ({
aboutOpen: false,
setAboutOpen: sidebarState.setAboutOpen,
attachmentSection: "all", attachmentSection: "all",
setAttachmentSection: vi.fn(), setAttachmentSection: vi.fn(),
inboxFilter: "all", inboxFilter: "all",
@ -94,7 +91,6 @@ describe("App sidebar logo", () => {
beforeEach(() => { beforeEach(() => {
authState.currentUser = { name: "users/test" }; authState.currentUser = { name: "users/test" };
sidebarState.memoScope = "home"; sidebarState.memoScope = "home";
sidebarState.setAboutOpen.mockReset();
}); });
it("navigates home instead of opening a global editor", () => { it("navigates home instead of opening a global editor", () => {
@ -119,12 +115,53 @@ describe("App sidebar logo", () => {
); );
expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.about" })); expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(sidebarState.setAboutOpen).toHaveBeenCalledWith(true);
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull(); expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
}); });
it("shows common destinations instead of empty route content for an authenticated user", () => {
render(
<MemoryRouter initialEntries={["/404"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.home" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/explore");
expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments");
expect(screen.getByRole("link", { name: "common.inbox" })).toHaveAttribute("href", "/inbox");
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
});
it("uses a visitor sidebar for a guest on a route without contextual content", () => {
authState.currentUser = undefined;
render(
<MemoryRouter initialEntries={["/404"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/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");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
});
it("marks About active on the About page", () => {
render(
<MemoryRouter initialEntries={["/about"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page");
});
it("uses a compact scope menu and places views below the calendar", async () => { it("uses a compact scope menu and places views below the calendar", async () => {
render( render(
<MemoryRouter initialEntries={["/"]}> <MemoryRouter initialEntries={["/"]}>