update
This commit is contained in:
@ -3,7 +3,7 @@ import '@/styles/global.css'
|
||||
import LoadingOverlay from '@/components/LoadingOverlay.astro'
|
||||
import AuthModal from '@/components/AuthModal.astro'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'
|
||||
import { getI18n, LOCALE_NAMES, setApiLocale, DEFAULT_LOCALE, STORED_LOCALE_KEY } from '@/lib/i18n'
|
||||
import { config } from '@/lib/config'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
@ -74,6 +74,19 @@ const headers = {
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<script is:inline define:vars={{ defaultLocale: DEFAULT_LOCALE, storedKey: STORED_LOCALE_KEY }}>
|
||||
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem(storedKey);
|
||||
var def = defaultLocale;
|
||||
var p = location.pathname;
|
||||
if (stored && stored !== def && (p === '/' || p === '/' + def)) {
|
||||
location.replace('/' + stored);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
@ -94,7 +107,7 @@ const headers = {
|
||||
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Home')}</a>
|
||||
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
|
||||
{t('Portfolio')}
|
||||
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
|
||||
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full tracking-tight leading-none">
|
||||
2026
|
||||
</span>
|
||||
</a>
|
||||
@ -138,7 +151,7 @@ const headers = {
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 语言切换器:恢复为 Alpine.js 控制开合(x-data + x-show + x-transition) -->
|
||||
<div class="relative" id="lang-switch" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<div class="relative" id="lang-switch" x-data="langSwitch()" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-[18px] h-[18px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@ -154,9 +167,9 @@ const headers = {
|
||||
>
|
||||
{headers.languages.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={getRelativeLocaleUrl(l.code, pathWithoutLocale)}
|
||||
@click="open = false"
|
||||
<a
|
||||
href={getRelativeLocaleUrl(l.code, pathWithoutLocale)}
|
||||
@click={`open = false; pick('${l.code}')`}
|
||||
class:list={[
|
||||
'block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
|
||||
l.code === locale ? 'font-bold' : 'opacity-60',
|
||||
@ -176,7 +189,7 @@ const headers = {
|
||||
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
|
||||
<span x-text="shortName"></span>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] font-mono text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
|
||||
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
@ -203,11 +216,70 @@ const headers = {
|
||||
import { getUser } from "@/lib/auth"
|
||||
import type { AuthUser } from "@/lib/auth"
|
||||
import { fetchMe, logout, enforceIdleLogout } from "@/lib/api"
|
||||
import { storeLocale, getStoredLocale, DEFAULT_LOCALE, LOCALE_NAMES } from "@/lib/i18n"
|
||||
|
||||
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
|
||||
enforceIdleLogout()
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// 语言切换器:点击某语言时记住偏好(storeLocale),下次进首页自动跳该语言
|
||||
Alpine.data("langSwitch", () => ({
|
||||
open: false,
|
||||
pick(code: string) {
|
||||
storeLocale(code as "en" | "cn")
|
||||
},
|
||||
}))
|
||||
|
||||
// 语言建议弹窗:未选过语言、且浏览器语言与当前首页语言不符时,提示是否切换
|
||||
Alpine.data("localeSuggest", () => {
|
||||
const names = {
|
||||
en: LOCALE_NAMES.en.nativeName,
|
||||
cn: LOCALE_NAMES.cn.nativeName,
|
||||
}
|
||||
return {
|
||||
visible: false,
|
||||
target: "en" as "en" | "cn",
|
||||
current: "en" as "en" | "cn",
|
||||
names,
|
||||
init() {
|
||||
try {
|
||||
const def = DEFAULT_LOCALE
|
||||
// 已选过偏好(含此前点「保持当前」的标记)→ 交给 head 脚本重定向逻辑,这里不弹
|
||||
if (getStoredLocale()) return
|
||||
const nav = (
|
||||
navigator.language ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
""
|
||||
).toLowerCase()
|
||||
const detected: "en" | "cn" = /^(zh|cmn|zho)/.test(nav) ? "cn" : "en"
|
||||
const p = location.pathname
|
||||
const onHome = p === "/" || p === "/" + def
|
||||
if (!onHome) return
|
||||
const cur = p === "/" ? def : (p.split("/")[1] as "en" | "cn")
|
||||
if (detected === cur) return
|
||||
this.target = detected
|
||||
this.current = cur
|
||||
this.visible = true
|
||||
} catch (e) {}
|
||||
},
|
||||
get message(): string {
|
||||
if (this.target === "cn") {
|
||||
return "检测到您的浏览器语言为「中文」,是否切换到中文界面?"
|
||||
}
|
||||
return `Your browser language appears to be ${this.names[this.target]}. Switch to ${this.names[this.target]}?`
|
||||
},
|
||||
switchLang() {
|
||||
storeLocale(this.target)
|
||||
location.replace("/" + this.target)
|
||||
},
|
||||
keep() {
|
||||
// 记下来:用户选择保持当前语言,后续不再提示
|
||||
storeLocale(this.current)
|
||||
this.visible = false
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Alpine.data("authMenu", () => ({
|
||||
user: null as AuthUser | null,
|
||||
open: false,
|
||||
@ -245,7 +317,7 @@ const headers = {
|
||||
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
|
||||
Portfolio Studio
|
||||
</span>
|
||||
<span class="text-[10px] font-mono bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
|
||||
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
|
||||
2026 Edition
|
||||
</span>
|
||||
</div>
|
||||
@ -255,7 +327,7 @@ const headers = {
|
||||
</p>
|
||||
|
||||
{/* Location Badge */}
|
||||
<div class="flex flex-wrap gap-3 text-[11px] font-mono text-zinc-500 uppercase tracking-widest pt-1">
|
||||
<div class="flex flex-wrap gap-3 text-[11px] text-zinc-500 uppercase tracking-widest pt-1">
|
||||
<span>Paris</span>
|
||||
<span>•</span>
|
||||
<span>Milan</span>
|
||||
@ -267,13 +339,40 @@ const headers = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
|
||||
<div class="mt-6 text-[11px] text-zinc-400 uppercase tracking-widest">
|
||||
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
|
||||
<AuthModal />
|
||||
|
||||
{/* 语言建议弹窗:未选过语言且浏览器语言与当前首页不符时,提示是否切换到该语言 */}
|
||||
<div
|
||||
x-data="localeSuggest()"
|
||||
x-show="visible"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-[60] w-[min(92vw,420px)] bg-white border border-black/15 shadow-lg px-5 py-4 flex flex-col gap-3"
|
||||
role="dialog"
|
||||
aria-label="Language suggestion"
|
||||
>
|
||||
<p class="text-[13px] leading-snug text-black" x-text="message"></p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@click="keep()"
|
||||
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] border border-black/20 hover:bg-black/5 transition-colors"
|
||||
x-text="names[current]"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="switchLang()"
|
||||
class="px-3 py-1.5 text-[12px] uppercase tracking-[0.5px] bg-black text-white hover:opacity-80 transition-opacity"
|
||||
x-text="names[target]"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user