terry-staging #11
69
src/App.tsx
69
src/App.tsx
@@ -11,6 +11,7 @@ import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
||||
import { SearchPage } from "./pages/Search";
|
||||
import { PostRedirect } from "./pages/PostRedirect";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { PageTitleProvider } from "./components/PageTitleContext";
|
||||
import Favorites from "./pages/Favorites";
|
||||
import { adminUiPrefix } from "./adminPaths";
|
||||
import { AdminRouteTree } from "./adminRouteTree";
|
||||
@@ -28,38 +29,46 @@ export default function App() {
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<ImageLightboxProvider>
|
||||
<VideoPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route
|
||||
path="/official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/resource/:id" element={<PostRedirect />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
</Route>
|
||||
<PageTitleProvider>
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route
|
||||
path="/categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route
|
||||
path="/resource/:id"
|
||||
element={<PostRedirect />}
|
||||
/>
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
</Route>
|
||||
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</PageTitleProvider>
|
||||
</VideoPlayerProvider>
|
||||
</ImageLightboxProvider>
|
||||
</AdminRouterModeProvider>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PostScope } from "../types/post";
|
||||
import { MessageStream } from "./messageStream/MessageStream";
|
||||
import { useSetPageTitle } from "./PageTitleContext";
|
||||
|
||||
type AssetStreamPageProps = {
|
||||
title: string;
|
||||
@@ -7,9 +8,12 @@ type 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 (
|
||||
<section>
|
||||
<MessageStream scope={scope} title={title} />
|
||||
<MessageStream scope={scope} />
|
||||
</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 { Skeleton } from "../Skeleton";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||
import { usePostStream } from "./hooks/usePostStream";
|
||||
|
||||
export type MessageStreamProps = {
|
||||
scope: PostScope;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function MessageStream({ scope, title }: MessageStreamProps) {
|
||||
export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const { hash } = useLocation();
|
||||
@@ -116,14 +114,9 @@ export function MessageStream({ scope, title }: MessageStreamProps) {
|
||||
|
||||
return (
|
||||
<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
|
||||
see which page they're on and can switch filters while scrolling. */}
|
||||
{/* Filters stay pinned below the global header (which shows the page
|
||||
name) so users can switch filters while scrolling. */}
|
||||
<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)} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
||||
import { pageTransition } from "../motion";
|
||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||
import { usePageTitle } from "../components/PageTitleContext";
|
||||
import { BackToTop } from "../components/BackToTop";
|
||||
import { DocumentMeta } from "../components/DocumentMeta";
|
||||
import { SearchPanel } from "../components/SearchPanel";
|
||||
@@ -289,6 +290,8 @@ export function PublicLayout() {
|
||||
navIsActive(pathname, search, hash, which);
|
||||
const isHome = pathname === "/";
|
||||
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 goSearch = () => {
|
||||
@@ -365,20 +368,29 @@ export function PublicLayout() {
|
||||
<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">
|
||||
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
||||
<Link
|
||||
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")}
|
||||
onClick={(e) => {
|
||||
if (isHome) {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
||||
<span className="truncate text-ark-gold">{t("brand")}</span>
|
||||
</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">
|
||||
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||
<Link
|
||||
to="/"
|
||||
aria-label={t("brand")}
|
||||
onClick={(e) => {
|
||||
if (isHome) {
|
||||
e.preventDefault();
|
||||
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" />
|
||||
</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]">
|
||||
<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">
|
||||
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
||||
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
||||
<Link
|
||||
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"
|
||||
onClick={(e) => {
|
||||
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">
|
||||
{t("brand")}
|
||||
</span>
|
||||
</Link>
|
||||
<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
|
||||
to="/"
|
||||
aria-label={t("brand")}
|
||||
onClick={(e) => {
|
||||
if (isHome) {
|
||||
e.preventDefault();
|
||||
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" />
|
||||
</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
|
||||
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 { useI18n } from "../../i18n";
|
||||
import { Reveal } from "../../motion";
|
||||
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useI18n();
|
||||
// Show "我的收藏" in the global header, consistent with the other pages.
|
||||
useSetPageTitle(t("favorites"));
|
||||
|
||||
return (
|
||||
<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