mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 11:06:32 +09:00
i18n 代码进入URL路由,优化重定向规则
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
export const LocaleContext = createContext<Locale>("zh-Hans");
|
||||
export function useRouteLocale() { return useContext(LocaleContext); }
|
||||
|
||||
export function LocaleProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
|
||||
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { applyLocale, getInitialLocale } from "@/lib/i18n";
|
||||
|
||||
export function LocaleUrlSync() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
applyLocale(getInitialLocale());
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getInitialLocale, withLocale, type Locale } from "@/lib/i18n";
|
||||
|
||||
export function PageBreadcrumb({
|
||||
items,
|
||||
@@ -7,12 +13,21 @@ export function PageBreadcrumb({
|
||||
items: Array<{ label: string; href?: string }>;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => setLocale(getInitialLocale());
|
||||
syncLocale();
|
||||
window.addEventListener("kr-locale-change", syncLocale);
|
||||
return () => window.removeEventListener("kr-locale-change", syncLocale);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="page-breadcrumb" aria-label={ariaLabel}>
|
||||
{items.map((item, index) => (
|
||||
<span className="page-breadcrumb-item" key={`${item.label}-${index}`}>
|
||||
{index > 0 && <span className="page-breadcrumb-separator" aria-hidden="true">›</span>}
|
||||
{item.href ? <Link href={item.href}>{item.label}</Link> : <span>{item.label}</span>}
|
||||
{item.href ? <Link href={withLocale(item.href, locale)}>{item.label}</Link> : <span>{item.label}</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "@/app/globals.css";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { LocaleProvider } from "@/components/layout/locale-context";
|
||||
import { SiteFooter } from "@/components/layout/site-footer";
|
||||
import { SiteHeader } from "@/components/layout/site-header";
|
||||
import { LocaleUrlSync } from "@/components/layout/locale-url-sync";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "基沃托斯铁道公团 KivotosRailway",
|
||||
template: "%s | 基沃托斯铁道公团 KivotosRailway",
|
||||
},
|
||||
description: "Kivotos Railway website",
|
||||
icons: {
|
||||
icon: "/icon/icon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
locale = "zh-Hans",
|
||||
}: Readonly<{ children: React.ReactNode; locale?: Locale }>) {
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Run during HTML parsing, before the body can paint. */}
|
||||
<script
|
||||
id="theme-preference"
|
||||
dangerouslySetInnerHTML={{ __html: `(() => {
|
||||
const isTheme = (value) => value === 'light' || value === 'system' || value === 'dark';
|
||||
let preference = 'system';
|
||||
try {
|
||||
const cookieTheme = document.cookie.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith('kr-theme='))?.slice('kr-theme='.length);
|
||||
const savedTheme = isTheme(cookieTheme) ? cookieTheme : localStorage.getItem('kr-theme');
|
||||
if (isTheme(savedTheme)) preference = savedTheme;
|
||||
} catch {}
|
||||
const theme = preference === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : preference;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();` }}
|
||||
/>
|
||||
<script
|
||||
id="locale-canonicalization"
|
||||
dangerouslySetInnerHTML={{ __html: `(() => {
|
||||
const canonicalLocale = (value) => {
|
||||
const language = value?.toLowerCase().replaceAll('_', '-');
|
||||
if (!language) return null;
|
||||
if (/^zh(?:-|$)/.test(language)) return /^zh-(tw|hk|mo)(-|$)|-hant(?:-|$)/.test(language) ? 'zh-Hant' : 'zh-Hans';
|
||||
if (/^en(?:-|$)/.test(language)) return 'en';
|
||||
if (/^(ja|jp)(?:-|$)/.test(language)) return 'jp';
|
||||
return null;
|
||||
};
|
||||
const parts = location.pathname.split('/');
|
||||
const firstSegment = parts[1];
|
||||
const pathLocale = canonicalLocale(firstSegment);
|
||||
const isUnknownLanguagePath = !pathLocale && /^[a-z]{2,3}(?:-[a-z0-9]+)*$/i.test(firstSegment ?? '');
|
||||
let locale = pathLocale;
|
||||
if (!locale) {
|
||||
try { locale = canonicalLocale(localStorage.getItem('kr-locale')); } catch {}
|
||||
}
|
||||
if (!locale) {
|
||||
for (const language of navigator.languages?.length ? navigator.languages : [navigator.language]) {
|
||||
locale = canonicalLocale(language);
|
||||
if (locale) break;
|
||||
}
|
||||
}
|
||||
locale ??= 'en';
|
||||
const path = pathLocale || isUnknownLanguagePath ? '/' + parts.slice(2).join('/') : location.pathname;
|
||||
const normalizedPath = path.replace(/\\/$/, '');
|
||||
const destination = '/' + locale + normalizedPath + '/';
|
||||
const search = new URLSearchParams(location.search);
|
||||
search.delete('lang');
|
||||
const canonicalUrl = destination + (search.size ? '?' + search : '') + location.hash;
|
||||
if (location.pathname + location.search + location.hash !== canonicalUrl) location.replace(canonicalUrl);
|
||||
})();` }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<LocaleProvider locale={locale}>
|
||||
<Suspense fallback={null}><LocaleUrlSync /></Suspense>
|
||||
<SiteHeader />{children}<SiteFooter />
|
||||
</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
|
||||
const footerColumnHrefs = [
|
||||
@@ -18,7 +20,7 @@ function isIcpVisibleHost(hostname: string) {
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
const [showIcp, setShowIcp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,7 +79,7 @@ export function SiteFooter() {
|
||||
<h3>{group.title}</h3>
|
||||
<ul>
|
||||
{group.links.map((link, linkIndex) => (
|
||||
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={footerColumnHrefs[groupIndex][linkIndex]}>{link}</a></li>
|
||||
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={withLocale(footerColumnHrefs[groupIndex][linkIndex], locale)}>{link}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -100,7 +102,7 @@ export function SiteFooter() {
|
||||
<div className="site-footer-meta">
|
||||
<div className="site-footer-legal-row">
|
||||
{messages.footer.legal.map((item, itemIndex) => (
|
||||
<a key={`legal-${itemIndex}`} href={footerLegalHrefs[itemIndex]}>{item}</a>
|
||||
<a key={`legal-${itemIndex}`} href={withLocale(footerLegalHrefs[itemIndex], locale)}>{item}</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="site-footer-description">{messages.footer.description}</p>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyLocale, getInitialLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
|
||||
import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
|
||||
|
||||
export function SiteHeader() {
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDocumentTitle = (nextLocale: Locale) => {
|
||||
const messages = getLocaleMessages(nextLocale);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
|
||||
const currentTitle = document.title.split(" | ")[0];
|
||||
const pageLabel = pathname === "/"
|
||||
? ""
|
||||
@@ -54,7 +56,7 @@ export function SiteHeader() {
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<Link className="site-logo" href="/" aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
|
||||
<Link className="site-logo" href={withLocale("/", locale)} aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
|
||||
<Image
|
||||
className="site-logo-image"
|
||||
src="/icon/kivotosrailway.svg"
|
||||
@@ -68,7 +70,7 @@ export function SiteHeader() {
|
||||
{messages.header.nav.map((label, index) => {
|
||||
const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/";
|
||||
return (
|
||||
<Link key={href} href={href} onClick={() => setMenuOpen(false)}>
|
||||
<Link key={href} href={withLocale(href, locale)} onClick={() => setMenuOpen(false)}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouteLocale } from "@/components/layout/locale-context";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
|
||||
@@ -43,7 +45,7 @@ function applyTheme(preference: ThemePreference) {
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>("system");
|
||||
const [locale, setLocale] = useState<Locale>("zh-CN");
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
|
||||
useEffect(() => {
|
||||
const syncTheme = () => {
|
||||
|
||||
Reference in New Issue
Block a user