terry-staging #11

Merged
terry merged 37 commits from terry-staging into main 2026-05-29 19:29:58 +00:00
6 changed files with 140 additions and 70 deletions
Showing only changes of commit 4a20d80f68 - Show all commits

View File

@@ -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,13 +29,17 @@ export default function App() {
<AdminRouterModeProvider value="absolute"> <AdminRouterModeProvider value="absolute">
<ImageLightboxProvider> <ImageLightboxProvider>
<VideoPlayerProvider> <VideoPlayerProvider>
<PageTitleProvider>
<BrowserRouter> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<Routes> <Routes>
<Route element={<PublicLayout />}> <Route element={<PublicLayout />}>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/browse" element={<Browse />} /> <Route path="/browse" element={<Browse />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route
path="/categories"
element={<CategoriesPage />}
/>
<Route <Route
path="/official-recommendations" path="/official-recommendations"
element={<OfficialRecommendationsPage />} element={<OfficialRecommendationsPage />}
@@ -44,7 +49,10 @@ export default function App() {
element={<CategoryPage />} element={<CategoryPage />}
/> />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
<Route path="/resource/:id" element={<PostRedirect />} /> <Route
path="/resource/:id"
element={<PostRedirect />}
/>
<Route path="/favorites" element={<Favorites />} /> <Route path="/favorites" element={<Favorites />} />
</Route> </Route>
@@ -60,6 +68,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</PageTitleProvider>
</VideoPlayerProvider> </VideoPlayerProvider>
</ImageLightboxProvider> </ImageLightboxProvider>
</AdminRouterModeProvider> </AdminRouterModeProvider>

View File

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

View 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]);
}

View File

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

View File

@@ -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,9 +368,10 @@ 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">
<div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold">
{/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to="/" to="/"
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]"
aria-label={t("brand")} aria-label={t("brand")}
onClick={(e) => { onClick={(e) => {
if (isHome) { if (isHome) {
@@ -375,10 +379,18 @@ export function PublicLayout() {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
}} }}
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]"
> >
<ArkLogoMark className="h-8 w-8 shrink-0" /> <ArkLogoMark className="h-8 w-8 shrink-0" />
<span className="truncate text-ark-gold">{t("brand")}</span>
</Link> </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">
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">
{/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to="/" to="/"
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" aria-label={t("brand")}
onClick={(e) => { onClick={(e) => {
if (isHome) { if (isHome) {
e.preventDefault(); e.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
}} }}
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"
> >
<ArkLogoMark className="h-10 w-10 shrink-0" /> <ArkLogoMark className="h-10 w-10 shrink-0" />
<span className="max-w-[8rem] truncate text-ark-gold sm:inline">
{t("brand")}
</span>
</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"

View File

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