feat(navigation): add favorite entry and Menu tab

This commit is contained in:
Codex
2026-07-22 19:31:06 +08:00
parent 4e71dbd5f8
commit 163ba78f06
23 changed files with 794 additions and 118 deletions
@@ -82,6 +82,7 @@ describe("core Tailwind components", () => {
privateZoomLabel="Maya Private Zoom"
onChatClick={() => undefined}
onPrivateZoomClick={() => undefined}
onMenuClick={() => undefined}
/>,
);
const privateZoomHtml = renderToStaticMarkup(
@@ -91,6 +92,7 @@ describe("core Tailwind components", () => {
privateZoomLabel="Maya Private Zoom"
onChatClick={() => undefined}
onPrivateZoomClick={() => undefined}
onMenuClick={() => undefined}
/>,
);
@@ -107,5 +109,7 @@ describe("core Tailwind components", () => {
expect(privateZoomHtml).toContain(
'data-analytics-key="navigation.private_zoom"',
);
expect(chatHtml).toContain('data-analytics-key="navigation.menu"');
expect(chatHtml).toContain("<span>Menu</span>");
});
});
@@ -4,7 +4,7 @@
left: 50%;
z-index: 30;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
box-sizing: border-box;
width: min(100vw, var(--app-max-width, 540px));
min-height: calc(
+15 -2
View File
@@ -1,10 +1,10 @@
"use client";
import { Camera, MessageCircle } from "lucide-react";
import { Camera, Menu as MenuIcon, MessageCircle } from "lucide-react";
import styles from "./app-bottom-nav.module.css";
export type AppBottomNavItem = "chat" | "privateZoom";
export type AppBottomNavItem = "chat" | "privateZoom" | "menu";
export type AppBottomNavVariant = "warm" | "dark";
export interface AppBottomNavProps {
@@ -13,6 +13,7 @@ export interface AppBottomNavProps {
privateZoomLabel: string;
onChatClick: () => void;
onPrivateZoomClick: () => void;
onMenuClick: () => void;
}
export function AppBottomNav({
@@ -21,6 +22,7 @@ export function AppBottomNav({
privateZoomLabel,
onChatClick,
onPrivateZoomClick,
onMenuClick,
}: AppBottomNavProps) {
return (
<nav className={getRootClass(variant)} aria-label="Primary navigation">
@@ -46,6 +48,17 @@ export function AppBottomNav({
<Camera size={20} aria-hidden="true" />
<span>{privateZoomLabel}</span>
</button>
<button
type="button"
data-analytics-key="navigation.menu"
data-analytics-label="Menu navigation"
className={getButtonClass(activeItem === "menu")}
aria-current={activeItem === "menu" ? "page" : undefined}
onClick={onMenuClick}
>
<MenuIcon size={20} aria-hidden="true" />
<span>Menu</span>
</button>
</nav>
);
}
@@ -0,0 +1,96 @@
.wrap {
position: relative;
display: inline-flex;
flex: 0 0 auto;
justify-content: flex-end;
}
.button {
display: inline-flex;
min-width: var(--responsive-icon-button-size, 42px);
height: var(--responsive-icon-button-size, 42px);
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
padding: 0 11px;
border: 1px solid var(--favorite-border);
border-radius: 999px;
background: var(--favorite-background);
color: var(--favorite-color);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
line-height: 1;
box-shadow: var(--favorite-shadow);
backdrop-filter: blur(16px);
transition: background 160ms ease, transform 160ms ease,
box-shadow 160ms ease;
-webkit-tap-highlight-color: transparent;
}
.dark {
--favorite-background: rgba(18, 15, 24, 0.72);
--favorite-border: rgba(255, 255, 255, 0.22);
--favorite-color: #ffffff;
--favorite-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
}
.light {
--favorite-background: rgba(255, 255, 255, 0.88);
--favorite-border: rgba(43, 27, 34, 0.1);
--favorite-color: #34272c;
--favorite-shadow: 0 10px 24px rgba(74, 48, 58, 0.1);
}
.favorite {
width: var(--responsive-icon-button-size, 42px);
padding: 0;
}
.download {
color: #ffffff;
background: linear-gradient(135deg, #ff76ab, #f84d96);
border-color: rgba(255, 255, 255, 0.42);
box-shadow: 0 10px 25px rgba(248, 77, 150, 0.3);
}
.saved {
color: #f84d96;
}
.button:hover {
transform: translateY(-1px);
box-shadow: 0 13px 30px rgba(0, 0, 0, 0.2);
}
.button:active {
transform: translateY(1px) scale(0.98);
}
.button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
.light:focus-visible {
outline-color: #f84d96;
}
.hint {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 45;
width: max-content;
max-width: min(72vw, 250px);
padding: 8px 10px;
border-radius: 10px;
background: rgba(22, 18, 25, 0.94);
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
}
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { Download, Star } from "lucide-react";
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
import {
hasPersistedFavoriteEntryIntent,
resolveFavoriteEntryMode,
type FavoriteEntryMode,
} from "@/lib/navigation/favorite_entry";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import { pwaUtil } from "@/utils/pwa";
import styles from "./favorite-entry-button.module.css";
export interface FavoriteEntryButtonProps {
characterSlug: string;
tone?: "dark" | "light";
}
export function FavoriteEntryButton({
characterSlug,
tone = "dark",
}: FavoriteEntryButtonProps) {
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
const [installHint, setInstallHint] = useState(false);
useEffect(() => {
const updateMode = () => {
setMode(
resolveFavoriteEntryMode({
hasFavoriteIntent: hasPersistedFavoriteEntryIntent(),
isAndroid: PlatformDetector.isAndroid(),
isIOS: PlatformDetector.isIOS(),
isInAppBrowser: BrowserDetector.isInAppBrowser(),
isInstalled: pwaUtil.isInstalled(),
}),
);
};
pwaUtil.prepareInstallPrompt();
updateMode();
const unsubscribe = pwaUtil.subscribe(updateMode);
return unsubscribe;
}, []);
const handleClick = async (): Promise<void> => {
if (mode === "favorite") {
await openChatInExternalBrowser({ characterSlug });
return;
}
if (mode === "download") {
pwaUtil.prepareInstallPrompt();
const result = await pwaUtil.install();
if (result === "accepted" || pwaUtil.isInstalled()) {
setMode("saved");
setInstallHint(false);
return;
}
if (result === "unavailable") setInstallHint(true);
}
};
const label = getFavoriteEntryLabel(mode);
return (
<div className={styles.wrap}>
<button
type="button"
data-analytics-key={`favorite.${mode}`}
data-analytics-label={label}
className={[styles.button, styles[tone], styles[mode]]
.filter(Boolean)
.join(" ")}
aria-label={label}
aria-pressed={mode === "saved"}
onClick={() => void handleClick()}
>
{mode === "download" ? (
<Download size={16} strokeWidth={2.4} aria-hidden="true" />
) : (
<Star
size={21}
strokeWidth={2.3}
fill={mode === "saved" ? "currentColor" : "none"}
aria-hidden="true"
/>
)}
{mode === "favorite" ? null : <span>{label}</span>}
</button>
{installHint ? (
<span className={styles.hint} role="status">
Chrome menu Add to Home screen
</span>
) : null}
</div>
);
}
function getFavoriteEntryLabel(mode: FavoriteEntryMode): string {
if (mode === "download") return "Download";
if (mode === "saved") return "Saved";
return "Save CozSweet";
}
+1
View File
@@ -5,6 +5,7 @@
export * from "./app-bottom-nav";
export * from "./bottom-sheet";
export * from "./checkbox";
export * from "./favorite-entry-button";
export * from "./loading-indicator";
export * from "./mobile-shell";
export * from "./page-loading-fallback";