mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 19:07:56 +09:00
优化页面数据加载并完善多语言 SEO、新闻回退、主题首屏及自动化回归测试
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { HttpStatusPage } from "@/components/status/http-status-page";
|
||||
|
||||
export const metadata = { title: "403", robots: { index: false, follow: false } };
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return <HttpStatusPage code={403} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpStatusPage } from "@/components/status/http-status-page";
|
||||
|
||||
export const metadata = { title: "500", robots: { index: false, follow: false } };
|
||||
|
||||
export default function InternalServerErrorPage() {
|
||||
return <HttpStatusPage code={500} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpStatusPage } from "@/components/status/http-status-page";
|
||||
|
||||
export const metadata = { title: "502", robots: { index: false, follow: false } };
|
||||
|
||||
export default function BadGatewayPage() {
|
||||
return <HttpStatusPage code={502} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpStatusPage } from "@/components/status/http-status-page";
|
||||
|
||||
export const metadata = { title: "503", robots: { index: false, follow: false } };
|
||||
|
||||
export default function ServiceUnavailablePage() {
|
||||
return <HttpStatusPage code={503} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpStatusPage } from "@/components/status/http-status-page";
|
||||
|
||||
export const metadata = { title: "504", robots: { index: false, follow: false } };
|
||||
|
||||
export default function GatewayTimeoutPage() {
|
||||
return <HttpStatusPage code={504} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { HomePageContent } from "@/components/home/home-page-content";
|
||||
import { getNewsCatalog } from "@/data/news";
|
||||
import { getLatestNews } from "@/data/news";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata = pageMetadata("zh-Hans", "/");
|
||||
|
||||
export default async function Home() {
|
||||
const newsByLocale = await getNewsCatalog();
|
||||
|
||||
return <HomePageContent newsByLocale={newsByLocale} />;
|
||||
return <HomePageContent locale="zh-Hans" news={await getLatestNews("zh-Hans")} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/403/page";
|
||||
export { default, metadata } from "@/app/(legacy)/403/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/500/page";
|
||||
export { default, metadata } from "@/app/(legacy)/500/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/502/page";
|
||||
export { default, metadata } from "@/app/(legacy)/502/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/503/page";
|
||||
export { default, metadata } from "@/app/(legacy)/503/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/504/page";
|
||||
export { default, metadata } from "@/app/(legacy)/504/page";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AboutPage } from "@/components/about/about-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).footer.columns[1].links[0] };
|
||||
return pageMetadata(locale, "/about", { title: getLocaleMessages(locale).footer.columns[1].links[0] });
|
||||
}
|
||||
|
||||
export default async function AboutRoute({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).header.nav[3] };
|
||||
return pageMetadata(locale, "/city", { title: getLocaleMessages(locale).header.nav[3] });
|
||||
}
|
||||
|
||||
export default async function CityPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).footer.columns[1].links[2] };
|
||||
return pageMetadata(locale, "/join-us", { title: getLocaleMessages(locale).footer.columns[1].links[2] });
|
||||
}
|
||||
|
||||
export default async function JoinUsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteShell } from "@/components/layout/site-shell";
|
||||
import { getLocaleMessages, locales, matchLocale } from "@/lib/i18n";
|
||||
import { getLocaleMessages, locales, isLocale } from "@/lib/i18n";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
@@ -10,9 +10,8 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const canonicalLocale = matchLocale(locale);
|
||||
if (!canonicalLocale) notFound();
|
||||
const title = getLocaleMessages(canonicalLocale).common.siteTitle;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const title = getLocaleMessages(locale).common.siteTitle;
|
||||
return { title: { default: title, template: `%s | ${title}` }, icons: { icon: "/icon/icon.svg" } };
|
||||
}
|
||||
|
||||
@@ -21,7 +20,6 @@ export default async function LocaleLayout({ children, params }: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const canonicalLocale = matchLocale(locale);
|
||||
if (!canonicalLocale) notFound();
|
||||
return <SiteShell locale={canonicalLocale}>{children}</SiteShell>;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <SiteShell locale={locale}>{children}</SiteShell>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FriendlyLinksPage } from "@/components/links/friendly-links-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).footer.legal[2] };
|
||||
return pageMetadata(locale, "/links", { title: getLocaleMessages(locale).footer.legal[2] });
|
||||
}
|
||||
|
||||
export default async function LinksPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MembersPage } from "@/components/members/members-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).footer.columns[1].links[1] };
|
||||
return pageMetadata(locale, "/members", { title: getLocaleMessages(locale).footer.columns[1].links[1] });
|
||||
}
|
||||
|
||||
export default async function MembersRoute({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { getNewsBySlug, getNewsList } from "@/data/news";
|
||||
import { NewsDetailClient } from "@/components/news/news-detail-client";
|
||||
import { isLocale } from "@/lib/i18n";
|
||||
import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return (await getNewsList()).map((item) => ({ slug: item.slug }));
|
||||
@@ -11,11 +12,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
const { slug, locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const item = await getNewsBySlug(slug, locale);
|
||||
return { title: item?.title ?? "新闻" };
|
||||
if (!item) notFound();
|
||||
return pageMetadata(locale, item.href, { title: item.title, description: descriptionFromMarkdown(item.body), article: true });
|
||||
}
|
||||
|
||||
export default async function NewsDetailPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
|
||||
const { slug, locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <NewsDetailClient item={await getNewsBySlug(slug, locale)} locale={locale} />;
|
||||
const item = await getNewsBySlug(slug, locale);
|
||||
if (!item) notFound();
|
||||
return <NewsDetailClient item={item} locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getNewsList } from "@/data/news";
|
||||
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
|
||||
@@ -8,7 +9,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).common.news };
|
||||
return pageMetadata(locale, "/news", { title: getLocaleMessages(locale).common.news });
|
||||
}
|
||||
|
||||
export default async function NewsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
export { default } from "@/app/(legacy)/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { HomePageContent } from "@/components/home/home-page-content";
|
||||
import { getLatestNews } from "@/data/news";
|
||||
import { isLocale } from "@/lib/i18n";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return pageMetadata(locale, "/");
|
||||
}
|
||||
|
||||
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <HomePageContent locale={locale} news={await getLatestNews(locale)} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PolicyPageContent } from "@/components/policy/policy-page-content";
|
||||
import { getPolicyCatalog, getPolicySlugs } from "@/data/policy";
|
||||
import { getPolicyDocument, getPolicySlugs } from "@/data/policy";
|
||||
import { isLocale } from "@/lib/i18n";
|
||||
import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
@@ -13,14 +14,15 @@ export function generateStaticParams() {
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string; locale: string }> }): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const catalog = await getPolicyCatalog(slug);
|
||||
return { title: catalog[locale]?.title ?? "法律信息" };
|
||||
const document = await getPolicyDocument(slug, locale);
|
||||
if (!document) notFound();
|
||||
return pageMetadata(locale, `/policy/${slug}`, { title: document.title, description: descriptionFromMarkdown(document.body) });
|
||||
}
|
||||
|
||||
export default async function PolicyPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
|
||||
const { slug, locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const catalog = await getPolicyCatalog(slug);
|
||||
if (!catalog["zh-Hans"]) notFound();
|
||||
return <PolicyPageContent catalog={catalog} />;
|
||||
const document = await getPolicyDocument(slug, locale);
|
||||
if (!document) notFound();
|
||||
return <PolicyPageContent document={document} locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
|
||||
import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return { title: getLocaleMessages(locale).header.nav[2] };
|
||||
return pageMetadata(locale, "/railway", { title: getLocaleMessages(locale).header.nav[2] });
|
||||
}
|
||||
|
||||
export default async function RailwayPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteOrigin } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return { rules: { userAgent: "*", allow: "/" }, sitemap: `${siteOrigin}/sitemap.xml` };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getNewsList } from "@/data/news";
|
||||
import { getPolicySlugs } from "@/data/policy";
|
||||
import { locales } from "@/lib/i18n";
|
||||
import { languageAlternates, localizedUrl } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const paths = ["/", "/news", "/about", "/railway", "/city", "/join-us", "/members", "/links",
|
||||
...getPolicySlugs().map(({ slug }) => `/policy/${slug}`),
|
||||
...(await getNewsList()).map(({ href }) => href),
|
||||
];
|
||||
return paths.flatMap((href) => locales.map((locale) => ({
|
||||
url: localizedUrl(href, locale),
|
||||
alternates: { languages: languageAlternates(href) },
|
||||
})));
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
import { PageActionCards } from "@/components/layout/page-action-cards";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { NewsItem } from "@/data/news";
|
||||
import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
import type { NewsSummary } from "@/data/news";
|
||||
import { withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
|
||||
const homeFeatureCopy: Record<Locale, {
|
||||
railway: { title: [string, string]; description: [string, string] };
|
||||
@@ -30,40 +26,8 @@ const homeFeatureCopy: Record<Locale, {
|
||||
},
|
||||
};
|
||||
|
||||
export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale, NewsItem[]> }) {
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
syncLocale(currentLocale);
|
||||
applyLocale(currentLocale);
|
||||
|
||||
const onLocaleChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ locale?: Locale }>).detail;
|
||||
if (detail?.locale) {
|
||||
setLocale(detail.locale);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorage = () => {
|
||||
const nextLocale = getInitialLocale();
|
||||
syncLocale(nextLocale);
|
||||
applyLocale(nextLocale);
|
||||
};
|
||||
|
||||
window.addEventListener("kr-locale-change", onLocaleChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", onLocaleChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
export function HomePageContent({ news, locale }: { news: NewsSummary[]; locale: Locale }) {
|
||||
const t = getLocaleMessages(locale);
|
||||
const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-Hans"] ?? [];
|
||||
const features = homeFeatureCopy[locale];
|
||||
|
||||
return (
|
||||
@@ -116,7 +80,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
|
||||
</div>
|
||||
|
||||
<div className="news-grid" aria-label={t.home.latest}>
|
||||
{visibleNews.slice(0, 3).map((post) => (
|
||||
{news.map((post) => (
|
||||
<article key={post.id} className="news-card">
|
||||
<Link href={withLocale(post.href, locale)} className="news-card-link" aria-label={post.title}>
|
||||
<div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { siteOrigin } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteOrigin),
|
||||
title: {
|
||||
default: "基沃托斯铁道公团 KivotosRailway",
|
||||
template: "%s | 基沃托斯铁道公团 KivotosRailway",
|
||||
},
|
||||
description: "Kivotos Railway website",
|
||||
description: "基沃托斯铁道公团官方网站,提供 Minecraft 铁路与城市建设、新闻、团队和加入信息。",
|
||||
icons: { icon: "/icon/icon.svg" },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
|
||||
import { useState } from "react";
|
||||
import { withLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
|
||||
|
||||
export function SiteHeader() {
|
||||
const locale = useRouteLocale();
|
||||
@@ -22,44 +22,6 @@ export function SiteHeader() {
|
||||
router.replace(withLocale(href, nextLocale));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateDocumentTitle = (nextLocale: Locale) => {
|
||||
const messages = getLocaleMessages(nextLocale);
|
||||
const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
|
||||
const currentTitle = document.title.split(" | ")[0];
|
||||
const pageLabel = pathname === "/"
|
||||
? ""
|
||||
: pathname === "/news"
|
||||
? messages.common.news
|
||||
: currentTitle;
|
||||
|
||||
document.title = pageLabel ? `${pageLabel} | ${messages.common.siteTitle}` : messages.common.siteTitle;
|
||||
};
|
||||
|
||||
const syncLocale = (nextLocale: Locale) => updateDocumentTitle(nextLocale);
|
||||
|
||||
const handleLocaleChange = (event: Event) => {
|
||||
const nextLocale = (event as CustomEvent<{ locale?: Locale }>).detail?.locale ?? getInitialLocale();
|
||||
syncLocale(nextLocale);
|
||||
};
|
||||
|
||||
const handleStorage = () => {
|
||||
syncLocale(getInitialLocale());
|
||||
};
|
||||
|
||||
const currentLocale = getInitialLocale();
|
||||
syncLocale(currentLocale);
|
||||
applyLocale(currentLocale);
|
||||
|
||||
window.addEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", handleLocaleChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,47 +3,13 @@
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { applyTheme, getInitialThemePreference, type ThemePreference } from "@/lib/theme";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
|
||||
type ThemePreference = "light" | "system" | "dark";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
const themeCookieName = "kr-theme";
|
||||
|
||||
function getThemeCookie(): ThemePreference | null {
|
||||
const value = document.cookie
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith(`${themeCookieName}=`))
|
||||
?.slice(themeCookieName.length + 1);
|
||||
|
||||
return value === "light" || value === "system" || value === "dark" ? value : null;
|
||||
}
|
||||
|
||||
function getInitialThemePreference(): ThemePreference {
|
||||
if (typeof window === "undefined") return "system";
|
||||
|
||||
const cookieTheme = getThemeCookie();
|
||||
if (cookieTheme) return cookieTheme;
|
||||
|
||||
const savedTheme = window.localStorage.getItem("kr-theme");
|
||||
return savedTheme === "light" || savedTheme === "system" || savedTheme === "dark" ? savedTheme : "system";
|
||||
}
|
||||
|
||||
function resolveTheme(preference: ThemePreference): ResolvedTheme {
|
||||
if (preference !== "system") return preference;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const theme = resolveTheme(preference);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
window.localStorage.setItem("kr-theme", preference);
|
||||
document.cookie = `${themeCookieName}=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const pathname = usePathname();
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>("system");
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
|
||||
@@ -70,7 +36,7 @@ export function ThemeToggle() {
|
||||
window.removeEventListener("storage", syncTheme);
|
||||
mediaQuery.removeEventListener("change", syncSystemTheme);
|
||||
};
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
const messages = getLocaleMessages(locale);
|
||||
const options: { theme: ThemePreference; label: string; icon: string }[] = [
|
||||
|
||||
@@ -5,13 +5,9 @@ import { markdownToHtml } from "@/lib/markdown";
|
||||
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
|
||||
import { NewsShare } from "@/components/news/news-share";
|
||||
|
||||
export function NewsDetailClient({ item, locale }: { item: NewsItem | null; locale: Locale }) {
|
||||
export function NewsDetailClient({ item, locale }: { item: NewsItem; locale: Locale }) {
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
if (!item) {
|
||||
return <main className="news-detail-page"><div className="news-detail-inner"><p>未找到该文章。</p><Link href={withLocale("/news", locale)}>{messages.common.backToNews}</Link></div></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="news-detail-page">
|
||||
<div className="news-detail-inner">
|
||||
@@ -20,7 +16,7 @@ export function NewsDetailClient({ item, locale }: { item: NewsItem | null; loca
|
||||
<p className="news-detail-tag">{item.category}</p>
|
||||
<h1>{item.title}</h1>
|
||||
<time>{item.date}</time>
|
||||
{locale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
|
||||
{item.contentLocale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
|
||||
<div className="news-detail-cover" style={{ backgroundImage: `url(${item.cover})` }} />
|
||||
<div className="news-detail-body" dangerouslySetInnerHTML={{ __html: markdownToHtml(item.body) }} />
|
||||
<NewsShare title={item.title} />
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { NewsItem } from "@/data/news";
|
||||
import type { NewsSummary } from "@/data/news";
|
||||
import { getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
export function NewsList({ news }: { news: NewsItem[] }) {
|
||||
export function NewsList({ news }: { news: NewsSummary[] }) {
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
import { getAiTranslationNotice, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
import type { PolicyDocument } from "@/data/policy";
|
||||
import { PageLocation } from "@/components/layout/page-location";
|
||||
import { PageTitleHero } from "@/components/layout/page-title-hero";
|
||||
|
||||
type PolicyCatalog = Partial<Record<Locale, PolicyDocument>>;
|
||||
|
||||
export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) {
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
const document = catalog[locale] ?? catalog["zh-Hans"];
|
||||
const isTranslated = locale !== "zh-Hans" && Boolean(catalog[locale]);
|
||||
export function PolicyPageContent({ document, locale }: { document: PolicyDocument; locale: Locale }) {
|
||||
const isTranslated = document.contentLocale !== "zh-Hans";
|
||||
const messages = getLocaleMessages(locale);
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => setLocale(getInitialLocale());
|
||||
syncLocale();
|
||||
window.addEventListener("kr-locale-change", syncLocale);
|
||||
window.addEventListener("storage", syncLocale);
|
||||
return () => {
|
||||
window.removeEventListener("kr-locale-change", syncLocale);
|
||||
window.removeEventListener("storage", syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="title-page policy-page">
|
||||
|
||||
+40
-29
@@ -14,8 +14,16 @@ export type NewsItem = {
|
||||
tags: string[];
|
||||
markdownFile: string;
|
||||
body: string;
|
||||
contentLocale: Locale;
|
||||
};
|
||||
|
||||
export type NewsSummary = Pick<NewsItem, "id" | "slug" | "title" | "date" | "cover" | "href" | "category">;
|
||||
|
||||
function summarize(item: NewsItem): NewsSummary {
|
||||
const { id, slug, title, date, cover, href, category } = item;
|
||||
return { id, slug, title, date, cover, href, category };
|
||||
}
|
||||
|
||||
type NewsFrontmatter = {
|
||||
title?: string;
|
||||
translationLabel?: string;
|
||||
@@ -66,7 +74,7 @@ function stripFrontmatter(markdown: string): string {
|
||||
|
||||
const DEFAULT_NEWS_COVER = "https://assets.kivotos.cc/image/2025/05/01/681397ba4e763.png";
|
||||
|
||||
function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string): NewsItem | null {
|
||||
function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string, locale: Locale): NewsItem | null {
|
||||
const title = raw.title?.trim();
|
||||
const date = raw.date?.trim() || "未注明日期";
|
||||
const category = (Array.isArray(raw.category) ? raw.category[0] : raw.category)?.trim() || "未分类";
|
||||
@@ -90,11 +98,12 @@ function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string)
|
||||
tags,
|
||||
markdownFile: `src/data/news/posts/${fileName}`,
|
||||
body,
|
||||
contentLocale: locale,
|
||||
};
|
||||
}
|
||||
|
||||
async function readNewsList(locale: Locale): Promise<NewsItem[]> {
|
||||
const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]);
|
||||
async function readNewsList(root: string, locale: Locale): Promise<NewsItem[]> {
|
||||
const directory = path.join(root, localeContentDirectory[locale]);
|
||||
|
||||
try {
|
||||
const files = (await fs.readdir(directory))
|
||||
@@ -107,7 +116,7 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
return normalizeNewsItem(fileName, frontmatter, body);
|
||||
return normalizeNewsItem(fileName, frontmatter, body, locale);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -117,34 +126,36 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
|
||||
const firstTime = Date.parse(first.date);
|
||||
return (Number.isNaN(secondTime) ? 0 : secondTime) - (Number.isNaN(firstTime) ? 0 : firstTime);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNewsList(locale: Locale = "zh-Hans"): Promise<NewsItem[]> {
|
||||
const items = await readNewsList(locale);
|
||||
return locale === "zh-Hans" || items.length > 0 ? items : readNewsList("zh-Hans");
|
||||
}
|
||||
|
||||
export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> {
|
||||
const source = await readNewsList("zh-Hans");
|
||||
const entries = await Promise.all((["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => {
|
||||
if (locale === "zh-Hans") return [locale, source] as const;
|
||||
|
||||
const translated = await readNewsList(locale);
|
||||
// The source catalog defines public slugs; a missing translation falls back per article.
|
||||
export function createNewsReader(root: string) {
|
||||
async function readLocalizedNews(locale: Locale) {
|
||||
const [source, translated] = await Promise.all([
|
||||
readNewsList(root, "zh-Hans"),
|
||||
locale === "zh-Hans" ? Promise.resolve([]) : readNewsList(root, locale),
|
||||
]);
|
||||
const bySlug = new Map(translated.map((item) => [item.slug, item]));
|
||||
return [locale, source.map((item) => bySlug.get(item.slug) ?? item)] as const;
|
||||
}));
|
||||
return Object.fromEntries(entries) as Record<Locale, NewsItem[]>;
|
||||
return source.map((item) => bySlug.get(item.slug) ?? item);
|
||||
}
|
||||
|
||||
return {
|
||||
async getNewsList(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
|
||||
return (await readLocalizedNews(locale)).map(summarize);
|
||||
},
|
||||
async getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
|
||||
return (await readLocalizedNews(locale)).find((item) => item.slug === slug) ?? null;
|
||||
},
|
||||
async getLatestNews(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
|
||||
return (await readLocalizedNews(locale)).slice(0, 3).map(summarize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
|
||||
const items = await getNewsList(locale);
|
||||
return items.find((item) => item.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function getLatestNews(locale: Locale = "zh-Hans") {
|
||||
const items = await getNewsList(locale);
|
||||
return items.slice(0, 3);
|
||||
}
|
||||
export const { getNewsList, getNewsBySlug, getLatestNews } = createNewsReader(
|
||||
path.join(process.cwd(), "src", "data", "news", "posts"),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export type PolicyDocument = {
|
||||
body: string;
|
||||
html: string;
|
||||
markdownFile: string;
|
||||
contentLocale: Locale;
|
||||
};
|
||||
|
||||
function isPolicySlug(slug: string): slug is PolicySlug {
|
||||
@@ -39,20 +40,18 @@ async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocum
|
||||
body: content,
|
||||
html: markdownToHtml(content),
|
||||
markdownFile: `src/data/policy/${localeContentDirectory[locale]}/${slug}.md`,
|
||||
contentLocale: locale,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Locale, PolicyDocument>>> {
|
||||
if (!isPolicySlug(slug)) return {};
|
||||
|
||||
const entries = await Promise.all(
|
||||
(["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => [locale, await readPolicy(slug, locale)] as const),
|
||||
);
|
||||
|
||||
return Object.fromEntries(entries.filter(([, document]) => document)) as Partial<Record<Locale, PolicyDocument>>;
|
||||
export async function getPolicyDocument(slug: string, locale: Locale): Promise<PolicyDocument | null> {
|
||||
if (!isPolicySlug(slug)) return null;
|
||||
const document = await readPolicy(slug, locale);
|
||||
return document ?? (locale === "zh-Hans" ? null : readPolicy(slug, "zh-Hans"));
|
||||
}
|
||||
|
||||
export function getPolicySlugs() {
|
||||
|
||||
+6
-1
@@ -6,6 +6,11 @@ import ja from "@/locales/ja.json";
|
||||
export const locales = ["zh-Hans", "zh-Hant", "en", "jp"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
// Public route /jp/ is retained; HTML and hreflang use the BCP 47 language code.
|
||||
export const localeLanguageTags: Record<Locale, string> = {
|
||||
"zh-Hans": "zh-Hans", "zh-Hant": "zh-Hant", en: "en", jp: "ja",
|
||||
};
|
||||
|
||||
// Content storage retains its original names; public URLs use script subtags.
|
||||
export const localeContentDirectory: Record<Locale, string> = {
|
||||
"zh-Hans": "zh-CN", "zh-Hant": "zh-TW", en: "en", jp: "ja",
|
||||
@@ -80,7 +85,7 @@ export function applyLocale(locale: Locale) {
|
||||
window.location.replace(localizedHref);
|
||||
return;
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.lang = localeLanguageTags[locale];
|
||||
document.documentElement.setAttribute("data-locale", locale);
|
||||
window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { locale } }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLocaleMessages, localeLanguageTags, locales, withLocale, type Locale } from "@/lib/i18n";
|
||||
|
||||
export const siteOrigin = new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://www.kivotos.cc").origin;
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
"zh-Hans": "基沃托斯铁道公团官方网站。了解我们的 Minecraft 铁路与城市建设,阅读最新新闻,认识团队并获取加入信息。",
|
||||
"zh-Hant": "基沃托斯鐵道公團官方網站。了解我們的 Minecraft 鐵路與城市建設,閱讀最新消息,認識團隊並取得加入資訊。",
|
||||
en: "The official KivotosRailway website. Explore our Minecraft railways and cities, read the latest news, meet the team, and find information about joining us.",
|
||||
jp: "キヴォトス鉄道公団の公式サイト。Minecraft の鉄道・都市開発、最新ニュース、メンバー紹介、参加に関する情報をお届けします。",
|
||||
};
|
||||
|
||||
const openGraphLocales: Record<Locale, string> = {
|
||||
"zh-Hans": "zh_CN", "zh-Hant": "zh_TW", en: "en_US", jp: "ja_JP",
|
||||
};
|
||||
|
||||
export function localizedUrl(href: string, locale: Locale) {
|
||||
return new URL(withLocale(href, locale), siteOrigin).href;
|
||||
}
|
||||
|
||||
export function languageAlternates(href: string) {
|
||||
return Object.fromEntries(locales.map((locale) => [localeLanguageTags[locale], localizedUrl(href, locale)]));
|
||||
}
|
||||
|
||||
export function descriptionFromMarkdown(markdown: string) {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/[#*_`>|]/g, "")
|
||||
.replace(/\s+/g, " ").trim().slice(0, 160);
|
||||
}
|
||||
|
||||
export function pageMetadata(locale: Locale, href: string, options: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
article?: boolean;
|
||||
} = {}): Metadata {
|
||||
const siteTitle = getLocaleMessages(locale).common.siteTitle;
|
||||
const title = options.title ? `${options.title} | ${siteTitle}` : siteTitle;
|
||||
const description = options.description || (options.title ? `${options.title} — ${descriptions[locale]}` : descriptions[locale]);
|
||||
const url = localizedUrl(href, locale);
|
||||
const image = new URL("/home-hero-scene.webp", siteOrigin).href;
|
||||
return {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: url, languages: { ...languageAlternates(href), "x-default": localizedUrl(href, "zh-Hans") } },
|
||||
openGraph: {
|
||||
type: options.article ? "article" : "website",
|
||||
title, description, url, siteName: siteTitle,
|
||||
locale: openGraphLocales[locale],
|
||||
alternateLocale: locales.filter((item) => item !== locale).map((item) => openGraphLocales[item]),
|
||||
images: [{ url: image, width: 1440, height: 743, alt: siteTitle }],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ThemePreference = "light" | "system" | "dark";
|
||||
|
||||
function isTheme(value: string | null | undefined): value is ThemePreference {
|
||||
return value === "light" || value === "dark" || value === "system";
|
||||
}
|
||||
|
||||
export function getInitialThemePreference(): ThemePreference {
|
||||
try {
|
||||
const cookie = document.cookie.split(";").map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith("kr-theme="))?.slice("kr-theme=".length);
|
||||
if (isTheme(cookie)) return cookie;
|
||||
} catch { /* Storage may be disabled independently of cookies. */ }
|
||||
try {
|
||||
const saved = window.localStorage.getItem("kr-theme");
|
||||
if (isTheme(saved)) return saved;
|
||||
} catch { /* System preference works without browser storage. */ }
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function applyTheme(preference: ThemePreference) {
|
||||
const theme = preference === "system"
|
||||
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||
: preference;
|
||||
const color = theme === "dark" ? "#10151d" : "#ffffff";
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.documentElement.style.backgroundColor = color;
|
||||
document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.setAttribute("content", color));
|
||||
try { window.localStorage.setItem("kr-theme", preference); } catch { /* Keep the current theme usable. */ }
|
||||
try { document.cookie = `kr-theme=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`; } catch { /* Optional persistence. */ }
|
||||
}
|
||||
Reference in New Issue
Block a user