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 (
+