feat: ship minecraft theme updates and platform workflow improvements
这个提交包含在:
@@ -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")}
|
||||
|
||||
在新工单中引用
屏蔽一个用户