feat: 顶栏显示当前页名,去掉独立标题行;Logo回首页/页名回顶部
- 新增 PageTitleContext:页面上报标题,顶栏 brand 位显示当前页名(全部资料/ 热门资料/最新/官方/分类名/搜索/我的收藏),未上报则回退品牌名 - AssetStreamPage、Favorites 上报标题;移除资料流内单独的标题行,省出空间 - 顶栏拆分点击:Logo→首页(首页则回顶部);页名文字→回到当前页顶部 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
src/App.tsx
69
src/App.tsx
@@ -11,6 +11,7 @@ import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
|||||||
import { SearchPage } from "./pages/Search";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { PostRedirect } from "./pages/PostRedirect";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
import { ScrollToTop } from "./components/ScrollToTop";
|
import { ScrollToTop } from "./components/ScrollToTop";
|
||||||
|
import { PageTitleProvider } from "./components/PageTitleContext";
|
||||||
import Favorites from "./pages/Favorites";
|
import Favorites from "./pages/Favorites";
|
||||||
import { adminUiPrefix } from "./adminPaths";
|
import { adminUiPrefix } from "./adminPaths";
|
||||||
import { AdminRouteTree } from "./adminRouteTree";
|
import { AdminRouteTree } from "./adminRouteTree";
|
||||||
@@ -28,38 +29,46 @@ export default function App() {
|
|||||||
<AdminRouterModeProvider value="absolute">
|
<AdminRouterModeProvider value="absolute">
|
||||||
<ImageLightboxProvider>
|
<ImageLightboxProvider>
|
||||||
<VideoPlayerProvider>
|
<VideoPlayerProvider>
|
||||||
<BrowserRouter>
|
<PageTitleProvider>
|
||||||
<ScrollToTop />
|
<BrowserRouter>
|
||||||
<Routes>
|
<ScrollToTop />
|
||||||
<Route element={<PublicLayout />}>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route
|
<Route
|
||||||
path="/official-recommendations"
|
path="/categories"
|
||||||
element={<OfficialRecommendationsPage />}
|
element={<CategoriesPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/category/:slug"
|
path="/official-recommendations"
|
||||||
element={<CategoryPage />}
|
element={<OfficialRecommendationsPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route
|
||||||
<Route path="/resource/:id" element={<PostRedirect />} />
|
path="/category/:slug"
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
element={<CategoryPage />}
|
||||||
</Route>
|
/>
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
<Route
|
||||||
|
path="/resource/:id"
|
||||||
|
element={<PostRedirect />}
|
||||||
|
/>
|
||||||
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{adminEnabled ? (
|
{adminEnabled ? (
|
||||||
AdminRouteTree()
|
AdminRouteTree()
|
||||||
) : (
|
) : (
|
||||||
<Route
|
<Route
|
||||||
path={`${adminUiPrefix}/*`}
|
path={`${adminUiPrefix}/*`}
|
||||||
element={<Navigate to="/" replace />}
|
element={<Navigate to="/" replace />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</PageTitleProvider>
|
||||||
</VideoPlayerProvider>
|
</VideoPlayerProvider>
|
||||||
</ImageLightboxProvider>
|
</ImageLightboxProvider>
|
||||||
</AdminRouterModeProvider>
|
</AdminRouterModeProvider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PostScope } from "../types/post";
|
import type { PostScope } from "../types/post";
|
||||||
import { MessageStream } from "./messageStream/MessageStream";
|
import { MessageStream } from "./messageStream/MessageStream";
|
||||||
|
import { useSetPageTitle } from "./PageTitleContext";
|
||||||
|
|
||||||
type AssetStreamPageProps = {
|
type AssetStreamPageProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,9 +8,12 @@ type AssetStreamPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AssetStreamPage({ title, scope }: AssetStreamPageProps) {
|
export function AssetStreamPage({ title, scope }: AssetStreamPageProps) {
|
||||||
|
// Show the page name in the global header instead of a separate title row,
|
||||||
|
// saving vertical space.
|
||||||
|
useSetPageTitle(title);
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<MessageStream scope={scope} title={title} />
|
<MessageStream scope={scope} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/components/PageTitleContext.tsx
Normal file
41
src/components/PageTitleContext.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type PageTitleCtx = {
|
||||||
|
title: string | null;
|
||||||
|
setTitle: (title: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageTitleContext = createContext<PageTitleCtx | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a page publish its title to the global header so the header can show the
|
||||||
|
* current page name (e.g. "全部资料" / "热门资料") in place of the brand, avoiding
|
||||||
|
* a separate on-page title row. Pages that don't set one fall back to the brand.
|
||||||
|
*/
|
||||||
|
export function PageTitleProvider({ children }: PropsWithChildren) {
|
||||||
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<PageTitleContext.Provider value={{ title, setTitle }}>
|
||||||
|
{children}
|
||||||
|
</PageTitleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageTitle(): string | null {
|
||||||
|
return useContext(PageTitleContext)?.title ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish the current page's title; clears it again when the page unmounts. */
|
||||||
|
export function useSetPageTitle(title: string | null): void {
|
||||||
|
const setTitle = useContext(PageTitleContext)?.setTitle;
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle?.(title);
|
||||||
|
return () => setTitle?.(null);
|
||||||
|
}, [setTitle, title]);
|
||||||
|
}
|
||||||
@@ -6,17 +6,15 @@ import type { PostScope } from "../../types/post";
|
|||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
import { Skeleton } from "../Skeleton";
|
import { Skeleton } from "../Skeleton";
|
||||||
import { FilterChips } from "./FilterChips";
|
import { FilterChips } from "./FilterChips";
|
||||||
import { SectionHeader } from "../SectionHeader";
|
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||||
import { usePostStream } from "./hooks/usePostStream";
|
import { usePostStream } from "./hooks/usePostStream";
|
||||||
|
|
||||||
export type MessageStreamProps = {
|
export type MessageStreamProps = {
|
||||||
scope: PostScope;
|
scope: PostScope;
|
||||||
title?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MessageStream({ scope, title }: MessageStreamProps) {
|
export function MessageStream({ scope }: MessageStreamProps) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [sp, setSp] = useSearchParams();
|
const [sp, setSp] = useSearchParams();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
@@ -116,14 +114,9 @@ export function MessageStream({ scope, title }: MessageStreamProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
{/* Title + filters stay pinned below the global header so users always
|
{/* Filters stay pinned below the global header (which shows the page
|
||||||
see which page they're on and can switch filters while scrolling. */}
|
name) so users can switch filters while scrolling. */}
|
||||||
<div className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]">
|
<div className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]">
|
||||||
{title ? (
|
|
||||||
<div className="px-4 pb-1 pt-2 md:px-0">
|
|
||||||
<SectionHeader title={title} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
||||||
import { pageTransition } from "../motion";
|
import { pageTransition } from "../motion";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
|
import { usePageTitle } from "../components/PageTitleContext";
|
||||||
import { BackToTop } from "../components/BackToTop";
|
import { BackToTop } from "../components/BackToTop";
|
||||||
import { DocumentMeta } from "../components/DocumentMeta";
|
import { DocumentMeta } from "../components/DocumentMeta";
|
||||||
import { SearchPanel } from "../components/SearchPanel";
|
import { SearchPanel } from "../components/SearchPanel";
|
||||||
@@ -289,6 +290,8 @@ export function PublicLayout() {
|
|||||||
navIsActive(pathname, search, hash, which);
|
navIsActive(pathname, search, hash, which);
|
||||||
const isHome = pathname === "/";
|
const isHome = pathname === "/";
|
||||||
const footerInContentFlow = pathname === "/browse";
|
const footerInContentFlow = pathname === "/browse";
|
||||||
|
// Current page name shown in the header brand slot (falls back to the brand).
|
||||||
|
const pageTitle = usePageTitle();
|
||||||
const popularHref = "/browse?sort=popular";
|
const popularHref = "/browse?sort=popular";
|
||||||
|
|
||||||
const goSearch = () => {
|
const goSearch = () => {
|
||||||
@@ -365,20 +368,29 @@ export function PublicLayout() {
|
|||||||
<DocumentMeta />
|
<DocumentMeta />
|
||||||
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
||||||
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
||||||
<Link
|
<div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold">
|
||||||
to="/"
|
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||||
className="flex h-8 shrink-0 items-center gap-2 rounded-sm text-[20px] font-black leading-5 tracking-tight text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
<Link
|
||||||
aria-label={t("brand")}
|
to="/"
|
||||||
onClick={(e) => {
|
aria-label={t("brand")}
|
||||||
if (isHome) {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
if (isHome) {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
e.preventDefault();
|
||||||
}
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
<span className="truncate text-ark-gold">{t("brand")}</span>
|
>
|
||||||
</Link>
|
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
className="truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
|
>
|
||||||
|
{pageTitle || t("brand")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
|
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
|
||||||
<button
|
<button
|
||||||
@@ -441,21 +453,29 @@ export function PublicLayout() {
|
|||||||
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-0">
|
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-0">
|
||||||
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
||||||
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
||||||
<Link
|
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">
|
||||||
to="/"
|
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||||
className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
<Link
|
||||||
onClick={(e) => {
|
to="/"
|
||||||
if (isHome) {
|
aria-label={t("brand")}
|
||||||
e.preventDefault();
|
onClick={(e) => {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
if (isHome) {
|
||||||
}
|
e.preventDefault();
|
||||||
}}
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
>
|
}
|
||||||
<ArkLogoMark className="h-10 w-10 shrink-0" />
|
}}
|
||||||
<span className="max-w-[8rem] truncate text-ark-gold sm:inline">
|
className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
{t("brand")}
|
>
|
||||||
</span>
|
<ArkLogoMark className="h-10 w-10 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
className="max-w-[10rem] truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg sm:inline"
|
||||||
|
>
|
||||||
|
{pageTitle || t("brand")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
|
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { Heart } from "lucide-react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
|
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||||
|
|
||||||
export default function Favorites() {
|
export default function Favorites() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Show "我的收藏" in the global header, consistent with the other pages.
|
||||||
|
useSetPageTitle(t("favorites"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
|
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user