From ed880fd57d3d49c535e1ee51f2ef3c4b3bfb0899 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 15 Jan 2026 11:13:41 +0800 Subject: [PATCH] add i18n --- app.js | 367 +++++++++++++++++++++++++++++++++++++++++++++++++++++ data.js | 189 +++++++++++++++++++++++++++ i18n.js | 158 +++++++++++++++++++++++ index.html | 42 +++++- main.js | 304 +------------------------------------------- render.js | 34 +++++ style.css | 27 ++-- theme.js | 122 ++++++++++++++++++ 8 files changed, 919 insertions(+), 324 deletions(-) create mode 100644 app.js create mode 100644 data.js create mode 100644 i18n.js create mode 100644 render.js create mode 100644 theme.js diff --git a/app.js b/app.js new file mode 100644 index 0000000..d3e9e41 --- /dev/null +++ b/app.js @@ -0,0 +1,367 @@ +import { pins, sites } from "./data.js" +import { + getDesignThemeLabel, + getInitialLanguage, + LANGUAGE_KEY, + LANGUAGE_NAMES, + SUPPORTED_LANGUAGES, + t, +} from "./i18n.js" +import { renderSites } from "./render.js" +import { + getCurrentDesignTheme, + getInitialDesignTheme, + getInitialTheme, + setDesignTheme, + setDesignThemeMenuOpen, + setSelectedDesignThemeUI, + setTheme, + toggleTheme, + updateDesignThemeOptions, +} from "./theme.js" + +export function initApp() { + const CAT_ICON = "/icons/noto--cat-face.svg" + const themeToggle = document.getElementById("themeToggle") + const designThemeButton = document.getElementById("designThemeButton") + const designThemeList = document.getElementById("designThemeList") + const languageButton = document.getElementById("languageButton") + const languageList = document.getElementById("languageList") + const titleEl = document.querySelector(".title") + const subtitleEl = document.querySelector(".subtitle") + const designThemeLabelEl = document.querySelector( + '[data-i18n="designThemeLabel"]', + ) + const languageLabelEl = document.querySelector('[data-i18n="languageLabel"]') + const moonIcon = document.querySelector(".theme-icon-moon") + const sunIcon = document.querySelector(".theme-icon-sun") + const sitesContainer = document.querySelector("#sites") + const faviconEl = document.querySelector('link[rel~="icon"]') + const beianIcpEl = document.querySelector('[data-i18n="beianIcp"]') + const beianMpsEl = document.querySelector('[data-i18n="beianMps"]') + + let currentLanguage = getInitialLanguage() + + const getThemeLabel = (designTheme, language = currentLanguage) => + getDesignThemeLabel(designTheme, language) + + function setSelectedLanguageUI(language) { + if (!languageList) return + const options = [...languageList.querySelectorAll('[role="option"]')] + options.forEach((el) => { + const value = el.getAttribute("data-value") + const label = LANGUAGE_NAMES[value] || value || "" + el.setAttribute("aria-selected", value === language ? "true" : "false") + el.textContent = label + }) + if (languageButton) { + languageButton.textContent = LANGUAGE_NAMES[language] || language + } + } + + function setLanguageMenuOpen(open) { + if (!languageButton || !languageList) return + languageButton.setAttribute("aria-expanded", open ? "true" : "false") + languageList.hidden = !open + if (open) { + languageList.focus() + } + } + + function updateSubtitle(language = currentLanguage) { + if (!subtitleEl) return + if (pins.length) { + subtitleEl.textContent = t("pinnedSubtitle", language) + } else { + subtitleEl.textContent = subtitleEl.dataset.text || "" + } + } + + function getDocumentLang(language) { + if (language === "zh-Hant") return "zh-Hant" + if (language === "zh-Hans") return "zh-Hans" + if (language === "ja") return "ja" + if (language === "ko") return "ko" + return "en" + } + + function setSwappableIcon(element, language) { + if (!element) return + if (!element.dataset.defaultSrc) { + element.dataset.defaultSrc = element.getAttribute("src") || "" + } + element.setAttribute( + "src", + language === "meow" ? CAT_ICON : element.dataset.defaultSrc, + ) + } + + function setFavicon(language) { + if (!faviconEl) return + if (!faviconEl.dataset.defaultHref) { + faviconEl.dataset.defaultHref = faviconEl.getAttribute("href") || "" + } + faviconEl.setAttribute( + "href", + language === "meow" ? CAT_ICON : faviconEl.dataset.defaultHref, + ) + } + + function applyTranslations() { + const language = currentLanguage + document.documentElement.setAttribute("lang", getDocumentLang(language)) + document.title = t("appTitle", language) + if (titleEl) { + const titleText = t("appTitle", language) + titleEl.textContent = titleText + titleEl.dataset.text = titleText + } + if (designThemeLabelEl) { + designThemeLabelEl.textContent = t("designThemeLabel", language) + } + if (languageLabelEl) { + languageLabelEl.textContent = t("languageLabel", language) + } + if (beianIcpEl) { + beianIcpEl.textContent = t("beianIcp", language) + } + if (beianMpsEl) { + beianMpsEl.textContent = t("beianMps", language) + } + if (designThemeButton) { + designThemeButton.setAttribute( + "aria-label", + t("designThemeLabel", language), + ) + } + if (languageButton) { + languageButton.setAttribute("aria-label", t("languageLabel", language)) + } + if (themeToggle) { + themeToggle.setAttribute("aria-label", t("themeToggleLabel", language)) + themeToggle.setAttribute("title", t("themeToggleTitle", language)) + } + if (moonIcon) { + moonIcon.setAttribute("alt", t("moonAlt", language)) + } + if (sunIcon) { + sunIcon.setAttribute("alt", t("sunAlt", language)) + } + setSwappableIcon(moonIcon, language) + setSwappableIcon(sunIcon, language) + setFavicon(language) + updateDesignThemeOptions({ + designThemeList, + getLabel: getThemeLabel, + language, + }) + setSelectedDesignThemeUI({ + designThemeList, + designThemeButton, + designTheme: getCurrentDesignTheme(), + getLabel: getThemeLabel, + }) + setSelectedLanguageUI(language) + updateSubtitle(language) + renderSites({ container: sitesContainer, sites, pins, language }) + } + + function setLanguage(language) { + const safeLanguage = SUPPORTED_LANGUAGES.includes(language) + ? language + : "zh-Hans" + currentLanguage = safeLanguage + localStorage.setItem(LANGUAGE_KEY, safeLanguage) + applyTranslations() + } + + if (titleEl && !titleEl.dataset.text) { + titleEl.dataset.text = titleEl.textContent?.trim() || "" + } + + if (subtitleEl && !subtitleEl.dataset.text) { + subtitleEl.dataset.text = subtitleEl.textContent?.trim() || "" + } + + const initialTheme = getInitialTheme() + setTheme(initialTheme) + + const initialDesignTheme = getInitialDesignTheme() + setDesignTheme(initialDesignTheme, themeToggle) + setSelectedDesignThemeUI({ + designThemeList, + designThemeButton, + designTheme: initialDesignTheme, + getLabel: getThemeLabel, + }) + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: false, + }) + setLanguage(currentLanguage) + + if (designThemeButton && designThemeList) { + designThemeButton.addEventListener("click", () => { + const isOpen = designThemeButton.getAttribute("aria-expanded") === "true" + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: !isOpen, + }) + }) + + designThemeList.addEventListener("click", (e) => { + const option = e.target.closest?.('[role="option"][data-value]') + if (!option) return + const value = option.getAttribute("data-value") + setDesignTheme(value, themeToggle) + setSelectedDesignThemeUI({ + designThemeList, + designThemeButton, + designTheme: value, + getLabel: getThemeLabel, + }) + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: false, + }) + }) + + document.addEventListener("click", (e) => { + if (!designThemeButton || !designThemeList) return + const clickedInside = + designThemeButton.contains(e.target) || + designThemeList.contains(e.target) + if (!clickedInside) { + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: false, + }) + } + }) + + document.addEventListener("keydown", (e) => { + const isOpen = designThemeButton.getAttribute("aria-expanded") === "true" + if (!isOpen) return + + if (e.key === "Escape") { + e.preventDefault() + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: false, + }) + designThemeButton.focus() + return + } + + const options = [...designThemeList.querySelectorAll('[role="option"]')] + if (!options.length) return + const current = getCurrentDesignTheme() + const currentIndex = Math.max( + 0, + options.findIndex((el) => el.getAttribute("data-value") === current), + ) + + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault() + const delta = e.key === "ArrowDown" ? 1 : -1 + const nextIndex = + (currentIndex + delta + options.length) % options.length + const nextValue = options[nextIndex].getAttribute("data-value") + setDesignTheme(nextValue, themeToggle) + setSelectedDesignThemeUI({ + designThemeList, + designThemeButton, + designTheme: nextValue, + getLabel: getThemeLabel, + }) + return + } + + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setDesignThemeMenuOpen({ + designThemeButton, + designThemeList, + open: false, + }) + designThemeButton.focus() + } + }) + } + + if (languageButton && languageList) { + languageButton.addEventListener("click", () => { + const isOpen = languageButton.getAttribute("aria-expanded") === "true" + setLanguageMenuOpen(!isOpen) + }) + + languageList.addEventListener("click", (e) => { + const option = e.target.closest?.('[role="option"][data-value]') + if (!option) return + const value = option.getAttribute("data-value") + setLanguage(value) + setLanguageMenuOpen(false) + }) + + document.addEventListener("click", (e) => { + if (!languageButton || !languageList) return + const clickedInside = + languageButton.contains(e.target) || languageList.contains(e.target) + if (!clickedInside) setLanguageMenuOpen(false) + }) + + document.addEventListener("keydown", (e) => { + const isOpen = languageButton.getAttribute("aria-expanded") === "true" + if (!isOpen) return + + if (e.key === "Escape") { + e.preventDefault() + setLanguageMenuOpen(false) + languageButton.focus() + return + } + + const options = [...languageList.querySelectorAll('[role="option"]')] + if (!options.length) return + const currentIndex = Math.max( + 0, + options.findIndex( + (el) => el.getAttribute("data-value") === currentLanguage, + ), + ) + + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault() + const delta = e.key === "ArrowDown" ? 1 : -1 + const nextIndex = + (currentIndex + delta + options.length) % options.length + const nextValue = options[nextIndex].getAttribute("data-value") + setLanguage(nextValue) + return + } + + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setLanguageMenuOpen(false) + languageButton.focus() + } + }) + } + + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (e) => { + if (!localStorage.getItem("theme")) { + setTheme(e.matches ? "dark" : "light") + } + }) + + if (themeToggle) { + themeToggle.addEventListener("click", toggleTheme) + } +} diff --git a/data.js b/data.js new file mode 100644 index 0000000..09353ba --- /dev/null +++ b/data.js @@ -0,0 +1,189 @@ +export const pins = [ + // { + // url: "https://code.xuyue.cc?query=30", + // description: "示例代码", + // }, +] + +export const sites = [ + { + url: import.meta.env.VITE_OJ, + title: { + "zh-Hans": "判题狗", + "zh-Hant": "判題狗", + en: "Judge Dog", + ja: "判定犬", + ko: "판정개", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "在线判题网站", + "zh-Hant": "在線判題網站", + en: "Online judge platform", + ja: "オンライン判定サイト", + ko: "온라인 판정 사이트", + meow: "喵喵喵喵喵喵", + }, + icon: "noto--dog-face.svg", + }, + { + url: import.meta.env.VITE_CODE, + title: { + "zh-Hans": "自测猫", + "zh-Hant": "自測貓", + en: "Self Test Cat", + ja: "自テスト猫", + ko: "자가테스트猫", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "代码运行网站", + "zh-Hant": "代碼運行網站", + en: "Code runner", + ja: "コード実行サイト", + ko: "코드 실행 사이트", + meow: "喵喵喵喵喵喵", + }, + icon: "noto--cat-face.svg", + }, + { + url: import.meta.env.VITE_WEB, + title: { + "zh-Hans": "哈基米", + "zh-Hant": "哈基米", + en: "Hakimi", + ja: "ハキミ", + ko: "하키미", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "Web 前端开发", + "zh-Hant": "Web 前端開發", + en: "Web frontend development", + ja: "Webフロントエンド開発", + ko: "웹 프론트엔드 개발", + meow: "喵喵喵喵喵喵喵喵", + }, + icon: "noto--honeybee.svg", + }, + { + url: import.meta.env.VITE_SHUATI, + title: { + "zh-Hans": "刷题鸭", + "zh-Hant": "刷題鴨", + en: "Practice Duck", + ja: "演習アヒル", + ko: "문제풀이오리", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "梁老师的刷题网站", + "zh-Hant": "梁老師的刷題網站", + en: "Practice problems by Mr. Liang", + ja: "梁先生の演習サイト", + ko: "량 선생님의 문제풀이 사이트", + meow: "喵喵喵喵喵喵喵喵", + }, + icon: "noto--paintbrush.svg", + }, + { + url: import.meta.env.VITE_BOOK, + title: { + "zh-Hans": "编程书", + "zh-Hant": "編程書", + en: "Coding Books", + ja: "プログラミング書", + ko: "프로그래밍 책", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "编程和计算机相关知识汇总", + "zh-Hant": "編程和計算機相關知識匯總", + en: "CS knowledge summary", + ja: "プログラミング/コンピュータ知識まとめ", + ko: "프로그래밍/컴퓨터 지식 모음", + meow: "喵喵喵喵喵喵喵喵喵喵喵喵", + }, + icon: "noto--bookmark-tabs.svg", + }, + { + url: import.meta.env.VITE_BLOCKLY, + title: { + "zh-Hans": "小方块", + "zh-Hant": "小方塊", + en: "Little Blocks", + ja: "小さなブロック", + ko: "작은 블록", + meow: "喵喵喵", + }, + description: { + "zh-Hans": "搭积木,学编程", + "zh-Hant": "搭積木,學編程", + en: "Learn coding with blocks", + ja: "ブロックでプログラミング", + ko: "블록으로 프로그래밍 배우기", + meow: "喵喵喵喵喵喵喵", + }, + icon: "twemoji--brick.svg", + }, + { + url: import.meta.env.VITE_HUABU, + title: { + "zh-Hans": "白板", + "zh-Hant": "白板", + en: "Whiteboard", + ja: "ホワイトボード", + ko: "화이트보드", + meow: "喵喵", + }, + description: { + "zh-Hans": "在线板书", + "zh-Hant": "在線板書", + en: "Online whiteboard", + ja: "オンライン板書", + ko: "온라인 판서", + meow: "喵喵喵喵", + }, + icon: "noto--artist-palette.svg", + }, + { + url: import.meta.env.VITE_PPT, + title: { + "zh-Hans": "Python PPT", + "zh-Hant": "Python PPT", + en: "Python PPT", + ja: "Python PPT", + ko: "Python PPT", + meow: "喵喵喵喵喵喵", + }, + description: { + "zh-Hans": "Python 第一学期上课用", + "zh-Hant": "Python 第一學期上課用", + en: "Python semester 1 materials", + ja: "Python 1学期授業用", + ko: "Python 1학기 수업용", + meow: "喵喵喵喵喵喵喵喵喵喵喵喵喵喵", + }, + icon: "material-icon-theme--python.svg", + }, + { + url: import.meta.env.VITE_PY, + title: { + "zh-Hans": "Python 项目", + "zh-Hant": "Python 項目", + en: "Python Projects", + ja: "Python プロジェクト", + ko: "Python 프로젝트", + meow: "喵喵喵喵喵喵喵喵", + }, + description: { + "zh-Hans": "Python 第二学期上课用", + "zh-Hant": "Python 第二學期上課用", + en: "Python semester 2 materials", + ja: "Python 2学期授業用", + ko: "Python 2학기 수업용", + meow: "喵喵喵喵喵喵喵", + }, + icon: "material-icon-theme--folder-python-open.svg", + }, +].filter((site) => !!site.url) diff --git a/i18n.js b/i18n.js new file mode 100644 index 0000000..41c33c7 --- /dev/null +++ b/i18n.js @@ -0,0 +1,158 @@ +export const I18N = { + "zh-Hans": { + appTitle: "物联网专业在线学习平台", + pinnedSubtitle: "置顶内容", + designThemeLabel: "设计主题", + themeToggleLabel: "切换主题", + themeToggleTitle: "切换深色/浅色模式", + moonAlt: "月亮", + sunAlt: "太阳", + languageLabel: "语言", + beianIcp: "浙ICP备2023044109号", + beianMps: "浙公网安备33100402331786号", + }, + "zh-Hant": { + appTitle: "物聯網專業在線學習平台", + pinnedSubtitle: "置頂內容", + designThemeLabel: "設計主題", + themeToggleLabel: "切換主題", + themeToggleTitle: "切換深色/淺色模式", + moonAlt: "月亮", + sunAlt: "太陽", + languageLabel: "語言", + beianIcp: "浙ICP備2023044109號", + beianMps: "浙公網安備33100402331786號", + }, + en: { + appTitle: "IoT Program Online Learning Hub", + pinnedSubtitle: "Pinned", + designThemeLabel: "Design theme", + themeToggleLabel: "Toggle theme", + themeToggleTitle: "Toggle dark/light mode", + moonAlt: "Moon", + sunAlt: "Sun", + languageLabel: "Language", + beianIcp: "Zhejiang ICP 2023044109", + beianMps: "Zhejiang Public Security 33100402331786", + }, + ja: { + appTitle: "IoT専攻オンライン学習プラットフォーム", + pinnedSubtitle: "ピン留め", + designThemeLabel: "デザインテーマ", + themeToggleLabel: "テーマ切替", + themeToggleTitle: "ダーク/ライト切替", + moonAlt: "月", + sunAlt: "太陽", + languageLabel: "言語", + beianIcp: "浙江ICP 2023044109", + beianMps: "浙江公安 33100402331786", + }, + ko: { + appTitle: "IoT 전공 온라인 학습 플랫폼", + pinnedSubtitle: "고정", + designThemeLabel: "디자인 테마", + themeToggleLabel: "테마 전환", + themeToggleTitle: "다크/라이트 전환", + moonAlt: "달", + sunAlt: "태양", + languageLabel: "언어", + beianIcp: "저장 ICP 2023044109", + beianMps: "저장 공안 33100402331786", + }, + meow: { + appTitle: "喵喵喵喵喵喵喵喵喵喵喵喵", + pinnedSubtitle: "喵喵喵喵", + designThemeLabel: "喵喵喵喵", + themeToggleLabel: "喵喵喵喵", + themeToggleTitle: "喵喵喵喵喵喵喵喵喵", + moonAlt: "喵喵", + sunAlt: "喵喵", + languageLabel: "喵喵", + beianIcp: "喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵", + beianMps: "喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵", + }, +} + +export const DESIGN_THEME_LABELS = { + "zh-Hans": { + fluent: "Fluent", + "material-you": "Material You", + terminal: "终端", + cyberpunk: "赛博朋克", + }, + "zh-Hant": { + fluent: "Fluent", + "material-you": "Material You", + terminal: "終端", + cyberpunk: "賽博龐克", + }, + en: { + fluent: "Fluent", + "material-you": "Material You", + terminal: "Terminal", + cyberpunk: "Cyberpunk", + }, + ja: { + fluent: "Fluent", + "material-you": "Material You", + terminal: "ターミナル", + cyberpunk: "サイバーパンク", + }, + ko: { + fluent: "Fluent", + "material-you": "Material You", + terminal: "터미널", + cyberpunk: "사이버펑크", + }, + meow: { + fluent: "喵喵", + "material-you": "喵喵喵", + terminal: "喵喵", + cyberpunk: "喵喵喵喵", + }, +} + +export const LANGUAGE_NAMES = { + "zh-Hans": "简体中文", + "zh-Hant": "繁體中文", + en: "English", + ja: "日本語", + ko: "한국어", + meow: "喵喵喵", +} + +export const LANGUAGE_KEY = "language" +export const SUPPORTED_LANGUAGES = Object.keys(LANGUAGE_NAMES) + +export function getLocalizedText(value, language) { + if (!value) return "" + if (typeof value === "object") { + return ( + value[language] || value["zh-Hans"] + ) + } + return value +} + +export function getInitialLanguage() { + const saved = localStorage.getItem(LANGUAGE_KEY) + if (saved && SUPPORTED_LANGUAGES.includes(saved)) return saved + const normalized = (navigator.language || "").toLowerCase() + if (normalized.startsWith("zh")) { + return normalized.includes("hant") || normalized.includes("tw") + ? "zh-Hant" + : "zh-Hans" + } + if (normalized.startsWith("ja")) return "ja" + if (normalized.startsWith("ko")) return "ko" + return "zh-Hans" +} + +export function t(key, language) { + return I18N[language]?.[key] || I18N["zh-Hans"][key] || "" +} + +export function getDesignThemeLabel(designTheme, language) { + const labels = DESIGN_THEME_LABELS[language] || DESIGN_THEME_LABELS["zh-Hans"] + return labels[designTheme] || labels.fluent +} diff --git a/index.html b/index.html index 6048656..c1b18ca 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,9 @@