chore: redesign about page as identity hero
Rebuild /about around an identity hero: instance branding with custom profile fallbacks, a demo badge, and version/commit chips linked to the GitHub release and commit. Dev builds show a plain version chip and hide the unknown-commit chip. Project links become an annotated directory (homepage, docs, API docs, GitHub repo), followed by an MIT license note. The bird sprite gallery moves out of the page, and the version/commit block leaves the settings sidebar now that /about owns build info.
This commit is contained in:
+82
-64
@@ -1,82 +1,100 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import TileSpriteStrip from "@/components/Placeholder/TileSpriteStrip";
|
||||
import { TILE_SPRITES, type TileSprite } from "@/components/Placeholder/tileSprites";
|
||||
import SettingGroup from "@/components/Settings/SettingGroup";
|
||||
import SettingSection from "@/components/Settings/SettingSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const SPRITE_SCALE = 2;
|
||||
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 PRODUCT_LINKS = [
|
||||
{ label: "Website", href: "https://usememos.com/" },
|
||||
{ label: "GitHub", href: "https://github.com/usememos/memos" },
|
||||
{ label: "Docs", href: "https://usememos.com/docs" },
|
||||
];
|
||||
const DEFAULT_TITLE = "Memos";
|
||||
const DEFAULT_TAGLINE = "Capture first. Keep it yours.";
|
||||
const DEFAULT_LOGO = "/logo.webp";
|
||||
|
||||
const PRODUCT_POINTS = ["Open. Write. Done.", "Markdown-native.", "Fully yours."];
|
||||
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
|
||||
const isSemver = (version: string) => /^\d+\.\d+\.\d+/.test(version);
|
||||
|
||||
const BirdSprite = ({ sprite }: { sprite: TileSprite }) => {
|
||||
return (
|
||||
<figure className="flex w-auto min-w-28 flex-none flex-col items-center gap-3 rounded-xl border border-border bg-muted/20 px-4 py-4 text-center">
|
||||
<TileSpriteStrip sprite={sprite} scale={SPRITE_SCALE} className="size-16" testId="about-bird-sprite" />
|
||||
<figcaption className="min-w-0">
|
||||
<h3 className="font-mono text-sm text-foreground">{sprite.name}</h3>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
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 About = () => {
|
||||
const t = useTranslate();
|
||||
const { profile, generalSetting } = useInstance();
|
||||
|
||||
// Instance identity: custom branding when the admin has set it, Memos defaults otherwise.
|
||||
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;
|
||||
|
||||
// Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit chip.
|
||||
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 projectLinks = [
|
||||
{ label: t("about.official-website"), note: "the project homepage", href: "https://usememos.com/" },
|
||||
{ label: t("about.documents"), note: "deploy, configure, use", href: "https://usememos.com/docs" },
|
||||
{ label: "API Docs", note: "REST + gRPC reference", href: "https://usememos.com/docs/api" },
|
||||
{ label: t("about.github-repository"), note: "source, issues, releases", href: "https://github.com/usememos/memos" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
|
||||
<SettingSection
|
||||
title="About Memos"
|
||||
description="Open-source, self-hosted note-taking built for quick capture: Markdown-native, lightweight, and fully yours."
|
||||
>
|
||||
<SettingGroup>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<img className="size-12 shrink-0 select-none rounded-md" src="/logo.webp" alt="" draggable={false} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Memos</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Capture first. Keep it yours.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{PRODUCT_LINKS.map((link) => (
|
||||
<Button key={link.href} render={<a href={link.href} target="_blank" rel="noreferrer" />} variant="outline" size="lg">
|
||||
{link.label}
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<img className="size-16 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
|
||||
{profile.demo && <Badge variant="warning">Demo</Badge>}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup
|
||||
showSeparator
|
||||
title="Product"
|
||||
description="A small timeline for notes that should be saved now and organized later."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{PRODUCT_POINTS.map((item) => (
|
||||
<div key={item} className="rounded-lg bg-muted/40 px-3 py-2 text-sm text-foreground">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-1 text-sm text-muted-foreground">{instanceTagline}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{profile.version && <Chip href={releaseUrl || undefined}>{versionLabel}</Chip>}
|
||||
{shortCommit && <Chip href={commitUrl}>{shortCommit}</Chip>}
|
||||
{isCustomBranded && <Chip>Powered by Memos</Chip>}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingGroup showSeparator title="Birds" description="Pixel tile strips used by empty states.">
|
||||
<section aria-label="Birds" className="flex flex-row flex-wrap gap-3">
|
||||
{TILE_SPRITES.map((sprite) => (
|
||||
<BirdSprite key={sprite.name} sprite={sprite} />
|
||||
))}
|
||||
</section>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
<nav aria-label="Project links" className="mt-5">
|
||||
{projectLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
className="group flex flex-col gap-0.5 border-t border-border py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{link.note}</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground group-hover:text-foreground">
|
||||
{link.href.replace("https://", "")}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<p className="border-t border-border pt-3 text-xs text-muted-foreground">Free and open source under the MIT license.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -16,19 +16,14 @@ import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
|
||||
|
||||
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
|
||||
|
||||
const Setting = () => {
|
||||
const t = useTranslate();
|
||||
const sm = useMediaQuery("sm");
|
||||
const location = useLocation();
|
||||
const user = useCurrentUser();
|
||||
const { profile, fetchSettings } = useInstance();
|
||||
const { fetchSettings } = useInstance();
|
||||
const [selectedSection, setSelectedSection] = useState<SettingSectionKey>(DEFAULT_SETTING_SECTION);
|
||||
const isHost = user?.role === User_Role.ADMIN;
|
||||
const commitUrl = isCommitSha(profile.commit) ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
|
||||
|
||||
const sectionGroups = useMemo(() => {
|
||||
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.scope === "basic" || isHost);
|
||||
@@ -93,24 +88,7 @@ const Setting = () => {
|
||||
{isHost && (
|
||||
<>
|
||||
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">
|
||||
{renderSectionMenuItems(sectionGroups.admin)}
|
||||
<div className="px-3 mt-2 opacity-70 text-sm leading-5">
|
||||
{t("setting.version")}: {profile.version}
|
||||
{profile.commit && (
|
||||
<span className="block font-mono break-all">
|
||||
Commit:{" "}
|
||||
{commitUrl ? (
|
||||
<a className="underline hover:text-foreground" href={commitUrl} target="_blank" rel="noreferrer">
|
||||
{profile.commit}
|
||||
</a>
|
||||
) : (
|
||||
profile.commit
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.admin)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,103 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TILE_SPRITES } from "@/components/Placeholder/tileSprites";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import About from "@/pages/About";
|
||||
|
||||
const mockInstance = {
|
||||
profile: {
|
||||
version: "0.25.0",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
instanceUrl: "",
|
||||
demo: false,
|
||||
admin: undefined as { username: string; displayName: string } | undefined,
|
||||
},
|
||||
generalSetting: {} as { customProfile?: { title: string; description: string; logoUrl: string } },
|
||||
};
|
||||
|
||||
vi.mock("@/contexts/InstanceContext", () => ({
|
||||
useInstance: () => mockInstance,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => (key === "common.version" ? "Version" : key),
|
||||
}));
|
||||
|
||||
describe("<About>", () => {
|
||||
beforeEach(() => {
|
||||
mockInstance.profile = {
|
||||
version: "0.25.0",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
instanceUrl: "https://notes.example.com",
|
||||
demo: false,
|
||||
admin: { username: "steven", displayName: "Steven" },
|
||||
};
|
||||
mockInstance.generalSetting = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
});
|
||||
|
||||
it("renders the product story and current bird sprites", () => {
|
||||
it("renders the identity hero with linked version and commit chips", () => {
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Capture first/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/quick capture/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "v0.25.0" })).toHaveAttribute("href", "https://github.com/usememos/memos/releases/tag/v0.25.0");
|
||||
expect(screen.getByRole("link", { name: "0123456" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/usememos/memos/commit/0123456789abcdef0123456789abcdef01234567",
|
||||
);
|
||||
expect(screen.getByText(/MIT license/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const birds = screen.getByRole("region", { name: "Birds" });
|
||||
expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length);
|
||||
it("links to the usememos.com homepage, docs, API docs, and GitHub repo", () => {
|
||||
render(<About />);
|
||||
|
||||
for (const sprite of TILE_SPRITES) {
|
||||
expect(within(birds).getByText(sprite.name)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/");
|
||||
expect(screen.getByRole("link", { name: /about\.documents/ })).toHaveAttribute("href", "https://usememos.com/docs");
|
||||
expect(screen.getByRole("link", { name: /API Docs/ })).toHaveAttribute("href", "https://usememos.com/docs/api");
|
||||
expect(screen.getByRole("link", { name: /about\.github-repository/ })).toHaveAttribute("href", "https://github.com/usememos/memos");
|
||||
});
|
||||
|
||||
it("does not surface the instance URL, administrator, or birds", () => {
|
||||
render(<About />);
|
||||
|
||||
expect(screen.queryByText("https://notes.example.com")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Administrator")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Steven")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Birds")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("about-bird-sprite")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a plain version chip and no commit chip on dev builds", () => {
|
||||
mockInstance.profile.version = "dev";
|
||||
mockInstance.profile.commit = "unknown";
|
||||
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByText("dev")).toBeInTheDocument();
|
||||
expect(screen.queryByText("vdev")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/unknown/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the demo badge on demo instances", () => {
|
||||
mockInstance.profile.demo = true;
|
||||
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByText("Demo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses custom branding for the identity hero and credits Memos", () => {
|
||||
mockInstance.generalSetting = {
|
||||
customProfile: { title: "Team Notes", description: "Our shared scratchpad.", logoUrl: "/custom-logo.png" },
|
||||
};
|
||||
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Team Notes" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Our shared scratchpad.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Powered by Memos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not add nested horizontal page padding on mobile", () => {
|
||||
|
||||
Reference in New Issue
Block a user