From f183a401fca9e9857b4897121c1539c60186446c Mon Sep 17 00:00:00 2001 From: TerryM Date: Thu, 28 May 2026 22:28:23 +0800 Subject: [PATCH] feat: enhance SEO with meta tags and sitemap, add DocumentMeta component --- index.html | 34 +++++- public/robots.txt | 4 + public/site.webmanifest | 17 +++ public/sitemap.xml | 18 +++ src/components/DocumentMeta.tsx | 194 ++++++++++++++++++++++++++++++++ src/layouts/PublicLayout.tsx | 36 +++++- 6 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 public/robots.txt create mode 100644 public/site.webmanifest create mode 100644 public/sitemap.xml create mode 100644 src/components/DocumentMeta.tsx diff --git a/index.html b/index.html index 98541c6..22f1bbe 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,39 @@ - + - ARK 資料庫 + + + + + + + + + + + + + + + + + + ARK 官方数据库
diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..d44fe6f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://ark-library.com/sitemap.xml diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..2e8ed18 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "ARK 资料库", + "short_name": "ARK Library", + "description": "ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。", + "start_url": "/", + "display": "standalone", + "background_color": "#08070c", + "theme_color": "#08070c", + "icons": [ + { + "src": "/assets/ark-mark.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..c85557c --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://ark-library.com/ + + + https://ark-library.com/browse + + + https://ark-library.com/categories + + + https://ark-library.com/official-recommendations + + + https://ark-library.com/about + + diff --git a/src/components/DocumentMeta.tsx b/src/components/DocumentMeta.tsx new file mode 100644 index 0000000..bf2c859 --- /dev/null +++ b/src/components/DocumentMeta.tsx @@ -0,0 +1,194 @@ +import { useEffect, useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import { useI18n, type Lang } from "../i18n"; + +type PageMeta = { + title: string; + description: string; +}; + +const descriptions: Record> = { + "zh-CN": { + home: "ARK 官方数据库集中整理官方教材、公告、视频、图片与常用文件,帮助社区快速找到可信资料。", + browse: "浏览 ARK 资料库中的官方资料、教程、公告、图片、视频与可下载文件。", + categories: + "按分类探索 ARK 官方资料,快速定位所需教材、公告、视频、图片与文件。", + official: "查看 ARK 官方推荐资料,获取优先整理的重点内容与可信资源。", + favorites: "收藏功能开发中,未来可在这里集中管理常用 ARK 资料。", + about: "了解 ARK 资料库的用途、资料范围与本站索引说明。", + search: "在 ARK 资料库中搜索标题、分类、标签、简介、文件类型与正文内容。", + }, + en: { + home: "ARK Library organizes official decks, announcements, videos, images, and files so the community can find trusted resources quickly.", + browse: + "Browse official ARK resources, guides, announcements, images, videos, and downloadable files.", + categories: + "Explore ARK resources by category and quickly find guides, announcements, videos, images, and files.", + official: + "View official ARK recommendations and prioritized trusted resources.", + favorites: + "Favorites are in development and will help you manage commonly used ARK resources.", + about: + "Learn about the ARK Library purpose, resource scope, and indexing notes.", + search: + "Search ARK Library titles, categories, tags, summaries, file types, and body text.", + }, + ja: {}, + ko: {}, + vi: {}, + id: {}, + ms: {}, +}; + +const localeMap: Record = { + "zh-CN": "zh_CN", + en: "en_US", + ja: "ja_JP", + ko: "ko_KR", + vi: "vi_VN", + id: "id_ID", + ms: "ms_MY", +}; + +function metaDescription(lang: Lang, key: string) { + return ( + descriptions[lang][key] || descriptions.en[key] || descriptions.en.home + ); +} + +function setMeta(selector: string, attr: "name" | "property", value: string) { + let meta = document.head.querySelector(selector); + if (!meta) { + meta = document.createElement("meta"); + meta.setAttribute(attr, value); + document.head.appendChild(meta); + } + return meta; +} + +function setLink(rel: string, href: string) { + let link = document.head.querySelector(`link[rel="${rel}"]`); + if (!link) { + link = document.createElement("link"); + link.rel = rel; + document.head.appendChild(link); + } + link.href = href; +} + +function routeMeta( + pathname: string, + search: string, + lang: Lang, + t: (key: string) => string, +): PageMeta { + const params = new URLSearchParams(search); + const q = params.get("q")?.trim(); + const sort = params.get("sort"); + + if (pathname === "/") { + return { + title: t("heroTitle"), + description: metaDescription(lang, "home"), + }; + } + + if (pathname === "/browse") { + if (q) { + return { + title: `${t("search")}: ${q}`, + description: metaDescription(lang, "search"), + }; + } + if (sort === "latest") { + return { + title: t("latest"), + description: metaDescription(lang, "browse"), + }; + } + if (sort === "popular") { + return { + title: t("popular"), + description: metaDescription(lang, "browse"), + }; + } + return { title: t("all"), description: metaDescription(lang, "browse") }; + } + + if (pathname === "/categories") { + return { + title: t("categories"), + description: metaDescription(lang, "categories"), + }; + } + + if (pathname === "/official-recommendations") { + return { + title: t("official"), + description: metaDescription(lang, "official"), + }; + } + + if (pathname === "/favorites") { + return { + title: t("favorites"), + description: metaDescription(lang, "favorites"), + }; + } + + if (pathname === "/about") { + return { + title: t("footerAbout"), + description: metaDescription(lang, "about"), + }; + } + + return { title: t("brand"), description: metaDescription(lang, "home") }; +} + +export function DocumentMeta() { + const { pathname, search } = useLocation(); + const { lang, t } = useI18n(); + const meta = useMemo( + () => routeMeta(pathname, search, lang, t), + [lang, pathname, search, t], + ); + + useEffect(() => { + const brand = t("brand"); + const title = pathname === "/" ? meta.title : `${meta.title} | ${brand}`; + const canonical = `${window.location.origin}${pathname}${search}`; + + document.documentElement.lang = lang; + document.title = title; + + setMeta('meta[name="description"]', "name", "description").content = + meta.description; + setMeta( + 'meta[name="application-name"]', + "name", + "application-name", + ).content = brand; + setMeta('meta[property="og:title"]', "property", "og:title").content = + title; + setMeta( + 'meta[property="og:description"]', + "property", + "og:description", + ).content = meta.description; + setMeta('meta[property="og:url"]', "property", "og:url").content = + canonical; + setMeta('meta[property="og:locale"]', "property", "og:locale").content = + localeMap[lang]; + setMeta('meta[name="twitter:title"]', "name", "twitter:title").content = + title; + setMeta( + 'meta[name="twitter:description"]', + "name", + "twitter:description", + ).content = meta.description; + setLink("canonical", canonical); + }, [lang, meta.description, meta.title, pathname, search, t]); + + return null; +} diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 3ffc598..512ee6b 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -2,6 +2,7 @@ import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { ArkLogoMark } from "../components/ArkLogoMark"; +import { DocumentMeta } from "../components/DocumentMeta"; import { SearchPanel } from "../components/SearchPanel"; import { useI18n, type Lang } from "../i18n"; import { LANG_OPTIONS } from "../i18nLanguages"; @@ -274,6 +275,9 @@ export function PublicLayout() { const [open, setOpen] = useState(false); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); const [q, setQ] = useState(""); + const menuRef = useRef(null); + const mobileMenuButtonRef = useRef(null); + const desktopMenuButtonRef = useRef(null); const nav = useNavigate(); const na = (which: PublicNavWhich) => @@ -290,8 +294,35 @@ export function PublicLayout() { setMobileSearchOpen(false); }; + useEffect(() => { + if (!open) return; + + const closeOnOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; + if ( + menuRef.current?.contains(target) || + mobileMenuButtonRef.current?.contains(target) || + desktopMenuButtonRef.current?.contains(target) + ) { + return; + } + setOpen(false); + }; + const closeOnScroll = () => setOpen(false); + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("touchstart", closeOnOutside); + window.addEventListener("scroll", closeOnScroll, true); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("touchstart", closeOnOutside); + window.removeEventListener("scroll", closeOnScroll, true); + }; + }, [open]); + return (
+