- {addon.iconUrl ? (
+ {displayIcon ? (
- {addon.name}
+ {displayName}
- {categoryLabels[addon.category] || addon.category}
+ {categoryLabel}
{latestVersion && (
@@ -68,12 +79,15 @@ export function AddonCard({ addon }: AddonCardProps) {
- {addon.summary}
+ {displaySummary}
- {addon.totalDownloads.toLocaleString()} 次下载
+
+ {addon.totalDownloads.toLocaleString()}
+ {t("addons", "downloadsCount")}
+
diff --git a/src/components/public/AddonDetail.tsx b/src/components/public/AddonDetail.tsx
new file mode 100644
index 0000000..ea41b32
--- /dev/null
+++ b/src/components/public/AddonDetail.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
+import { DownloadButton } from "@/components/public/DownloadButton";
+import { MarkdownContent } from "@/components/public/MarkdownContent";
+import { useLocale } from "@/i18n/LocaleProvider";
+import type { Messages } from "@/i18n/messages";
+
+interface ReleaseLite {
+ id: string;
+ version: string;
+ changelog: string;
+ changelogEn: string;
+ gameVersion: string;
+ wowVersion: string;
+ downloadCount: number;
+ isLatest: boolean;
+ createdAt: string;
+}
+
+interface ScreenshotLite {
+ id: string;
+ imageUrl: string;
+}
+
+interface AddonDetailProps {
+ addon: {
+ slug: string;
+ name: string;
+ nameEn: string;
+ summary: string;
+ summaryEn: string;
+ description: string;
+ descriptionEn: string;
+ iconUrl: string | null;
+ category: string;
+ totalDownloads: number;
+ wowVersion: string;
+ releases: ReleaseLite[];
+ screenshots: ScreenshotLite[];
+ };
+}
+
+function pickLocale(zh: string, en: string, locale: "zh" | "en") {
+ if (locale === "en" && en && en.trim()) return en;
+ return zh;
+}
+
+export function AddonDetail({ addon }: AddonDetailProps) {
+ const { locale, t } = useLocale();
+ const dateLocale = locale === "en" ? "en-US" : "zh-CN";
+
+ const displayName = pickLocale(addon.name, addon.nameEn, locale);
+ const displaySummary = pickLocale(addon.summary, addon.summaryEn, locale);
+ const displayDescription = pickLocale(
+ addon.description,
+ addon.descriptionEn,
+ locale
+ );
+ const categoryKey = addon.category as keyof Messages["category"];
+ const fromDict = t("category", categoryKey);
+ const categoryLabel = fromDict.startsWith("category.") ? addon.category : fromDict;
+
+ const latestRelease = addon.releases.find((r) => r.isLatest);
+ const displayIcon =
+ addon.iconUrl || addon.screenshots?.[0]?.imageUrl || null;
+
+ return (
+
+
+
+ {t("addons", "back")}
+
+
+ {/* Header */}
+
+ {displayIcon ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {displayName}
+
+
+ {displaySummary}
+
+
+
+ {categoryLabel}
+
+
+ WoW {addon.wowVersion}
+
+
+
+ {addon.totalDownloads.toLocaleString()}
+ {t("addons", "downloadsCount")}
+
+ {latestRelease && (
+
+
+ v{latestRelease.version}
+
+ )}
+
+
+ {latestRelease && (
+
+
+
+ )}
+
+
+
+
+
+ {/* Description */}
+
+
+
+ {t("addons", "introduction")}
+
+
+
+
+ {/* Screenshots */}
+ {addon.screenshots.length > 0 && (
+
+
+ {t("addons", "screenshots")}
+
+
+ {addon.screenshots.map((ss) => (
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Sidebar - Releases */}
+
+
+
+ {t("addons", "releaseHistory")}
+
+
+ {t("addons", "releaseCount", { n: addon.releases.length })}
+
+
+ {addon.releases.map((release) => {
+ const cl = pickLocale(
+ release.changelog,
+ release.changelogEn,
+ locale
+ );
+ return (
+
+
+
+
+ v{release.version}
+
+ {release.isLatest && (
+
+ {t("addons", "latestTag")}
+
+ )}
+
+
+
+ {release.gameVersion && (
+
+ WoW {release.gameVersion}
+
+ )}
+
+
+
+ {new Date(release.createdAt).toLocaleDateString(
+ dateLocale
+ )}
+
+
+
+ {release.downloadCount}
+
+
+ {cl && (
+
+ {cl}
+
+ )}
+
+ );
+ })}
+ {addon.releases.length === 0 && (
+
+ {t("addons", "noReleases")}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/public/AddonsCategoryFilter.tsx b/src/components/public/AddonsCategoryFilter.tsx
new file mode 100644
index 0000000..53836ca
--- /dev/null
+++ b/src/components/public/AddonsCategoryFilter.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import Link from "next/link";
+import { useLocale } from "@/i18n/LocaleProvider";
+import type { Messages } from "@/i18n/messages";
+
+export function AddonsCategoryFilter({
+ active,
+ categories,
+}: {
+ active: string | null;
+ categories: { slug: string; count: number }[];
+}) {
+ const { t } = useLocale();
+
+ function categoryLabel(slug: string) {
+ const key = slug as keyof Messages["category"];
+ const fromDict = t("category", key);
+ return fromDict.startsWith("category.") ? slug : fromDict;
+ }
+
+ return (
+
+
+ {t("addons", "all")}
+
+ {categories.map((cat) => (
+
+ {categoryLabel(cat.slug)} ({cat.count})
+
+ ))}
+
+ );
+}
diff --git a/src/components/public/ArticleCard.tsx b/src/components/public/ArticleCard.tsx
new file mode 100644
index 0000000..34fa590
--- /dev/null
+++ b/src/components/public/ArticleCard.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import Link from "next/link";
+import Image from "next/image";
+import { Calendar } from "lucide-react";
+import { useLocale } from "@/i18n/LocaleProvider";
+
+interface ArticleCardProps {
+ article: {
+ id: string;
+ slug: string;
+ title: string;
+ titleEn: string;
+ summary: string;
+ summaryEn: string;
+ coverImage: string | null;
+ createdAt: string;
+ };
+ variant?: "compact" | "default";
+}
+
+function pickLocale(zh: string, en: string, locale: "zh" | "en") {
+ if (locale === "en" && en && en.trim()) return en;
+ return zh;
+}
+
+export function ArticleCard({ article, variant = "default" }: ArticleCardProps) {
+ const { locale } = useLocale();
+ const dateLocale = locale === "en" ? "en-US" : "zh-CN";
+ const title = pickLocale(article.title, article.titleEn, locale);
+ const summary = pickLocale(article.summary, article.summaryEn, locale);
+
+ const compact = variant === "compact";
+
+ return (
+
+ {article.coverImage && (
+
+
+
+ )}
+
+
+ {title}
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+
+ {new Date(article.createdAt).toLocaleDateString(dateLocale)}
+
+
+
+ );
+}
diff --git a/src/components/public/ArticleDetail.tsx b/src/components/public/ArticleDetail.tsx
new file mode 100644
index 0000000..f137416
--- /dev/null
+++ b/src/components/public/ArticleDetail.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import Link from "next/link";
+import Image from "next/image";
+import { Calendar, ArrowLeft } from "lucide-react";
+import { MarkdownContent } from "@/components/public/MarkdownContent";
+import { useLocale } from "@/i18n/LocaleProvider";
+
+interface ArticleDetailProps {
+ article: {
+ title: string;
+ titleEn: string;
+ content: string;
+ contentEn: string;
+ coverImage: string | null;
+ createdAt: string;
+ };
+}
+
+function pickLocale(zh: string, en: string, locale: "zh" | "en") {
+ if (locale === "en" && en && en.trim()) return en;
+ return zh;
+}
+
+export function ArticleDetail({ article }: ArticleDetailProps) {
+ const { locale, t } = useLocale();
+ const dateLocale = locale === "en" ? "en-US" : "zh-CN";
+ const title = pickLocale(article.title, article.titleEn, locale);
+ const content = pickLocale(article.content, article.contentEn, locale);
+
+ return (
+
+
+
+ {t("articles", "back")}
+
+
+
+ {title}
+
+
+
+
+ {new Date(article.createdAt).toLocaleDateString(dateLocale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+ {article.coverImage && (
+
+
+
+ )}
+
+
+
+
+
+
+ {t("articles", "back")}
+
+
+
+ );
+}
diff --git a/src/components/public/BgmPlayer.tsx b/src/components/public/BgmPlayer.tsx
new file mode 100644
index 0000000..7e18007
--- /dev/null
+++ b/src/components/public/BgmPlayer.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Play, Pause, Volume2 } from "lucide-react";
+
+interface BgmPlayerProps {
+ src: string;
+ autoplay: boolean;
+ volume: number; // 0–100
+}
+
+const STORAGE_KEY = "nanami.bgm.userPaused";
+
+export function BgmPlayer({ src, autoplay, volume }: BgmPlayerProps) {
+ const audioRef = useRef
(null);
+ const [playing, setPlaying] = useState(false);
+ const [showVolume, setShowVolume] = useState(false);
+ const [vol, setVol] = useState(Math.max(0, Math.min(100, volume)));
+ const [autoplayBlocked, setAutoplayBlocked] = useState(false);
+
+ useEffect(() => {
+ setVol(Math.max(0, Math.min(100, volume)));
+ }, [volume]);
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ audio.volume = vol / 100;
+ }, [vol]);
+
+ useEffect(() => {
+ if (!src) return;
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.loop = true;
+ audio.volume = vol / 100;
+
+ const userPaused = localStorage.getItem(STORAGE_KEY) === "1";
+
+ if (autoplay && !userPaused) {
+ const p = audio.play();
+ if (p && typeof p.then === "function") {
+ p.then(() => {
+ setPlaying(true);
+ setAutoplayBlocked(false);
+ }).catch(() => {
+ setPlaying(false);
+ setAutoplayBlocked(true);
+ });
+ }
+ }
+
+ const onPlay = () => setPlaying(true);
+ const onPause = () => setPlaying(false);
+ audio.addEventListener("play", onPlay);
+ audio.addEventListener("pause", onPause);
+ return () => {
+ audio.removeEventListener("play", onPlay);
+ audio.removeEventListener("pause", onPause);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [src, autoplay]);
+
+ // If autoplay was blocked, start playback on the first user interaction.
+ useEffect(() => {
+ if (!autoplayBlocked) return;
+ if (!audioRef.current) return;
+
+ const onInteract = () => {
+ const userPaused = localStorage.getItem(STORAGE_KEY) === "1";
+ if (userPaused) return;
+ const a = audioRef.current;
+ if (!a) return;
+ a.play()
+ .then(() => {
+ setAutoplayBlocked(false);
+ })
+ .catch(() => {
+ // still blocked, wait for another interaction
+ });
+ };
+
+ window.addEventListener("pointerdown", onInteract, { once: true });
+ window.addEventListener("keydown", onInteract, { once: true });
+ return () => {
+ window.removeEventListener("pointerdown", onInteract);
+ window.removeEventListener("keydown", onInteract);
+ };
+ }, [autoplayBlocked]);
+
+ if (!src) return null;
+
+ const toggle = async () => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (audio.paused) {
+ try {
+ await audio.play();
+ localStorage.removeItem(STORAGE_KEY);
+ setAutoplayBlocked(false);
+ } catch {
+ /* ignore */
+ }
+ } else {
+ audio.pause();
+ localStorage.setItem(STORAGE_KEY, "1");
+ }
+ };
+
+ return (
+
+
+
+ {showVolume && (
+
+
+ setVol(Number(e.target.value))}
+ className="bgm-range h-1 w-28 cursor-pointer appearance-none rounded-full bg-white/20 accent-white/80"
+ aria-label="音量"
+ />
+
+ {vol}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/public/ChangelogTimeline.tsx b/src/components/public/ChangelogTimeline.tsx
new file mode 100644
index 0000000..01ceed4
--- /dev/null
+++ b/src/components/public/ChangelogTimeline.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { Download, Calendar, Tag } from "lucide-react";
+import { useLocale } from "@/i18n/LocaleProvider";
+
+interface VersionItem {
+ id: string;
+ version: string;
+ versionCode: number;
+ changelog: string;
+ changelogEn: string;
+ fileSize: number;
+ isLatest: boolean;
+ forceUpdate: boolean;
+ wowVersion: string;
+ downloadCount: number;
+ createdAt: string;
+}
+
+const STRINGS = {
+ zh: {
+ title: "版本历史",
+ subtitle: "Nanami 启动器更新日志",
+ empty: "暂无版本记录",
+ latest: "最新版本",
+ forceUpdate: "强制更新",
+ downloads: "次下载",
+ noChangelog: "无更新说明",
+ },
+ en: {
+ title: "Release History",
+ subtitle: "Nanami Launcher changelog",
+ empty: "No releases yet",
+ latest: "Latest",
+ forceUpdate: "Required",
+ downloads: " downloads",
+ noChangelog: "No release notes",
+ },
+} as const;
+
+export function ChangelogTimeline({
+ versions,
+ wowVersion,
+}: {
+ versions: VersionItem[];
+ wowVersion: string;
+}) {
+ const { locale } = useLocale();
+ const s = STRINGS[locale];
+ const dateLocale = locale === "en" ? "en-US" : "zh-CN";
+
+ return (
+
+
+
+ {s.title}
+
+
+ WoW {wowVersion}
+
+
+
+ {s.subtitle}
+
+
+ {versions.length === 0 ? (
+ {s.empty}
+ ) : (
+
+
+
+
+ {versions.map((v, idx) => {
+ const text =
+ locale === "en" && v.changelogEn && v.changelogEn.trim()
+ ? v.changelogEn
+ : v.changelog;
+ return (
+
+
+
+
+
+
+ v{v.version}
+
+ {v.isLatest && (
+
+ {s.latest}
+
+ )}
+ {v.forceUpdate && (
+
+ {s.forceUpdate}
+
+ )}
+
+
+
+
+
+ {new Date(v.createdAt).toLocaleDateString(dateLocale)}
+
+
+
+ Build {v.versionCode}
+
+
+
+ {v.downloadCount.toLocaleString()}
+ {s.downloads}
+
+ {v.fileSize > 0 && (
+
+ {(v.fileSize / 1024 / 1024).toFixed(1)} MB
+
+ )}
+
+
+
+ {text || s.noChangelog}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/public/Footer.tsx b/src/components/public/Footer.tsx
index bea213f..a52b882 100644
--- a/src/components/public/Footer.tsx
+++ b/src/components/public/Footer.tsx
@@ -1,13 +1,20 @@
+"use client";
+
import Link from "next/link";
import { Package, Download, FileText, Clock } from "lucide-react";
-
-const quickLinks = [
- { href: "/addons", label: "插件列表", icon: Package },
- { href: "/articles", label: "公告文章", icon: FileText },
- { href: "/changelog", label: "更新日志", icon: Clock },
-];
+import { useLocale } from "@/i18n/LocaleProvider";
+import { useWowVersion } from "@/i18n/WowVersionProvider";
export function Footer() {
+ const { t } = useLocale();
+ const { wowVersion } = useWowVersion();
+
+ const quickLinks = [
+ { href: "/addons", label: t("footer", "addons"), icon: Package },
+ { href: "/articles", label: t("footer", "articlesFull"), icon: FileText },
+ { href: "/changelog", label: t("footer", "changelog"), icon: Clock },
+ ];
+
return (