feat: ship minecraft theme updates and platform workflow improvements

这个提交包含在:
Codex CLI
2026-02-15 17:36:56 +08:00
父节点 cd7540ab9d
当前提交 37266bb846
修改 32 个文件,包含 5297 行新增119 行删除

查看文件

@@ -3,6 +3,7 @@
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { PixelAvatar } from "@/components/pixel-avatar";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth";
@@ -19,6 +20,11 @@ type NavGroup = {
links: NavLink[];
};
type MeProfile = {
id?: number;
username?: string;
};
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
const groups: NavGroup[] = [
{
@@ -97,12 +103,13 @@ export function AppNav() {
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
const [isAdmin, setIsAdmin] = useState(false);
const [meProfile, setMeProfile] = useState<MeProfile | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
const activeGroup = resolveActiveGroup(pathname, navGroups);
const usePopupSecondary = theme === "default";
const usePopupSecondary = theme === "default" || theme === "minecraft";
useEffect(() => {
let canceled = false;
@@ -112,15 +119,20 @@ export function AppNav() {
setHasToken(Boolean(token));
if (!token) {
setIsAdmin(false);
setMeProfile(null);
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
const me = await apiFetch<MeProfile>("/api/v1/me", {}, token);
if (!canceled) {
setIsAdmin((me?.username ?? "") === "admin");
setMeProfile(me ?? null);
}
} catch {
if (!canceled) setIsAdmin(false);
if (!canceled) {
setIsAdmin(false);
setMeProfile(null);
}
}
};
const onRefresh = () => {
@@ -149,6 +161,16 @@ export function AppNav() {
}, [desktopOpenGroup, usePopupSecondary]);
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
const avatarSeed = meProfile
? `${meProfile.username ?? "user"}-${meProfile.id ?? ""}`
: "guest";
const handleLogout = () => {
clearToken();
setHasToken(false);
setIsAdmin(false);
setMeProfile(null);
setDesktopOpenGroup(null);
};
return (
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
@@ -188,7 +210,11 @@ export function AppNav() {
{group.label}
</button>
{opened && (
<div className="absolute left-0 top-full z-50 mt-2 min-w-[11rem] rounded-md border bg-[color:var(--surface)] p-1 shadow-lg">
<div
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${
group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
}`}
>
{group.links.map((item) => {
const linkActive = isActivePath(pathname, item.href);
return (
@@ -207,6 +233,64 @@ export function AppNav() {
</button>
);
})}
{group.key === "account" && (
<div className="mt-2 space-y-2 border-t border-zinc-200 px-2 pt-2">
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={theme}
onChange={(e) => setTheme(e.target.value as ThemeId)}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={language}
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
>
<option value="en">{t("prefs.lang.en")}</option>
<option value="zh">{t("prefs.lang.zh")}</option>
</select>
</label>
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-200 px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<>
<PixelAvatar
seed={avatarSeed}
size={20}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
{meProfile?.username && (
<span className="truncate text-xs text-zinc-500">{meProfile.username}</span>
)}
</>
)}
</div>
{hasToken && (
<button
type="button"
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={handleLogout}
>
{t("nav.logout")}
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
@@ -274,7 +358,7 @@ export function AppNav() {
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
<div className={`mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm ${usePopupSecondary ? "md:hidden" : ""}`}>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
@@ -306,13 +390,17 @@ export function AppNav() {
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<PixelAvatar
seed={avatarSeed}
size={24}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
)}
{hasToken && (
<button
onClick={() => {
clearToken();
setHasToken(false);
setIsAdmin(false);
}}
onClick={handleLogout}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
{t("nav.logout")}