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:
parent
83eab44695
commit
d14a542002
8 changed files with 275 additions and 275 deletions
|
|
@ -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;
|
||||
|
|
@ -61,7 +61,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemosLogo from "../MemosLogo";
|
||||
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 TagsSection from "./TagsSection";
|
||||
|
||||
|
|
@ -395,7 +395,6 @@ interface GlobalNavItem {
|
|||
icon: LucideIcon;
|
||||
active: boolean;
|
||||
count?: number;
|
||||
opensDialog?: boolean;
|
||||
}
|
||||
|
||||
const GlobalNavigation = () => {
|
||||
|
|
@ -404,7 +403,7 @@ const GlobalNavigation = () => {
|
|||
const navigate = useNavigate();
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: notifications = [] } = useNotifications();
|
||||
const { memoDetail, memoScope, setAboutOpen, setMemoScope, setMobileOpen } = useAppSidebar();
|
||||
const { memoDetail, memoScope, setMemoScope, setMobileOpen } = useAppSidebar();
|
||||
const { filters } = useMemoFilterContext();
|
||||
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
|
||||
const routeKind = getSidebarRouteKind(location.pathname);
|
||||
|
|
@ -464,7 +463,7 @@ const GlobalNavigation = () => {
|
|||
icon: EarthIcon,
|
||||
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 = (
|
||||
|
|
@ -556,20 +555,7 @@ const GlobalNavigation = () => {
|
|||
)}
|
||||
</>
|
||||
);
|
||||
const content = item.opensDialog ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
className={itemClassName}
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
setAboutOpen(true);
|
||||
}}
|
||||
>
|
||||
{itemContent}
|
||||
</button>
|
||||
) : (
|
||||
const content = (
|
||||
<Link
|
||||
key={item.id}
|
||||
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 t = useTranslate();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||
const staticMode = getSidebarRouteKind(location.pathname) === "empty";
|
||||
return (
|
||||
<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)}>
|
||||
|
|
@ -620,14 +676,22 @@ const AppSidebar = ({ className }: { className?: string }) => {
|
|||
<SearchIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
</div>
|
||||
{staticMode ? (
|
||||
<StaticNavigation />
|
||||
) : (
|
||||
<>
|
||||
<GlobalNavigation />
|
||||
<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 />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<footer className="shrink-0 border-t border-border/70">
|
||||
{currentUser ? (
|
||||
<UserMenu />
|
||||
<UserMenu showAbout={!staticMode} />
|
||||
) : (
|
||||
<Link
|
||||
to={ROUTES.AUTH}
|
||||
|
|
|
|||
|
|
@ -34,13 +34,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
showAbout?: boolean;
|
||||
}
|
||||
|
||||
const UserMenu = (props: Props) => {
|
||||
const { collapsed } = props;
|
||||
const { collapsed, showAbout = true } = props;
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { setAboutOpen, setMobileOpen } = useAppSidebar();
|
||||
const { setMobileOpen } = useAppSidebar();
|
||||
const currentUser = useCurrentUser();
|
||||
const { userGeneralSetting, refetchSettings, logout } = useAuth();
|
||||
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
|
||||
|
|
@ -109,11 +110,6 @@ const UserMenu = (props: Props) => {
|
|||
navigateTo(path);
|
||||
};
|
||||
|
||||
const handleAboutOpen = () => {
|
||||
setMobileOpen(false);
|
||||
setAboutOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -183,10 +179,12 @@ const UserMenu = (props: Props) => {
|
|||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onClick={handleAboutOpen}>
|
||||
{showAbout && (
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.ABOUT)}>
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.about")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.SETTING)}>
|
||||
<SettingsIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.settings")}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ export interface MemoDetailSidebarDescriptor {
|
|||
}
|
||||
|
||||
interface AppSidebarContextValue {
|
||||
aboutOpen: boolean;
|
||||
setAboutOpen: (open: boolean) => void;
|
||||
attachmentSection: AttachmentSection;
|
||||
setAttachmentSection: (section: AttachmentSection) => void;
|
||||
inboxFilter: InboxFilter;
|
||||
|
|
@ -34,7 +32,6 @@ const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);
|
|||
|
||||
export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
|
||||
const location = useLocation();
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [attachmentSection, setAttachmentSection] = useState<AttachmentSection>("all");
|
||||
const [inboxFilter, setInboxFilter] = useState<InboxFilter>("all");
|
||||
const [memoDetail, setMemoDetailState] = useState<MemoDetailSidebarDescriptor>();
|
||||
|
|
@ -54,8 +51,6 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
aboutOpen,
|
||||
setAboutOpen,
|
||||
attachmentSection,
|
||||
setAttachmentSection,
|
||||
inboxFilter,
|
||||
|
|
@ -69,7 +64,7 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
|
|||
memoScope,
|
||||
setMemoScope,
|
||||
}),
|
||||
[aboutOpen, attachmentSection, inboxFilter, memoDetail, setMemoDetail, mobileOpen, quickFindOpen, memoScope],
|
||||
[attachmentSection, inboxFilter, memoDetail, setMemoDetail, mobileOpen, quickFindOpen, memoScope],
|
||||
);
|
||||
|
||||
return <AppSidebarContext.Provider value={value}>{children}</AppSidebarContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
import AboutDialog from "@/components/AboutDialog";
|
||||
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 { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
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 location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -81,7 +70,7 @@ const RootLayoutContent = () => {
|
|||
{profile.demo && <DemoBanner />}
|
||||
<Outlet />
|
||||
</main>
|
||||
<AppDialogs />
|
||||
<QuickFindDialog />
|
||||
</div>
|
||||
</AppSidebarProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,129 @@
|
|||
import { useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
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 { 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 }) => (
|
||||
<h2 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">{children}</h2>
|
||||
);
|
||||
|
||||
const About = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const { setAboutOpen } = useAppSidebar();
|
||||
const { isInitialized } = useAuth();
|
||||
const t = useTranslate();
|
||||
const { profile, generalSetting } = useInstance();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) setAboutOpen(true);
|
||||
}, [isInitialized, setAboutOpen]);
|
||||
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;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AboutDialog from "@/components/AboutDialog";
|
||||
import About from "@/pages/About";
|
||||
|
||||
const mockInstance = {
|
||||
|
|
@ -14,28 +12,11 @@ const mockInstance = {
|
|||
},
|
||||
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", () => ({
|
||||
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", () => ({
|
||||
useTranslate: () => (key: string) =>
|
||||
(
|
||||
|
|
@ -46,13 +27,10 @@ vi.mock("@/utils/i18n", () => ({
|
|||
)[key] ?? key,
|
||||
}));
|
||||
|
||||
const renderAbout = () => render(<AboutDialog open onOpenChange={vi.fn()} />);
|
||||
const renderAbout = () => render(<About />);
|
||||
|
||||
describe("<AboutDialog>", () => {
|
||||
describe("<About>", () => {
|
||||
beforeEach(() => {
|
||||
pageState.authInitialized = false;
|
||||
pageState.currentUser = { name: "users/test" };
|
||||
pageState.setAboutOpen.mockReset();
|
||||
mockInstance.profile = {
|
||||
version: "0.25.0",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
|
|
@ -67,7 +45,7 @@ describe("<AboutDialog>", () => {
|
|||
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();
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("renders in a compact dialog instead of a page surface", () => {
|
||||
renderAbout();
|
||||
it("renders as a page without nested mobile padding", () => {
|
||||
const { container } = renderAbout();
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveClass("p-0!");
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
const contentWrapper = container.querySelector("section > div");
|
||||
expect(contentWrapper).toHaveClass("w-full");
|
||||
expect(contentWrapper).not.toHaveClass("px-4");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import AppSidebar, { MobileAppHeader } from "@/components/AppSidebar";
|
|||
const authState = vi.hoisted(() => ({ currentUser: { name: "users/test" } as { name: string } | undefined }));
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
memoScope: "home" as "home" | "explore" | "archived",
|
||||
setAboutOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemosLogo", () => ({
|
||||
|
|
@ -27,8 +26,6 @@ vi.mock("@/components/AppSidebar/TagsSection", () => ({
|
|||
|
||||
vi.mock("@/contexts/AppSidebarContext", () => ({
|
||||
useAppSidebar: () => ({
|
||||
aboutOpen: false,
|
||||
setAboutOpen: sidebarState.setAboutOpen,
|
||||
attachmentSection: "all",
|
||||
setAttachmentSection: vi.fn(),
|
||||
inboxFilter: "all",
|
||||
|
|
@ -94,7 +91,6 @@ describe("App sidebar logo", () => {
|
|||
beforeEach(() => {
|
||||
authState.currentUser = { name: "users/test" };
|
||||
sidebarState.memoScope = "home";
|
||||
sidebarState.setAboutOpen.mockReset();
|
||||
});
|
||||
|
||||
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();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.about" }));
|
||||
expect(sidebarState.setAboutOpen).toHaveBeenCalledWith(true);
|
||||
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
|
||||
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
|
||||
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 () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue