diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 5ff483a25..f826bbe48 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -1,4 +1,4 @@ -import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react"; +import { BellIcon, EarthIcon, InfoIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react"; import { NavLink } from "react-router-dom"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -40,6 +40,12 @@ const Navigation = (props: Props) => { title: t("common.explore"), icon: , }; + const aboutNavLink: NavLinkItem = { + id: "header-about", + path: Routes.ABOUT, + title: t("common.about"), + icon: , + }; const attachmentsNavLink: NavLinkItem = { id: "header-attachments", path: Routes.ATTACHMENTS, @@ -71,7 +77,7 @@ const Navigation = (props: Props) => { const primaryNavLinks: NavLinkItem[] = currentUser ? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink] - : [exploreNavLink, signInNavLink]; + : [exploreNavLink, aboutNavLink, signInNavLink]; const inboxAriaLabel = unreadCount > 0 ? `${t("common.inbox")}, ${unreadCount} unread` : t("common.inbox"); return ( diff --git a/web/src/components/Placeholder/pieces/DESIGN.md b/web/src/components/Placeholder/pieces/DESIGN.md index 273c3cf58..31f02fd6a 100644 --- a/web/src/components/Placeholder/pieces/DESIGN.md +++ b/web/src/components/Placeholder/pieces/DESIGN.md @@ -12,8 +12,8 @@ These SVGs are pixel-art tile strips for the placeholder component. They should ## Naming -- Start with the animal name, for example `Owl` or `Falcon`. -- Add the animation name when needed, for example `OwlBlink` or `FalconIdle`. +- Start with the animal name, for example `Owl` or `Eagle`. +- Add the animation name when needed, for example `OwlBlink` or `EagleIdle`. - Do not name assets after UI states or empty-state scenarios. ## Frame Count @@ -45,5 +45,5 @@ Avoid padding an animation with duplicate frames just to hit a standard count. A ## Current Assets - `OwlBlink.svg`: five-frame blink/idle strip with breathing wings, blink, and ear-feather settle. -- `FalconIdle.svg`: four-frame idle strip with breathing, blink, alert head shift, and tail flick. -- `WoodpeckerPeck.svg`: six-frame tree-trunk peck strip with red crest, chisel beak, barred wing, impact chips, recoil, and settle. +- `EagleIdle.svg`: four-frame idle strip with breathing, blink, alert head shift, and tail flick. +- `ToucanIdle.svg`: four-frame idle strip with beak bob, chest breathing, blink, tail flick, and settle. diff --git a/web/src/components/Placeholder/pieces/FalconIdle.svg b/web/src/components/Placeholder/pieces/EagleIdle.svg similarity index 73% rename from web/src/components/Placeholder/pieces/FalconIdle.svg rename to web/src/components/Placeholder/pieces/EagleIdle.svg index b10f015d1..1e53f112a 100644 --- a/web/src/components/Placeholder/pieces/FalconIdle.svg +++ b/web/src/components/Placeholder/pieces/EagleIdle.svg @@ -1,6 +1,6 @@ - Falcon idle pixel tileset - A four-frame 32 by 32 pixel falcon idle animation strip. + Eagle idle pixel tileset + A four-frame 32 by 32 pixel eagle idle animation strip. - + @@ -34,14 +34,14 @@ - - + + - + @@ -65,8 +65,8 @@ - - + + @@ -74,49 +74,49 @@ - + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + diff --git a/web/src/components/Placeholder/pieces/ToucanIdle.svg b/web/src/components/Placeholder/pieces/ToucanIdle.svg new file mode 100644 index 000000000..cf34c2c76 --- /dev/null +++ b/web/src/components/Placeholder/pieces/ToucanIdle.svg @@ -0,0 +1,87 @@ + + Toucan idle pixel tileset + A four-frame 32 by 32 pixel toucan idle animation strip. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/components/Placeholder/pieces/WoodpeckerPeck.svg b/web/src/components/Placeholder/pieces/WoodpeckerPeck.svg deleted file mode 100644 index 05e2a0ef5..000000000 --- a/web/src/components/Placeholder/pieces/WoodpeckerPeck.svg +++ /dev/null @@ -1,162 +0,0 @@ - - Woodpecker peck pixel tileset - A six-frame 32 by 32 pixel woodpecker pecking a tree trunk. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/src/components/Placeholder/tileSprites.ts b/web/src/components/Placeholder/tileSprites.ts index b7326b595..460a9a7d4 100644 --- a/web/src/components/Placeholder/tileSprites.ts +++ b/web/src/components/Placeholder/tileSprites.ts @@ -1,6 +1,6 @@ -import FalconIdle from "./pieces/FalconIdle.svg?url"; +import EagleIdle from "./pieces/EagleIdle.svg?url"; import OwlBlink from "./pieces/OwlBlink.svg?url"; -import WoodpeckerPeck from "./pieces/WoodpeckerPeck.svg?url"; +import ToucanIdle from "./pieces/ToucanIdle.svg?url"; export interface TileSprite { name: string; @@ -21,20 +21,20 @@ export const TILE_SPRITES: TileSprite[] = [ duration: 1500, }, { - name: "FalconIdle", - src: FalconIdle, + name: "EagleIdle", + src: EagleIdle, frameWidth: 32, frameHeight: 32, frames: 4, duration: 960, }, { - name: "WoodpeckerPeck", - src: WoodpeckerPeck, + name: "ToucanIdle", + src: ToucanIdle, frameWidth: 32, frameHeight: 32, - frames: 6, - duration: 1080, + frames: 4, + duration: 1120, }, ]; diff --git a/web/src/components/Settings/SettingSection.tsx b/web/src/components/Settings/SettingSection.tsx index 9f62e1730..8ec95288f 100644 --- a/web/src/components/Settings/SettingSection.tsx +++ b/web/src/components/Settings/SettingSection.tsx @@ -20,7 +20,7 @@ const SettingSection: React.FC = ({ title, description, chi {typeof title === "string" ? {title} : title} )} - {description && {description}} + {description && {description}} {actions && {actions}} diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 430f65a16..985f7d13e 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -1,4 +1,14 @@ -import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react"; +import { + ArchiveIcon, + CheckIcon, + GlobeIcon, + InfoIcon, + LogOutIcon, + PaletteIcon, + SettingsIcon, + SquareUserIcon, + User2Icon, +} from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; @@ -132,6 +142,10 @@ const UserMenu = (props: Props) => { {t("common.archived")} + navigateTo(Routes.ABOUT)}> + + {t("common.about")} + diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index b6292f7d5..faabab17b 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -21,6 +21,7 @@ const MainLayout = () => { const location = useLocation(); const currentUser = useCurrentUser(); const [profileUserName, setProfileUserName] = useState(); + const showMemoExplorer = location.pathname !== Routes.ABOUT; // Determine context based on current route const context: MemoExplorerContext = useMemo(() => { @@ -69,12 +70,8 @@ const MainLayout = () => { return ( - {!md && ( - - - - )} - {md && ( + {!md && {showMemoExplorer && }} + {md && showMemoExplorer && ( diff --git a/web/src/pages/About.tsx b/web/src/pages/About.tsx new file mode 100644 index 000000000..b1cbb5bd2 --- /dev/null +++ b/web/src/pages/About.tsx @@ -0,0 +1,159 @@ +import { ExternalLinkIcon } from "lucide-react"; +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"; + +const SPRITE_SCALE = 2; + +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 PRODUCT_POINTS = ["Open. Write. Done.", "Markdown-native.", "Fully yours."]; + +const BIRD_META: Record = { + OwlBlink: { + label: "Owl", + description: "Night watch idle with a compact blink.", + }, + EagleIdle: { + label: "Eagle", + description: "Perched idle with a sharp head and steady chest motion.", + }, + ToucanIdle: { + label: "Toucan", + description: "Calm tropical idle built around a large curved beak.", + }, +}; + +const getBirdAnimationName = (sprite: TileSprite) => `about-bird-${sprite.name}`; + +const BIRD_KEYFRAMES_CSS = ` + ${TILE_SPRITES.map( + (sprite) => ` + @keyframes ${getBirdAnimationName(sprite)} { + from { transform: translateX(0); } + to { transform: translateX(-${sprite.frameWidth * sprite.frames * SPRITE_SCALE}px); } + } + `, + ).join("\n")} + + @media (prefers-reduced-motion: reduce) { + .about-bird-strip { + animation: none !important; + transform: translateX(0) !important; + } + } +`; + +const BirdSprite = ({ sprite }: { sprite: TileSprite }) => { + const stripWidth = sprite.frameWidth * sprite.frames; + const displayWidth = stripWidth * SPRITE_SCALE; + const displayHeight = sprite.frameHeight * SPRITE_SCALE; + const frameDisplayWidth = displayWidth / sprite.frames; + const meta = BIRD_META[sprite.name]; + + return ( + + + + + + + {sprite.name} + {meta.label} + + {meta.description} + + + ); +}; + +const About = () => { + return ( + + + + + + + + + + + + Memos + Capture first. Keep it yours. + + + + {PRODUCT_LINKS.map((link) => ( + + + {link.label} + + + + ))} + + + + + + + {PRODUCT_POINTS.map((item) => ( + + {item} + + ))} + + + + + + {TILE_SPRITES.map((sprite) => ( + + ))} + + + + + + + ); +}; + +export default About; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 255543c5e..d44b089ae 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -25,6 +25,7 @@ function lazyWithReload(factory: () => Promise<{ } const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn")); +const About = lazyWithReload(() => import("@/pages/About")); const Archived = lazyWithReload(() => import("@/pages/Archived")); const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback")); const Explore = lazyWithReload(() => import("@/pages/Explore")); @@ -84,6 +85,7 @@ export const routeConfig: RouteObject[] = [ element: , children: [{ index: true, element: }], }, + { path: Routes.ABOUT, element: }, { path: Routes.EXPLORE, element: }, { path: "u/:username", element: }, { diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index d44672654..3f5734f58 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -1,5 +1,6 @@ export const ROUTES = { HOME: "/", + ABOUT: "/about", ATTACHMENTS: "/attachments", INBOX: "/inbox", ARCHIVED: "/archived", diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts index bfc4741d5..48fb134fa 100644 --- a/web/src/utils/redirect-safety.ts +++ b/web/src/utils/redirect-safety.ts @@ -57,6 +57,7 @@ export function buildAuthRoute(options?: { redirect?: string | null; reason?: st const PUBLIC_ROUTE_PREFIXES = [ ROUTES.AUTH, // Authentication pages + ROUTES.ABOUT, // About page ROUTES.EXPLORE, // Explore page `${ROUTES.SHARED_MEMO}/`, // Shared memo pages (share-link viewer) "/u/", // User profile pages (dynamic) diff --git a/web/tests/about-page.test.tsx b/web/tests/about-page.test.tsx new file mode 100644 index 000000000..b52e6f3e1 --- /dev/null +++ b/web/tests/about-page.test.tsx @@ -0,0 +1,21 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TILE_SPRITES } from "@/components/Placeholder/tileSprites"; +import About from "@/pages/About"; + +describe("", () => { + it("renders the product story and current bird sprites", () => { + render(); + + expect(screen.getByRole("heading", { name: "Memos" })).toBeInTheDocument(); + expect(screen.getByText(/Capture first/i)).toBeInTheDocument(); + expect(screen.getByText(/quick capture/i)).toBeInTheDocument(); + + const birds = screen.getByRole("region", { name: "Birds" }); + expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length); + + for (const sprite of TILE_SPRITES) { + expect(within(birds).getByText(sprite.name)).toBeInTheDocument(); + } + }); +}); diff --git a/web/tests/placeholder-pool.test.ts b/web/tests/placeholder-pool.test.ts index 98503c65c..543336887 100644 --- a/web/tests/placeholder-pool.test.ts +++ b/web/tests/placeholder-pool.test.ts @@ -4,11 +4,11 @@ import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placehol describe("TILE_SPRITES integrity", () => { it("registers 32px by 32px sprite strips with animation-specific frame counts", () => { - expect(TILE_SPRITES.map((sprite) => sprite.name)).toEqual(["OwlBlink", "FalconIdle", "WoodpeckerPeck"]); + expect(TILE_SPRITES.map((sprite) => sprite.name)).toEqual(["OwlBlink", "EagleIdle", "ToucanIdle"]); expect(TILE_SPRITES.map((sprite) => [sprite.name, sprite.frames])).toEqual([ ["OwlBlink", 5], - ["FalconIdle", 4], - ["WoodpeckerPeck", 6], + ["EagleIdle", 4], + ["ToucanIdle", 4], ]); for (const sprite of TILE_SPRITES) { diff --git a/web/tests/redirect-safety.test.ts b/web/tests/redirect-safety.test.ts index c252a90a2..fc1e0a57b 100644 --- a/web/tests/redirect-safety.test.ts +++ b/web/tests/redirect-safety.test.ts @@ -60,6 +60,7 @@ describe("isPublicRoute", () => { it("identifies anonymous-accessible page prefixes", () => { expect(isPublicRoute("/auth")).toBe(true); expect(isPublicRoute("/auth/signup")).toBe(true); + expect(isPublicRoute("/about")).toBe(true); expect(isPublicRoute("/explore")).toBe(true); expect(isPublicRoute("/memos/abc")).toBe(true); expect(isPublicRoute("/memos/shares/abc")).toBe(true); diff --git a/web/tests/router-config.test.tsx b/web/tests/router-config.test.tsx index 0b2723950..b56fe93c2 100644 --- a/web/tests/router-config.test.tsx +++ b/web/tests/router-config.test.tsx @@ -58,7 +58,7 @@ describe("router configuration", () => { }); it("leaves public pages outside RequireAuthRoute", () => { - for (const path of [ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) { + for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) { expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false); } });
{description}
{meta.description}
Capture first. Keep it yours.