feat(about): add about page with bird sprites

pull/5954/head
boojack 2 weeks ago
parent 638e4f398e
commit 411ba7b34c

@ -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: <EarthIcon className="w-6 h-auto shrink-0" />,
};
const aboutNavLink: NavLinkItem = {
id: "header-about",
path: Routes.ABOUT,
title: t("common.about"),
icon: <InfoIcon className="w-6 h-auto shrink-0" />,
};
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 (

@ -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.

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="32" viewBox="0 0 128 32" role="img" aria-labelledby="title desc" shape-rendering="crispEdges">
<title id="title">Falcon idle pixel tileset</title>
<desc id="desc">A four-frame 32 by 32 pixel falcon idle animation strip.</desc>
<title id="title">Eagle idle pixel tileset</title>
<desc id="desc">A four-frame 32 by 32 pixel eagle idle animation strip.</desc>
<defs>
<style>
.outline { fill: #120f0d; }
@ -19,7 +19,7 @@
.eye-light { fill: #ffd069; }
</style>
<symbol id="falcon-head" viewBox="0 0 32 32">
<symbol id="eagle-head" viewBox="0 0 32 32">
<path class="outline" d="M14 2h10v1h1v12h2v3h-1v1h-4v-2h-3v-3h-3v-2h-2V9H5V8h1V6h5V4h3V2z" />
<path class="head" d="M15 4h8v1h1v10h2v2h-3v-2h-3v-3h-3v-2h-2V8h-4V6h4V4z" />
<rect class="head-shadow" x="16" y="14" width="3" height="2" />
@ -34,14 +34,14 @@
<rect class="beak-shadow" x="6" y="15" width="8" height="1" />
</symbol>
<symbol id="falcon-head-alert" viewBox="0 0 32 32">
<use href="#falcon-head" x="-1" />
<symbol id="eagle-head-alert" viewBox="0 0 32 32">
<use href="#eagle-head" x="-1" />
<rect class="outline" x="18" y="7" width="4" height="1" />
<rect class="eye" x="19" y="8" width="2" height="2" />
<rect class="eye-light" x="20" y="8" width="1" height="1" />
</symbol>
<symbol id="falcon-body" viewBox="0 0 32 32">
<symbol id="eagle-body" viewBox="0 0 32 32">
<path class="outline" d="M7 15h17v2h2v9h-2v3h-4v2H10v-2H7v-3H6v-9h1v-2z" />
<path class="body" d="M9 16h14v2h1v8h-2v2H12v-2H9V16z" />
<path class="wing" d="M7 17h3v9H8v-1H7v-8z" />
@ -65,8 +65,8 @@
<path class="claw" d="M20 26h1v3h2v1h-5v-1h2v-3z" />
</symbol>
<symbol id="falcon-body-breathe" viewBox="0 0 32 32">
<use href="#falcon-body" />
<symbol id="eagle-body-breathe" viewBox="0 0 32 32">
<use href="#eagle-body" />
<rect class="outline" x="7" y="16" width="3" height="1" />
<rect class="wing" x="7" y="18" width="3" height="8" />
<rect class="outline" x="23" y="16" width="3" height="1" />
@ -74,49 +74,49 @@
<rect class="body" x="11" y="17" width="10" height="1" />
</symbol>
<symbol id="falcon-tail" viewBox="0 0 32 32">
<symbol id="eagle-tail" viewBox="0 0 32 32">
<path class="outline" d="M23 23h3v2h3v1h2v3h-2v1h-5v-2h-2v-3h1v-2z" />
<path class="body-dark" d="M23 24h3v2h2v1h-4v-1h-1v-2z" />
<rect class="tail-tip" x="26" y="25" width="2" height="2" />
<rect class="tail-tip" x="24" y="27" width="2" height="1" />
</symbol>
<symbol id="falcon-tail-flick" viewBox="0 0 32 32">
<symbol id="eagle-tail-flick" viewBox="0 0 32 32">
<path class="outline" d="M23 22h3v2h3v1h2v3h-2v1h-5v-2h-2v-3h1v-2z" />
<path class="body-dark" d="M23 23h3v2h2v1h-4v-1h-1v-2z" />
<rect class="tail-tip" x="26" y="24" width="2" height="2" />
<rect class="tail-tip" x="24" y="26" width="2" height="1" />
</symbol>
<symbol id="falcon-idle-a" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body" />
<use href="#falcon-head" />
<symbol id="eagle-idle-a" viewBox="0 0 32 32">
<use href="#eagle-tail" />
<use href="#eagle-body" />
<use href="#eagle-head" />
</symbol>
<symbol id="falcon-idle-b" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body-breathe" />
<use href="#falcon-head" />
<symbol id="eagle-idle-b" viewBox="0 0 32 32">
<use href="#eagle-tail" />
<use href="#eagle-body-breathe" />
<use href="#eagle-head" />
</symbol>
<symbol id="falcon-idle-blink" viewBox="0 0 32 32">
<use href="#falcon-tail" />
<use href="#falcon-body" />
<use href="#falcon-head" />
<symbol id="eagle-idle-blink" viewBox="0 0 32 32">
<use href="#eagle-tail" />
<use href="#eagle-body" />
<use href="#eagle-head" />
<rect class="head" x="20" y="8" width="2" height="2" />
<rect class="outline" x="19" y="8" width="3" height="1" />
</symbol>
<symbol id="falcon-idle-c" viewBox="0 0 32 32">
<use href="#falcon-tail-flick" />
<use href="#falcon-body" />
<use href="#falcon-head-alert" />
<symbol id="eagle-idle-c" viewBox="0 0 32 32">
<use href="#eagle-tail-flick" />
<use href="#eagle-body" />
<use href="#eagle-head-alert" />
</symbol>
</defs>
<use href="#falcon-idle-a" x="0" y="0" width="32" height="32" />
<use href="#falcon-idle-b" x="32" y="0" width="32" height="32" />
<use href="#falcon-idle-blink" x="64" y="0" width="32" height="32" />
<use href="#falcon-idle-c" x="96" y="0" width="32" height="32" />
<use href="#eagle-idle-a" x="0" y="0" width="32" height="32" />
<use href="#eagle-idle-b" x="32" y="0" width="32" height="32" />
<use href="#eagle-idle-blink" x="64" y="0" width="32" height="32" />
<use href="#eagle-idle-c" x="96" y="0" width="32" height="32" />
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="32" viewBox="0 0 128 32" role="img" aria-labelledby="title desc" shape-rendering="crispEdges">
<title id="title">Toucan idle pixel tileset</title>
<desc id="desc">A four-frame 32 by 32 pixel toucan idle animation strip.</desc>
<defs>
<style>
.outline { fill: #2f2a24; }
.body { fill: #1f2528; }
.bodyHighlight { fill: #354046; }
.face { fill: #2b3539; }
.chest { fill: #f1d88b; }
.chestShadow { fill: #c99b55; }
.beak { fill: #e57f24; }
.beakHighlight { fill: #f4c247; }
.beakAccent { fill: #b93d22; }
.eye { fill: #120f0d; }
.eyeLight { fill: #f4ead5; }
.foot { fill: #b86f2e; }
</style>
<symbol id="toucan-open" viewBox="0 0 32 32">
<path class="outline" d="M14 5h7v2h3v4h2v12h-2v4h-3v2h-8v-2h-3v-5H8v-3H5v-2H2v-6h2V9h4V7h6V5z" />
<path class="beak" d="M4 10h10v1h3v3h-1v3H4v-1H3v-4h1v-2z" />
<path class="beakHighlight" d="M5 10h8v1h2v2H5v-3z" />
<rect class="beakAccent" x="3" y="14" width="4" height="2" />
<path class="body" d="M15 8h6v2h2v12h-2v3h-3v2h-5v-2h-2v-5h2v-3h2V8z" />
<path class="face" d="M15 8h5v2h1v5h-2v1h-4V8z" />
<path class="bodyHighlight" d="M18 10h3v8h-1v2h-2V10z" />
<rect class="chest" x="15" y="9" width="2" height="2" />
<path class="chest" d="M11 16h6v8h-1v2h-3v-2h-2v-8z" />
<rect class="chestShadow" x="12" y="23" width="4" height="1" />
<rect class="body" x="16" y="17" width="1" height="5" />
<rect class="eyeLight" x="15" y="9" width="2" height="2" />
<rect class="eye" x="16" y="10" width="1" height="1" />
<rect class="bodyHighlight" x="19" y="12" width="1" height="4" />
<path class="body" d="M24 20h3v2h2v2h-4v-1h-1v-3z" />
<rect class="bodyHighlight" x="25" y="21" width="2" height="2" />
<rect class="foot" x="13" y="27" width="2" height="3" />
<rect class="foot" x="18" y="27" width="2" height="3" />
</symbol>
<symbol id="toucan-breathe" viewBox="0 0 32 32">
<path class="outline" d="M14 6h7v2h3v4h2v11h-2v4h-3v2h-8v-2h-3v-5H8v-3H5v-2H2v-6h2V9h4V7h6V6z" />
<path class="beak" d="M4 11h10v1h3v3h-1v3H4v-1H3v-4h1v-2z" />
<path class="beakHighlight" d="M5 11h8v1h2v2H5v-3z" />
<rect class="beakAccent" x="3" y="15" width="4" height="2" />
<path class="body" d="M15 9h6v2h2v11h-2v3h-3v2h-5v-2h-2v-5h2v-3h2V9z" />
<path class="face" d="M15 9h5v2h1v5h-2v1h-4V9z" />
<path class="bodyHighlight" d="M18 11h3v7h-1v2h-2v-9z" />
<rect class="chest" x="15" y="10" width="2" height="2" />
<path class="chest" d="M10 16h7v9h-1v2h-3v-2h-3v-9z" />
<rect class="chestShadow" x="11" y="24" width="5" height="1" />
<rect class="body" x="17" y="17" width="1" height="5" />
<rect class="eyeLight" x="15" y="10" width="2" height="2" />
<rect class="eye" x="16" y="11" width="1" height="1" />
<rect class="bodyHighlight" x="19" y="13" width="1" height="3" />
<path class="body" d="M25 21h3v2h1v2h-4v-1h-1v-2h1v-1z" />
<rect class="bodyHighlight" x="26" y="22" width="2" height="2" />
<rect class="foot" x="13" y="27" width="2" height="3" />
<rect class="foot" x="18" y="27" width="2" height="3" />
</symbol>
<symbol id="toucan-blink" viewBox="0 0 32 32">
<use href="#toucan-open" />
<rect class="face" x="15" y="9" width="2" height="2" />
<rect class="eye" x="15" y="10" width="2" height="1" />
</symbol>
<symbol id="toucan-settle" viewBox="0 0 32 32">
<use href="#toucan-open" />
<rect class="bodyHighlight" x="18" y="10" width="3" height="2" />
<rect class="chest" x="12" y="17" width="4" height="1" />
<rect class="body" x="24" y="21" width="3" height="2" />
<rect class="bodyHighlight" x="25" y="22" width="2" height="1" />
</symbol>
</defs>
<use href="#toucan-open" x="0" y="0" width="32" height="32" />
<use href="#toucan-breathe" x="32" y="0" width="32" height="32" />
<use href="#toucan-blink" x="64" y="0" width="32" height="32" />
<use href="#toucan-settle" x="96" y="0" width="32" height="32" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@ -1,162 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="32" viewBox="0 0 192 32" role="img" aria-labelledby="title desc" shape-rendering="crispEdges">
<title id="title">Woodpecker peck pixel tileset</title>
<desc id="desc">A six-frame 32 by 32 pixel woodpecker pecking a tree trunk.</desc>
<defs>
<style>
.outline { fill: #17110e; }
.bark { fill: #6c4b34; }
.bark-dark { fill: #3f2b20; }
.bark-light { fill: #9b6a45; }
.crest { fill: #c8442f; }
.crest-dark { fill: #7c261d; }
.head { fill: #f0d3a4; }
.cheek { fill: #f2efe7; }
.head-shadow { fill: #b78354; }
.beak { fill: #2f2a24; }
.body { fill: #835b3c; }
.belly { fill: #e6d7bf; }
.wing { fill: #27201d; }
.wing-light { fill: #f2efe7; }
.stripe { fill: #5b4d42; }
.tail { fill: #2f2a24; }
.claw { fill: #8e8376; }
.eye { fill: #120f0d; }
.eye-light { fill: #f4ead5; }
.chip { fill: #d8a55f; }
</style>
<symbol id="trunk" viewBox="0 0 32 32">
<path class="outline" d="M1 0h5v32H1V0z" />
<rect class="bark" x="2" y="0" width="3" height="32" />
<rect class="bark-dark" x="2" y="6" width="1" height="5" />
<rect class="bark-dark" x="4" y="17" width="1" height="6" />
<rect class="bark-light" x="4" y="2" width="1" height="4" />
<rect class="bark-light" x="3" y="24" width="1" height="5" />
</symbol>
<symbol id="woodpecker-rest" viewBox="0 0 32 32">
<use href="#trunk" />
<path class="outline" d="M14 3h4v2h2v3h2v4h2v2h2v12h-2v3h-4v2h-7v-2h-2v-3H9v-4h1v-5H8v-2H6v-2h3V8h2V5h3V3z" />
<path class="crest" d="M14 4h3v2h2v2h1v2h-5V8h-2V6h1V4z" />
<rect class="crest-dark" x="16" y="6" width="2" height="2" />
<path class="head" d="M11 8h8v2h2v4h-2v2h-7v-2h-2v-4h1V8z" />
<rect class="cheek" x="11" y="12" width="4" height="3" />
<rect class="head-shadow" x="16" y="14" width="3" height="2" />
<rect class="eye-light" x="14" y="9" width="2" height="2" />
<rect class="eye" x="14" y="9" width="1" height="1" />
<path class="beak" d="M5 11h7v1H5v-1z" />
<rect class="beak" x="6" y="12" width="6" height="1" />
<path class="body" d="M13 16h9v2h2v8h-2v2h-8v-2h-2v-8h1v-2z" />
<path class="belly" d="M13 17h4v8h-1v1h-2v-2h-1v-7z" />
<path class="wing" d="M17 16h7v3h-1v5h-2v2h-5v-9h1v-1z" />
<rect class="wing-light" x="18" y="17" width="3" height="1" />
<rect class="wing-light" x="20" y="20" width="3" height="1" />
<rect class="wing-light" x="17" y="23" width="3" height="1" />
<rect class="stripe" x="18" y="18" width="2" height="1" />
<rect class="stripe" x="21" y="21" width="2" height="1" />
<path class="tail" d="M22 24h3v2h2v3h-2v1h-4v-2h1v-4z" />
<rect class="claw" x="7" y="17" width="4" height="1" />
<rect class="claw" x="8" y="24" width="4" height="1" />
</symbol>
<symbol id="woodpecker-lean" viewBox="0 0 32 32">
<use href="#trunk" />
<path class="outline" d="M13 3h4v2h2v3h2v4h2v2h2v12h-2v3h-4v2h-7v-2h-2v-3H8v-4h1v-5H7v-2H5v-2h3V8h2V5h3V3z" />
<path class="crest" d="M13 4h3v2h2v2h1v2h-5V8h-2V6h1V4z" />
<rect class="crest-dark" x="15" y="6" width="2" height="2" />
<path class="head" d="M10 8h8v2h2v4h-2v2h-7v-2H9v-4h1V8z" />
<rect class="cheek" x="10" y="12" width="4" height="3" />
<rect class="head-shadow" x="15" y="14" width="3" height="2" />
<rect class="eye-light" x="13" y="9" width="2" height="2" />
<rect class="eye" x="13" y="9" width="1" height="1" />
<path class="beak" d="M4 11h7v1H4v-1z" />
<rect class="beak" x="5" y="12" width="6" height="1" />
<path class="body" d="M12 16h9v2h2v8h-2v2h-8v-2h-2v-8h1v-2z" />
<path class="belly" d="M12 17h4v8h-1v1h-2v-2h-1v-7z" />
<path class="wing" d="M16 16h7v3h-1v5h-2v2h-5v-9h1v-1z" />
<rect class="wing-light" x="17" y="17" width="3" height="1" />
<rect class="wing-light" x="19" y="20" width="3" height="1" />
<rect class="wing-light" x="16" y="23" width="3" height="1" />
<path class="tail" d="M21 24h3v2h2v3h-2v1h-4v-2h1v-4z" />
<rect class="claw" x="7" y="17" width="4" height="1" />
<rect class="claw" x="8" y="24" width="4" height="1" />
</symbol>
<symbol id="woodpecker-strike" viewBox="0 0 32 32">
<use href="#trunk" />
<path class="outline" d="M12 4h4v2h2v3h2v4h2v2h2v11h-2v3h-4v2h-7v-2H9v-3H7v-4h1v-5H6v-2H4v-3h4V9h2V6h2V4z" />
<path class="crest" d="M12 5h3v2h2v2h1v2h-5V9h-2V7h1V5z" />
<rect class="crest-dark" x="14" y="7" width="2" height="2" />
<path class="head" d="M9 9h8v2h2v4h-2v2h-7v-2H8v-4h1V9z" />
<rect class="cheek" x="9" y="13" width="4" height="3" />
<rect class="head-shadow" x="14" y="15" width="3" height="2" />
<rect class="eye-light" x="12" y="10" width="2" height="2" />
<rect class="eye" x="12" y="10" width="1" height="1" />
<path class="beak" d="M3 12h7v1H3v-1z" />
<rect class="beak" x="4" y="13" width="6" height="1" />
<path class="body" d="M11 17h9v2h2v7h-2v2h-8v-2h-2v-8h1v-1z" />
<path class="belly" d="M11 18h4v7h-1v1h-2v-2h-1v-6z" />
<path class="wing" d="M15 17h7v3h-1v4h-2v2h-5v-8h1v-1z" />
<rect class="wing-light" x="16" y="18" width="3" height="1" />
<rect class="wing-light" x="18" y="21" width="3" height="1" />
<rect class="wing-light" x="15" y="23" width="3" height="1" />
<path class="tail" d="M20 24h3v2h2v3h-2v1h-4v-2h1v-4z" />
<rect class="claw" x="7" y="17" width="4" height="1" />
<rect class="claw" x="8" y="24" width="4" height="1" />
</symbol>
<symbol id="woodpecker-impact" viewBox="0 0 32 32">
<use href="#woodpecker-strike" />
<rect class="chip" x="1" y="10" width="1" height="1" />
<rect class="chip" x="3" y="9" width="1" height="1" />
<rect class="chip" x="2" y="14" width="1" height="1" />
<rect class="bark-light" x="5" y="12" width="1" height="1" />
</symbol>
<symbol id="woodpecker-recoil" viewBox="0 0 32 32">
<use href="#trunk" />
<path class="outline" d="M15 2h4v2h2v3h2v4h2v2h2v13h-2v3h-4v2h-7v-2h-2v-3h-2v-4h1v-5H9v-2H7v-2h3V7h2V4h3V2z" />
<path class="crest" d="M15 3h3v2h2v2h1v2h-5V7h-2V5h1V3z" />
<rect class="crest-dark" x="17" y="5" width="2" height="2" />
<path class="head" d="M12 7h8v2h2v4h-2v2h-7v-2h-2V9h1V7z" />
<rect class="cheek" x="12" y="11" width="4" height="3" />
<rect class="eye-light" x="15" y="8" width="2" height="2" />
<rect class="eye" x="15" y="8" width="1" height="1" />
<path class="beak" d="M6 10h7v1H6v-1z" />
<rect class="beak" x="7" y="11" width="6" height="1" />
<path class="body" d="M14 16h9v2h2v8h-2v2h-8v-2h-2v-8h1v-2z" />
<path class="belly" d="M14 17h4v8h-1v1h-2v-2h-1v-7z" />
<path class="wing" d="M18 16h7v3h-1v5h-2v2h-5v-9h1v-1z" />
<rect class="wing-light" x="19" y="17" width="3" height="1" />
<rect class="wing-light" x="21" y="20" width="3" height="1" />
<rect class="wing-light" x="18" y="23" width="3" height="1" />
<path class="tail" d="M24 24h3v2h2v3h-2v1h-4v-2h1v-4z" />
<rect class="claw" x="8" y="17" width="4" height="1" />
<rect class="claw" x="9" y="24" width="4" height="1" />
</symbol>
<symbol id="woodpecker-settle" viewBox="0 0 32 32">
<use href="#woodpecker-rest" />
<rect class="crest-dark" x="14" y="5" width="3" height="1" />
<rect class="wing-light" x="21" y="19" width="2" height="1" />
<rect class="claw" x="8" y="18" width="3" height="1" />
<rect class="claw" x="9" y="25" width="3" height="1" />
</symbol>
</defs>
<use href="#woodpecker-rest" x="0" y="0" width="32" height="32" />
<use href="#woodpecker-lean" x="32" y="0" width="32" height="32" />
<use href="#woodpecker-strike" x="64" y="0" width="32" height="32" />
<use href="#woodpecker-impact" x="96" y="0" width="32" height="32" />
<use href="#woodpecker-recoil" x="128" y="0" width="32" height="32" />
<use href="#woodpecker-settle" x="160" y="0" width="32" height="32" />
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

@ -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,
},
];

@ -20,7 +20,7 @@ const SettingSection: React.FC<SettingSectionProps> = ({ title, description, chi
{typeof title === "string" ? <h3>{title}</h3> : title}
</div>
)}
{description && <p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>}
{description && <p className="w-full text-sm leading-6 text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>

@ -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) => {
<ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigateTo(Routes.ABOUT)}>
<InfoIcon className="size-4 text-muted-foreground" />
{t("common.about")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />

@ -21,6 +21,7 @@ const MainLayout = () => {
const location = useLocation();
const currentUser = useCurrentUser();
const [profileUserName, setProfileUserName] = useState<string | undefined>();
const showMemoExplorer = location.pathname !== Routes.ABOUT;
// Determine context based on current route
const context: MemoExplorerContext = useMemo(() => {
@ -69,12 +70,8 @@ const MainLayout = () => {
return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center md:flex-row md:items-start">
{!md && (
<MobileHeader>
<MemoExplorerDrawer {...memoExplorerProps} />
</MobileHeader>
)}
{md && (
{!md && <MobileHeader>{showMemoExplorer && <MemoExplorerDrawer {...memoExplorerProps} />}</MobileHeader>}
{md && showMemoExplorer && (
<div className={DESKTOP_EXPLORER_CLASS_NAME}>
<MemoExplorer className="px-3 py-6" {...memoExplorerProps} />
</div>

@ -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<TileSprite["name"], { label: string; description: string }> = {
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 (
<figure className="flex min-w-0 flex-1 basis-36 flex-col items-center gap-3 rounded-xl border border-border bg-muted/20 px-4 py-4 text-center">
<div
aria-hidden="true"
data-testid="about-bird-sprite"
className="relative size-16 shrink-0 overflow-hidden"
style={{ width: frameDisplayWidth, height: displayHeight }}
>
<img
className="about-bird-strip"
src={sprite.src}
alt=""
width={stripWidth}
height={sprite.frameHeight}
draggable={false}
style={{
display: "block",
width: displayWidth,
height: displayHeight,
maxWidth: "none",
imageRendering: "pixelated",
animationName: getBirdAnimationName(sprite),
animationDuration: `${sprite.duration}ms`,
animationTimingFunction: `steps(${sprite.frames})`,
animationIterationCount: "infinite",
}}
/>
</div>
<figcaption className="min-w-0">
<div className="flex flex-col items-center gap-1">
<h3 className="font-mono text-sm font-semibold text-foreground">{sprite.name}</h3>
<span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">{meta.label}</span>
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{meta.description}</p>
</figcaption>
</figure>
);
};
const About = () => {
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">
<style>{BIRD_KEYFRAMES_CSS}</style>
<div className="w-full px-4 sm:px-6">
<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} asChild variant="outline" size="lg">
<a href={link.href} target="_blank" rel="noreferrer">
{link.label}
<ExternalLinkIcon className="size-3.5" />
</a>
</Button>
))}
</div>
</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>
))}
</div>
</SettingGroup>
<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>
</div>
</div>
</section>
);
};
export default About;

@ -25,6 +25,7 @@ function lazyWithReload<T extends React.ComponentType>(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: <LandingRoute />,
children: [{ index: true, element: <Home /> }],
},
{ path: Routes.ABOUT, element: <About /> },
{ path: Routes.EXPLORE, element: <Explore /> },
{ path: "u/:username", element: <UserProfile /> },
{

@ -1,5 +1,6 @@
export const ROUTES = {
HOME: "/",
ABOUT: "/about",
ATTACHMENTS: "/attachments",
INBOX: "/inbox",
ARCHIVED: "/archived",

@ -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)

@ -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("<About>", () => {
it("renders the product story and current bird sprites", () => {
render(<About />);
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();
}
});
});

@ -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) {

@ -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);

@ -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);
}
});

Loading…
Cancel
Save