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": "關注我們",