增加 404 页面,新增暗色模式

This commit is contained in:
2026-09-16 12:00:53 +08:00
parent d7001a207f
commit 36b6012809
20 changed files with 370 additions and 7 deletions
+21
View File
@@ -0,0 +1,21 @@
# Random 404 messages grouped by locale.
zh-CN:
- 人与人的悲欢不尽相同
- 此处没有列车经过
- 这条线路还没有开通
- 你抵达了地图之外的地方
zh-TW:
- 人與人的悲歡並不相同
- 此處沒有列車經過
- 這條路線尚未通車
- 你抵達了地圖之外的地方
en:
- We do not all share the same joys and sorrows
- No train stops here
- This line has not opened yet
- You have reached the edge of the map
ja:
- 人それぞれ、喜びも悲しみも違う
- ここを通る列車はありません
- この路線はまだ開通していません
- 地図の外まで来てしまいました
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M11.6667 1.75H2.33333C1.689 1.75 1.16667 2.27233 1.16667 2.91667V8.75C1.16667 9.39433 1.689 9.91667 2.33333 9.91667H11.6667C12.311 9.91667 12.8333 9.39433 12.8333 8.75V2.91667C12.8333 2.27233 12.311 1.75 11.6667 1.75Z" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M4.66667 12.25H9.33333" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M7 9.91667V12.25" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 844 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon">
<path id="Vector" d="M12.25 7.46083C12.1582 8.45379 11.7856 9.40007 11.1756 10.189C10.5657 10.9778 9.7437 11.5767 8.80583 11.9155C7.86795 12.2542 6.85299 12.3189 5.87971 12.1019C4.90643 11.8849 4.01508 11.3952 3.30996 10.69C2.60485 9.98492 2.11513 9.09357 1.89811 8.12029C1.68109 7.14701 1.74575 6.13205 2.08452 5.19417C2.42329 4.2563 3.02215 3.4343 3.81104 2.82436C4.59993 2.21442 5.54621 1.84176 6.53917 1.75C5.95782 2.53649 5.67808 3.50551 5.75081 4.48083C5.82354 5.45614 6.24392 6.37295 6.93548 7.06452C7.62705 7.75608 8.54386 8.17646 9.51918 8.24919C10.4945 8.32192 11.4635 8.04218 12.25 7.46083Z" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 903 B

+18
View File
@@ -0,0 +1,18 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icon" clip-path="url(#clip0_0_24)">
<path id="Vector" d="M7 9.91667C8.61083 9.91667 9.91667 8.61083 9.91667 7C9.91667 5.38917 8.61083 4.08333 7 4.08333C5.38917 4.08333 4.08333 5.38917 4.08333 7C4.08333 8.61083 5.38917 9.91667 7 9.91667Z" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M7 0.583334V1.75" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M7 12.25V13.4167" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M2.46167 2.46167L3.29 3.29" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M10.71 10.71L11.5383 11.5383" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_6" d="M0.583334 7H1.75" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_7" d="M12.25 7H13.4167" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_8" d="M2.46167 11.5383L3.29 10.71" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_9" d="M10.71 3.29L11.5383 2.46167" stroke="#18181B" stroke-opacity="0.8" stroke-width="0.875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_0_24">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function ForbiddenPage() {
return <HttpStatusPage code={403} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function InternalServerErrorPage() {
return <HttpStatusPage code={500} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function BadGatewayPage() {
return <HttpStatusPage code={502} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function ServiceUnavailablePage() {
return <HttpStatusPage code={503} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function GatewayTimeoutPage() {
return <HttpStatusPage code={504} />;
}
+56 -2
View File
@@ -19,6 +19,12 @@ a { color: inherit; text-decoration: none; }
.site-locale button { appearance: none; border: 0; background: transparent; border-radius: 12px; padding: 10px 14px; min-width: 48px; color: #5d6773; font-size: 12px; font-weight: 700; letter-spacing: 0.05em; cursor: pointer; transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } .site-locale button { appearance: none; border: 0; background: transparent; border-radius: 12px; padding: 10px 14px; min-width: 48px; color: #5d6773; font-size: 12px; font-weight: 700; letter-spacing: 0.05em; cursor: pointer; transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.site-locale button:hover, .site-locale button:focus-visible { color: #8F56A2; } .site-locale button:hover, .site-locale button:focus-visible { color: #8F56A2; }
.site-locale button.active { background: #fff; color: #8F56A2; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); } .site-locale button.active { background: #fff; color: #8F56A2; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); }
.theme-toggle { display: inline-flex; align-items: center; width: 104px; height: 40px; padding: 3px; border: .8px solid rgba(24, 24, 27, 0.1); border-radius: 999px; background: transparent; }
.theme-toggle button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; transition: background-color .2s ease, box-shadow .2s ease; }
.theme-toggle button.active { background: #fff; box-shadow: 0 1px 2px rgba(128, 128, 128, 0.2), 0 1px 3px rgba(128, 128, 128, 0.1); }
.theme-toggle button:hover, .theme-toggle button:focus-visible { background: rgba(143, 86, 162, 0.1); }
.theme-toggle button.active:hover, .theme-toggle button.active:focus-visible { background: #fff; }
.theme-toggle img { display: block; width: 14px; height: 14px; }
.locale-button-label { display: inline-flex; align-items: center; justify-content: center; } .locale-button-label { display: inline-flex; align-items: center; justify-content: center; }
.site-mobile-locale { display: none; } .site-mobile-locale { display: none; }
.site-menu-button { display: none; width: 38px; border: 0; background: transparent; cursor: pointer; } .site-menu-button { display: none; width: 38px; border: 0; background: transparent; cursor: pointer; }
@@ -127,9 +133,17 @@ a { color: inherit; text-decoration: none; }
.news-share-button:hover { border-color: #0f73ff; color: #0f73ff; } .news-share-button:hover { border-color: #0f73ff; color: #0f73ff; }
.news-share-x, .news-share-qq { min-width: 52px; font-weight: 700; } .news-share-x, .news-share-qq { min-width: 52px; font-weight: 700; }
.status-page { background: var(--paper); }
.status-content { display: flex; align-items: center; justify-content: center; height: 600px; padding: 0 24px 48px; }
.status-content-inner { display: flex; flex-direction: column; align-items: center; gap: 40px; text-align: center; }
.status-code, .status-content h1 { margin: 0; color: #111817; font-family: "Microsoft YaHei", "Noto Sans SC", sans-serif; font-size: 38px; font-weight: 700; line-height: 1.24; }
.status-content h1 { max-width: 100%; }
.status-back-button { padding: 0; border: 0; background: transparent; color: #8f56a2; font: 700 30px/1.25 "Microsoft YaHei", "Noto Sans SC", sans-serif; cursor: pointer; }
.status-back-button:hover, .status-back-button:focus-visible { text-decoration: underline; text-underline-offset: 5px; }
.site-footer { height: 500px; color: #5e686d; background: #fbfbfb; } .site-footer { height: 500px; color: #5e686d; background: #fbfbfb; }
.site-footer-inner { width: min(1200px, calc(100% - 40px)); height: 480px; margin: 0 auto; } .site-footer-inner { width: min(1200px, calc(100% - 40px)); height: 480px; margin: 0 auto; }
.site-footer-top { height: 89px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); padding-top: 33px; } .site-footer-top { display: flex; align-items: center; justify-content: space-between; height: 89px; border-bottom: 1px solid rgba(15, 23, 42, 0.06); padding-top: 0; }
.site-footer-socials { display: flex; align-items: center; justify-content: flex-start; gap: 33px; height: 28px; } .site-footer-socials { display: flex; align-items: center; justify-content: flex-start; gap: 33px; height: 28px; }
.site-footer-title { width: 80px; font-family: "Noto Sans SC", sans-serif; font-size: 20px; font-weight: 400; line-height: 21px; letter-spacing: 0; color: #5f6464; } .site-footer-title { width: 80px; font-family: "Noto Sans SC", sans-serif; font-size: 20px; font-weight: 400; line-height: 21px; letter-spacing: 0; color: #5f6464; }
.site-footer-icons { display: flex; align-items: center; gap: 28px; } .site-footer-icons { display: flex; align-items: center; gap: 28px; }
@@ -155,6 +169,39 @@ a { color: inherit; text-decoration: none; }
.site-footer-meta p { margin: 18px 0 0; font-family: "Noto Sans SC", sans-serif; font-size: 14px; font-weight: 400; line-height: 17px; color: #66737e; } .site-footer-meta p { margin: 18px 0 0; font-family: "Noto Sans SC", sans-serif; font-size: 14px; font-weight: 400; line-height: 17px; color: #66737e; }
.site-footer-meta .site-footer-description { width: 1200px; max-width: 100%; margin-top: 18px; } .site-footer-meta .site-footer-description { width: 1200px; max-width: 100%; margin-top: 18px; }
html[data-theme="dark"] {
--ink: #edf2f7;
--blue: #70adff;
--paper: #10151d;
--soft: #1a222d;
--muted: #a9b5c1;
--panel: #1a222d;
}
html[data-theme="dark"] .site-header { background: #10151d; }
html[data-theme="dark"] .site-logo-image, html[data-theme="dark"] .site-footer-brand-logo { filter: brightness(0) invert(1); }
html[data-theme="dark"] .site-locale { background: #1b2531; border-color: rgba(255, 255, 255, 0.12); box-shadow: none; }
html[data-theme="dark"] .site-locale button { color: #bdc8d5; }
html[data-theme="dark"] .site-locale button.active { background: #2b3746; color: #dcaee9; box-shadow: none; }
html[data-theme="dark"] .theme-toggle { border-color: rgba(255, 255, 255, 0.16); }
html[data-theme="dark"] .theme-toggle button.active { background: #2b3746; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); }
html[data-theme="dark"] .theme-toggle button:hover, html[data-theme="dark"] .theme-toggle button:focus-visible { background: rgba(255, 255, 255, 0.12); }
html[data-theme="dark"] .theme-toggle button.active:hover, html[data-theme="dark"] .theme-toggle button.active:focus-visible { background: #2b3746; }
html[data-theme="dark"] .theme-toggle img { filter: brightness(0) invert(1); }
html[data-theme="dark"] .news-toolbar { border-color: rgba(255, 255, 255, 0.16); }
html[data-theme="dark"] .news-filter-button, html[data-theme="dark"] .news-view-toggle { color: var(--ink); border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.04); }
html[data-theme="dark"] .news-pagination-button, html[data-theme="dark"] .news-share-button { color: var(--ink); border-color: rgba(255, 255, 255, 0.2); background: #1b2531; }
html[data-theme="dark"] .news-detail-card { background: #19222d; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); }
html[data-theme="dark"] .news-detail-body, html[data-theme="dark"] .policy-body { color: #dce5ee; }
html[data-theme="dark"] .policy-page { background: var(--paper); }
html[data-theme="dark"] .policy-card { background: #19222d; box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); }
html[data-theme="dark"] .page-bottom-nav { border-color: #394553; }
html[data-theme="dark"] .page-breadcrumb, html[data-theme="dark"] .page-breadcrumb-separator, html[data-theme="dark"] .page-breadcrumb a { color: #cbd5df; }
html[data-theme="dark"] .news-share { border-color: rgba(255, 255, 255, 0.14); }
html[data-theme="dark"] .status-code, html[data-theme="dark"] .status-content h1 { color: var(--ink); }
html[data-theme="dark"] .site-footer { color: #afbbc6; background: #0b1016; }
html[data-theme="dark"] .site-footer-top, html[data-theme="dark"] .site-footer-meta { border-color: rgba(255, 255, 255, 0.1); }
html[data-theme="dark"] .site-footer-title, html[data-theme="dark"] .site-footer-column h3, html[data-theme="dark"] .site-footer-column a, html[data-theme="dark"] .site-footer-icon, html[data-theme="dark"] .site-footer-legal-row, html[data-theme="dark"] .site-footer-copyright-row, html[data-theme="dark"] .site-footer-meta p { color: #afbbc6; }
@media (max-width: 900px) { @media (max-width: 900px) {
.site-header { height: 84px; padding: 0 16px; } .site-header { height: 84px; padding: 0 16px; }
.site-logo { min-width: 0; flex: 1; justify-content: flex-start; } .site-logo { min-width: 0; flex: 1; justify-content: flex-start; }
@@ -219,6 +266,7 @@ a { color: inherit; text-decoration: none; }
.site-footer-columns { grid-template-columns: 1fr; gap: 22px; width: auto; height: auto; } .site-footer-columns { grid-template-columns: 1fr; gap: 22px; width: auto; height: auto; }
.site-footer-brand-wrap { display: none; } .site-footer-brand-wrap { display: none; }
.site-footer-socials { flex-direction: row; align-items: center; } .site-footer-socials { flex-direction: row; align-items: center; }
.site-footer-top { align-items: center; }
.site-footer-legal-row { gap: 8px 12px; } .site-footer-legal-row { gap: 8px 12px; }
.site-footer { height: auto; padding: 28px 0 22px; } .site-footer { height: auto; padding: 28px 0 22px; }
.site-footer-meta p { font-size: 12px; } .site-footer-meta p { font-size: 12px; }
@@ -278,6 +326,12 @@ a { color: inherit; text-decoration: none; }
.news-share-actions { gap: 8px; } .news-share-actions { gap: 8px; }
.news-share-button { min-width: 0; height: 38px; padding: 0 12px; font-size: 14px; } .news-share-button { min-width: 0; height: 38px; padding: 0 12px; font-size: 14px; }
.status-content { height: min(600px, calc(100svh - 72px)); min-height: 420px; padding-bottom: 32px; }
.status-content-inner { gap: 32px; }
.status-code, .status-content h1 { font-size: clamp(28px, 8vw, 38px); }
.status-content h1 { overflow-wrap: anywhere; }
.status-back-button { font-size: 24px; }
.policy-page-inner { gap: 20px; padding: 0 12px 24px; } .policy-page-inner { gap: 20px; padding: 0 12px 24px; }
.policy-hero { min-height: 170px; padding: 64px 20px 0; border-radius: 8px; } .policy-hero { min-height: 170px; padding: 64px 20px 0; border-radius: 8px; }
.policy-hero-inner { margin-left: -20px; padding: 7px 20px; max-width: 100%; } .policy-hero-inner { margin-left: -20px; padding: 7px 20px; max-width: 100%; }
@@ -296,7 +350,7 @@ a { color: inherit; text-decoration: none; }
.site-footer { padding: 24px 0 18px; } .site-footer { padding: 24px 0 18px; }
.site-footer-inner { padding: 0 16px; } .site-footer-inner { padding: 0 16px; }
.site-footer-top { height: auto; padding-top: 0; padding-bottom: 24px; } .site-footer-top { height: auto; flex-wrap: wrap; gap: 16px; padding-top: 0; padding-bottom: 24px; }
.site-footer-socials { gap: 18px; } .site-footer-socials { gap: 18px; }
.site-footer-title { width: auto; font-size: 18px; } .site-footer-title { width: auto; font-size: 18px; }
.site-footer-icons { gap: 14px; } .site-footer-icons { gap: 14px; }
+3 -1
View File
@@ -24,7 +24,9 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body><SiteHeader />{children}<SiteFooter /></body> <body>
<SiteHeader />{children}<SiteFooter />
</body>
</html> </html>
); );
} }
+5
View File
@@ -0,0 +1,5 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export default function NotFound() {
return <HttpStatusPage code={404} />;
}
+2
View File
@@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { ThemeToggle } from "@/components/layout/theme-toggle";
const footerColumnHrefs = [ const footerColumnHrefs = [
["/policy/user-agreement", "/policy/privacy-policy", "/policy/disclaimer", "/policy/resource-pack"], ["/policy/user-agreement", "/policy/privacy-policy", "/policy/disclaimer", "/policy/resource-pack"],
@@ -66,6 +67,7 @@ export function SiteFooter() {
))} ))}
</div> </div>
</div> </div>
<ThemeToggle />
</div> </div>
<div className="site-footer-main"> <div className="site-footer-main">
+85
View File
@@ -0,0 +1,85 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
type ThemePreference = "light" | "system" | "dark";
type ResolvedTheme = "light" | "dark";
function getInitialThemePreference(): ThemePreference {
if (typeof window === "undefined") return "light";
const savedTheme = window.localStorage.getItem("kr-theme");
return savedTheme === "light" || savedTheme === "system" || savedTheme === "dark" ? savedTheme : "light";
}
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);
}
export function ThemeToggle() {
const [themePreference, setThemePreference] = useState<ThemePreference>("light");
const [locale, setLocale] = useState<Locale>("zh-CN");
useEffect(() => {
const syncTheme = () => {
const preference = getInitialThemePreference();
setThemePreference(preference);
applyTheme(preference);
};
const syncLocale = () => setLocale(getInitialLocale());
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const syncSystemTheme = () => {
if (getInitialThemePreference() === "system") applyTheme("system");
};
syncTheme();
syncLocale();
window.addEventListener("kr-locale-change", syncLocale);
window.addEventListener("storage", syncTheme);
mediaQuery.addEventListener("change", syncSystemTheme);
return () => {
window.removeEventListener("kr-locale-change", syncLocale);
window.removeEventListener("storage", syncTheme);
mediaQuery.removeEventListener("change", syncSystemTheme);
};
}, []);
const messages = getLocaleMessages(locale);
const options: { theme: ThemePreference; label: string; icon: string }[] = [
{ theme: "dark", label: messages.common.useDarkTheme, icon: "/icon/theme/light.svg" },
{ theme: "light", label: messages.common.useLightTheme, icon: "/icon/theme/system.svg" },
{ theme: "system", label: messages.common.useSystemTheme, icon: "/icon/theme/dark.svg" },
];
return (
<div className="theme-toggle" aria-label={messages.common.theme}>
{options.map((option) => (
<button
key={option.theme}
type="button"
className={themePreference === option.theme ? "active" : ""}
aria-label={option.label}
aria-pressed={themePreference === option.theme}
title={option.label}
onClick={() => {
setThemePreference(option.theme);
applyTheme(option.theme);
}}
>
<Image src={option.icon} alt="" width={14} height={14} />
</button>
))}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { getInitialLocale, type Locale } from "@/lib/i18n";
export type HttpStatusCode = 403 | 404 | 500 | 502 | 503 | 504;
const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
"zh-CN": {
403: "你没有权限访问此页面",
404: "人与人的悲欢不尽相同",
500: "服务器遇到了一点问题",
502: "服务暂时无法连接",
503: "服务暂时不可用",
504: "服务响应超时",
},
"zh-TW": {
403: "你沒有權限存取此頁面",
404: "人與人的悲歡並不相同",
500: "伺服器遇到了一點問題",
502: "服務暫時無法連線",
503: "服務暫時無法使用",
504: "服務回應逾時",
},
en: {
403: "You do not have permission to access this page",
404: "We do not all share the same joys and sorrows",
500: "The server ran into a problem",
502: "The service is temporarily unreachable",
503: "The service is temporarily unavailable",
504: "The service took too long to respond",
},
ja: {
403: "このページにアクセスする権限がありません",
404: "人それぞれ、喜びも悲しみも違う",
500: "サーバーで問題が発生しました",
502: "サービスに一時的に接続できません",
503: "サービスは一時的に利用できません",
504: "サービスの応答がタイムアウトしました",
},
};
const backLabels: Record<Locale, string> = {
"zh-CN": "回到上一页",
"zh-TW": "回到上一頁",
en: "Go back",
ja: "前のページに戻る",
};
function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]>> {
const messages: Partial<Record<Locale, string[]>> = {};
let locale: Locale | null = null;
for (const line of source.split(/\r?\n/)) {
const localeMatch = line.match(/^(zh-CN|zh-TW|en|ja):\s*$/);
if (localeMatch) {
locale = localeMatch[1] as Locale;
messages[locale] = [];
continue;
}
const messageMatch = line.match(/^\s+-\s+(.+)$/);
if (locale && messageMatch) messages[locale]?.push(messageMatch[1].trim());
}
return messages;
}
export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [randomMessages, setRandomMessages] = useState<Partial<Record<Locale, string[]>>>({});
const [randomMessage, setRandomMessage] = useState<string | null>(null);
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);
};
}, []);
useEffect(() => {
if (code !== 404) return;
fetch("/error/404-messages.yml")
.then((response) => response.ok ? response.text() : Promise.reject(new Error("Could not load 404 messages")))
.then((source) => setRandomMessages(parseLocalizedMessages(source)))
.catch(() => setRandomMessages({}));
}, [code]);
useEffect(() => {
const messages = randomMessages[locale] ?? randomMessages["zh-CN"];
if (!messages?.length) return;
setRandomMessage(messages[Math.floor(Math.random() * messages.length)]);
}, [locale, randomMessages]);
const returnToPreviousPage = () => {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.assign("/");
};
return (
<main className="status-page">
<section className="status-content" aria-labelledby="status-title">
<div className="status-content-inner">
<p className="status-code">{code}</p>
<Image src="/error/status-illustration.png" alt="" width={56} height={115} preload unoptimized />
<h1 id="status-title">{code === 404 && randomMessage ? randomMessage : statusMessages[locale][code]}</h1>
<button type="button" className="status-back-button" onClick={returnToPreviousPage}>
{backLabels[locale]}
</button>
</div>
</section>
</main>
);
}
+5 -1
View File
@@ -23,7 +23,11 @@
"all": "All", "all": "All",
"listView": "List view", "listView": "List view",
"pagePosition": "Page position", "pagePosition": "Page position",
"backToNews": "Back to news list" "backToNews": "Back to news list",
"theme": "Theme",
"useDarkTheme": "Switch to dark mode",
"useLightTheme": "Switch to light mode",
"useSystemTheme": "Use system theme"
}, },
"footer": { "footer": {
"follow": "Follow us", "follow": "Follow us",
+5 -1
View File
@@ -23,7 +23,11 @@
"all": "すべて", "all": "すべて",
"listView": "リスト表示", "listView": "リスト表示",
"pagePosition": "ページの位置", "pagePosition": "ページの位置",
"backToNews": "ニュース一覧へ戻る" "backToNews": "ニュース一覧へ戻る",
"theme": "テーマ",
"useDarkTheme": "ダークモードに切り替える",
"useLightTheme": "ライトモードに切り替える",
"useSystemTheme": "システム設定に従う"
}, },
"footer": { "footer": {
"follow": "フォローする", "follow": "フォローする",
+5 -1
View File
@@ -23,7 +23,11 @@
"all": "全部", "all": "全部",
"listView": "列表视图", "listView": "列表视图",
"pagePosition": "页面位置", "pagePosition": "页面位置",
"backToNews": "返回新闻列表" "backToNews": "返回新闻列表",
"theme": "主题",
"useDarkTheme": "切换至深色模式",
"useLightTheme": "切换至浅色模式",
"useSystemTheme": "跟随系统主题"
}, },
"footer": { "footer": {
"follow": "关注我们", "follow": "关注我们",
+5 -1
View File
@@ -23,7 +23,11 @@
"all": "全部", "all": "全部",
"listView": "列表視圖", "listView": "列表視圖",
"pagePosition": "頁面位置", "pagePosition": "頁面位置",
"backToNews": "返回新聞列表" "backToNews": "返回新聞列表",
"theme": "主題",
"useDarkTheme": "切換至深色模式",
"useLightTheme": "切換至淺色模式",
"useSystemTheme": "跟隨系統主題"
}, },
"footer": { "footer": {
"follow": "關注我們", "follow": "關注我們",