diff --git a/public/error/404-messages.yml b/public/error/404-messages.yml
new file mode 100644
index 0000000..0253210
--- /dev/null
+++ b/public/error/404-messages.yml
@@ -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:
+ - 人それぞれ、喜びも悲しみも違う
+ - ここを通る列車はありません
+ - この路線はまだ開通していません
+ - 地図の外まで来てしまいました
diff --git a/public/error/status-illustration.png b/public/error/status-illustration.png
new file mode 100644
index 0000000..e6eea34
Binary files /dev/null and b/public/error/status-illustration.png differ
diff --git a/public/icon/theme/dark.svg b/public/icon/theme/dark.svg
new file mode 100644
index 0000000..442ddd7
--- /dev/null
+++ b/public/icon/theme/dark.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/icon/theme/light.svg b/public/icon/theme/light.svg
new file mode 100644
index 0000000..91c77e1
--- /dev/null
+++ b/public/icon/theme/light.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/icon/theme/system.svg b/public/icon/theme/system.svg
new file mode 100644
index 0000000..d91f260
--- /dev/null
+++ b/public/icon/theme/system.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/app/403/page.tsx b/src/app/403/page.tsx
new file mode 100644
index 0000000..9680f05
--- /dev/null
+++ b/src/app/403/page.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function ForbiddenPage() {
+ return ;
+}
diff --git a/src/app/500/page.tsx b/src/app/500/page.tsx
new file mode 100644
index 0000000..0d6bdbc
--- /dev/null
+++ b/src/app/500/page.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function InternalServerErrorPage() {
+ return ;
+}
diff --git a/src/app/502/page.tsx b/src/app/502/page.tsx
new file mode 100644
index 0000000..8bdc814
--- /dev/null
+++ b/src/app/502/page.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function BadGatewayPage() {
+ return ;
+}
diff --git a/src/app/503/page.tsx b/src/app/503/page.tsx
new file mode 100644
index 0000000..ab5525a
--- /dev/null
+++ b/src/app/503/page.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function ServiceUnavailablePage() {
+ return ;
+}
diff --git a/src/app/504/page.tsx b/src/app/504/page.tsx
new file mode 100644
index 0000000..0cb9b73
--- /dev/null
+++ b/src/app/504/page.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function GatewayTimeoutPage() {
+ return ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 1503cd6..a653266 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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: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); }
+.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; }
.site-mobile-locale { display: none; }
.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-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-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-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; }
@@ -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 .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) {
.site-header { height: 84px; padding: 0 16px; }
.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-brand-wrap { display: none; }
.site-footer-socials { flex-direction: row; align-items: center; }
+ .site-footer-top { align-items: center; }
.site-footer-legal-row { gap: 8px 12px; }
.site-footer { height: auto; padding: 28px 0 22px; }
.site-footer-meta p { font-size: 12px; }
@@ -278,6 +326,12 @@ a { color: inherit; text-decoration: none; }
.news-share-actions { gap: 8px; }
.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-hero { min-height: 170px; padding: 64px 20px 0; border-radius: 8px; }
.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-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-title { width: auto; font-size: 18px; }
.site-footer-icons { gap: 14px; }
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 15a7dbf..eaac7b7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -24,7 +24,9 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
-
{children}
+
+ {children}
+
);
}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..1d9bc5f
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,5 @@
+import { HttpStatusPage } from "@/components/status/http-status-page";
+
+export default function NotFound() {
+ return ;
+}
diff --git a/src/components/layout/site-footer.tsx b/src/components/layout/site-footer.tsx
index e5e315e..ee30605 100644
--- a/src/components/layout/site-footer.tsx
+++ b/src/components/layout/site-footer.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
+import { ThemeToggle } from "@/components/layout/theme-toggle";
const footerColumnHrefs = [
["/policy/user-agreement", "/policy/privacy-policy", "/policy/disclaimer", "/policy/resource-pack"],
@@ -66,6 +67,7 @@ export function SiteFooter() {
))}
+
diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx
new file mode 100644
index 0000000..11ee4fa
--- /dev/null
+++ b/src/components/layout/theme-toggle.tsx
@@ -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
("light");
+ const [locale, setLocale] = useState("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 (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/status/http-status-page.tsx b/src/components/status/http-status-page.tsx
new file mode 100644
index 0000000..ffb31df
--- /dev/null
+++ b/src/components/status/http-status-page.tsx
@@ -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> = {
+ "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 = {
+ "zh-CN": "回到上一页",
+ "zh-TW": "回到上一頁",
+ en: "Go back",
+ ja: "前のページに戻る",
+};
+
+function parseLocalizedMessages(source: string): Partial> {
+ const messages: Partial> = {};
+ 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("zh-CN");
+ const [randomMessages, setRandomMessages] = useState>>({});
+ const [randomMessage, setRandomMessage] = useState(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 (
+
+
+
+
{code}
+
+
{code === 404 && randomMessage ? randomMessage : statusMessages[locale][code]}
+
+
+
+
+ );
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 0144b7a..3337ea9 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -23,7 +23,11 @@
"all": "All",
"listView": "List view",
"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": {
"follow": "Follow us",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 16f0fd6..841f05b 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -23,7 +23,11 @@
"all": "すべて",
"listView": "リスト表示",
"pagePosition": "ページの位置",
- "backToNews": "ニュース一覧へ戻る"
+ "backToNews": "ニュース一覧へ戻る",
+ "theme": "テーマ",
+ "useDarkTheme": "ダークモードに切り替える",
+ "useLightTheme": "ライトモードに切り替える",
+ "useSystemTheme": "システム設定に従う"
},
"footer": {
"follow": "フォローする",
diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json
index 5b8a823..78c37c0 100644
--- a/src/locales/zh-CN.json
+++ b/src/locales/zh-CN.json
@@ -23,7 +23,11 @@
"all": "全部",
"listView": "列表视图",
"pagePosition": "页面位置",
- "backToNews": "返回新闻列表"
+ "backToNews": "返回新闻列表",
+ "theme": "主题",
+ "useDarkTheme": "切换至深色模式",
+ "useLightTheme": "切换至浅色模式",
+ "useSystemTheme": "跟随系统主题"
},
"footer": {
"follow": "关注我们",
diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json
index 5fe3490..ec43552 100644
--- a/src/locales/zh-TW.json
+++ b/src/locales/zh-TW.json
@@ -23,7 +23,11 @@
"all": "全部",
"listView": "列表視圖",
"pagePosition": "頁面位置",
- "backToNews": "返回新聞列表"
+ "backToNews": "返回新聞列表",
+ "theme": "主題",
+ "useDarkTheme": "切換至深色模式",
+ "useLightTheme": "切換至淺色模式",
+ "useSystemTheme": "跟隨系統主題"
},
"footer": {
"follow": "關注我們",