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:
parent
1e91cfba9d
commit
8d9849e13f
3 changed files with 171 additions and 98 deletions
|
|
@ -1,82 +1,100 @@
|
||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
import TileSpriteStrip from "@/components/Placeholder/TileSpriteStrip";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TILE_SPRITES, type TileSprite } from "@/components/Placeholder/tileSprites";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
import SettingGroup from "@/components/Settings/SettingGroup";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import SettingSection from "@/components/Settings/SettingSection";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
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 = [
|
const DEFAULT_TITLE = "Memos";
|
||||||
{ label: "Website", href: "https://usememos.com/" },
|
const DEFAULT_TAGLINE = "Capture first. Keep it yours.";
|
||||||
{ label: "GitHub", href: "https://github.com/usememos/memos" },
|
const DEFAULT_LOGO = "/logo.webp";
|
||||||
{ label: "Docs", href: "https://usememos.com/docs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 }) => {
|
const Chip = ({ href, children }: { href?: string; children: React.ReactNode }) => {
|
||||||
return (
|
const className = "inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 font-mono text-xs text-muted-foreground";
|
||||||
<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">
|
if (href) {
|
||||||
<TileSpriteStrip sprite={sprite} scale={SPRITE_SCALE} className="size-16" testId="about-bird-sprite" />
|
return (
|
||||||
<figcaption className="min-w-0">
|
<a className={`${className} hover:bg-accent hover:text-foreground`} href={href} target="_blank" rel="noreferrer">
|
||||||
<h3 className="font-mono text-sm text-foreground">{sprite.name}</h3>
|
{children}
|
||||||
</figcaption>
|
</a>
|
||||||
</figure>
|
);
|
||||||
);
|
}
|
||||||
|
return <span className={className}>{children}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const About = () => {
|
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 (
|
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">
|
<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">
|
||||||
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
|
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
|
||||||
<SettingSection
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
title="About Memos"
|
<img className="size-16 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
|
||||||
description="Open-source, self-hosted note-taking built for quick capture: Markdown-native, lightweight, and fully yours."
|
<div className="min-w-0">
|
||||||
>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SettingGroup>
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
{profile.demo && <Badge variant="warning">Demo</Badge>}
|
||||||
<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>
|
</div>
|
||||||
</SettingGroup>
|
<p className="mt-1 text-sm text-muted-foreground">{instanceTagline}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
<SettingGroup
|
{profile.version && <Chip href={releaseUrl || undefined}>{versionLabel}</Chip>}
|
||||||
showSeparator
|
{shortCommit && <Chip href={commitUrl}>{shortCommit}</Chip>}
|
||||||
title="Product"
|
{isCustomBranded && <Chip>Powered by Memos</Chip>}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</SettingGroup>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingGroup showSeparator title="Birds" description="Pixel tile strips used by empty states.">
|
<nav aria-label="Project links" className="mt-5">
|
||||||
<section aria-label="Birds" className="flex flex-row flex-wrap gap-3">
|
{projectLinks.map((link) => (
|
||||||
{TILE_SPRITES.map((sprite) => (
|
<a
|
||||||
<BirdSprite key={sprite.name} sprite={sprite} />
|
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"
|
||||||
</section>
|
href={link.href}
|
||||||
</SettingGroup>
|
target="_blank"
|
||||||
</SettingSection>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,14 @@ import useMediaQuery from "@/hooks/useMediaQuery";
|
||||||
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
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 Setting = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const sm = useMediaQuery("sm");
|
const sm = useMediaQuery("sm");
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const { profile, fetchSettings } = useInstance();
|
const { fetchSettings } = useInstance();
|
||||||
const [selectedSection, setSelectedSection] = useState<SettingSectionKey>(DEFAULT_SETTING_SECTION);
|
const [selectedSection, setSelectedSection] = useState<SettingSectionKey>(DEFAULT_SETTING_SECTION);
|
||||||
const isHost = user?.role === User_Role.ADMIN;
|
const isHost = user?.role === User_Role.ADMIN;
|
||||||
const commitUrl = isCommitSha(profile.commit) ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
|
|
||||||
|
|
||||||
const sectionGroups = useMemo(() => {
|
const sectionGroups = useMemo(() => {
|
||||||
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.scope === "basic" || isHost);
|
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.scope === "basic" || isHost);
|
||||||
|
|
@ -93,24 +88,7 @@ const Setting = () => {
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
|
<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">
|
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.admin)}</div>
|
||||||
{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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,103 @@
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { TILE_SPRITES } from "@/components/Placeholder/tileSprites";
|
|
||||||
import About from "@/pages/About";
|
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>", () => {
|
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(() => {
|
afterEach(() => {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
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 />);
|
render(<About />);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Capture first/i)).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" });
|
it("links to the usememos.com homepage, docs, API docs, and GitHub repo", () => {
|
||||||
expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length);
|
render(<About />);
|
||||||
|
|
||||||
for (const sprite of TILE_SPRITES) {
|
expect(screen.getByRole("link", { name: /about\.official-website/ })).toHaveAttribute("href", "https://usememos.com/");
|
||||||
expect(within(birds).getByText(sprite.name)).toBeInTheDocument();
|
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", () => {
|
it("does not add nested horizontal page padding on mobile", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue