Files
frontend_v2/src/layouts/Layout.astro
toom1996 f8ff5b86c7 update
2026-09-25 11:32:11 +08:00

409 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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, DEFAULT_LOCALE, STORED_LOCALE_KEY } from '@/lib/i18n'
import { config } from '@/lib/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
interface Props {
title?: string
description?: string
keywords?: string
}
const { title, description, keywords } = Astro.props
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 语言偏好跳转用的 localStorage key 与默认语言:注入到 <html> 的 data-*,供内联脚本读取
const storedLocaleKey = STORED_LOCALE_KEY
const defaultLocaleVal = DEFAULT_LOCALE
const SITE_NAME = config.siteName ?? "Xisoa"
const siteTitle = title ? `${title} — ${SITE_NAME}` : SITE_NAME
// 默认 TDK(按语言);各页面可经 Layout 的 description/keywords 覆盖。
const defaultDescription =
locale === 'cn'
? `${SITE_NAME} 时尚档案收录全球时装周走秀造型与街头抓拍,可按品牌、季节、城市检索。`
: `${SITE_NAME} is a fashion archive of runway looks and street style snaps — browse by brand, season and city.`
const defaultKeywords =
locale === 'cn'
? "时尚档案,走秀,街拍,时装周,品牌,季节"
: "fashion archive,runway looks,street style,fashion week,brand,season"
const metaDescription = description ?? defaultDescription
const metaKeywords = keywords ?? defaultKeywords
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一走 X-Locale 头,
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 script 标签;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
// (账号区现改为 Alpine 组件,译文直接在模板内用 t() 输出,无需 data 属性中转)
const pathName = Astro.url.pathname;
const locales = toCodes(i18n!.locales);
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName;
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 script 控制开合。
const headers = {
// 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
runways: [
{ label: t('all runway looks'), href: href(locale, ROUTES.runwayLooks) },
{ label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' },
{ label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' },
{ label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' },
{ label: t('Resort'), href: href(locale, ROUTES.runwayLooks) + '?collection=resort' },
],
// 街拍下拉菜单项:按地区分类(值即后端 street_snap.city 列存储的规范英文名),
// 深链带 ?city= 参数,由 StreetSnaps 的 init() 读取并自动选中筛选。
// 仅保留 4 项(全部 + 三座核心时尚之都),完整城市列表在街拍页侧栏 City 筛选里。
streets: [
{ label: t('All Street Snaps'), href: href(locale, ROUTES.streetSnaps) },
{ label: t('Paris'), href: href(locale, ROUTES.streetSnaps) + '?city=Paris' },
{ label: t('New York'), href: href(locale, ROUTES.streetSnaps) + '?city=New York' },
{ label: t('Milan'), href: href(locale, ROUTES.streetSnaps) + '?city=Milan' },
],
// 语言列表(code + 本地名称)
languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })),
}
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'} data-stored-key={storedLocaleKey} data-default-locale={defaultLocaleVal}>
<head>
<script is:inline>
// 记住语言偏好:若用户曾选过非默认语言,进首页(/ 或 /<default>)时自动跳到该语言首页
(function () {
try {
var stored = localStorage.getItem(document.documentElement.dataset.storedKey);
var def = document.documentElement.dataset.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" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{siteTitle}</title>
<meta name="description" content={metaDescription} />
<meta name="keywords" content={metaKeywords} />
</head>
<body class="bg-white text-black min-h-screen flex flex-col px-5 py-5 md:px-[50px] md:py-[30px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingOverlay.astro -->
<LoadingOverlay />
<!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8">
<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 tracking-tight leading-none">
2026
</span>
</a>
<!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('all runway looks')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open">
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.runways.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
{item.label}
</a>
))
}
</div>
</div>
<!-- 街拍:菜单项用 Astro 渲染(headers.streets.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 street style 列表" aria-haspopup="true" :aria-expanded="open">
<svg viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.streets.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
{item.label}
</a>
))
}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 语言切换器:恢复为 Alpine.js 控制开合(x-data + x-show + x-transition) -->
<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"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span>{LOCALE_NAMES[locale].nativeName}</span>
</button>
<ul
x-show="open" x-cloak
x-transition.opacity.duration.150ms
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
>
{headers.languages.map((l) => (
<li>
<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',
]}
>{l.label}</a>
</li>
))}
</ul>
</div>
<div class="flex items-center gap-3" x-data="authMenu">
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏)。
login-redirect:交给 AuthModal 的全局门禁捕获,带上「当前页」跳登录页,
登录成功后回到原页,而不是一律落到个人中心(无 JS 时回落 href 到 /login)。 -->
<a x-show="!user" x-cloak login-redirect href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
<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] 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>
</button>
<ul x-show="open" x-cloak x-transition.opacity.duration.150ms class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2" role="menu">
<li role="none">
<a href={href(locale, ROUTES.account)} @click="open = false" role="menuitem" class="block px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60">{t('my account')}</a>
</li>
<li role="none">
<button type="button" @click="doLogout()" role="menuitem" class="block w-full text-left px-4 py-1.5 text-[13px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors opacity-60 bg-transparent border-0 cursor-pointer font-normal">{t('sign out')}</button>
</li>
</ul>
</div>
</div>
</div>
</nav>
<div class="flex-1 flex flex-col">
<slot />
</div>
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script>
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,
init() {
this.user = getUser() // 立即恢复缓存,避免登录态闪烁
fetchMe()
.then((u) => {
this.user = u ?? this.user ?? null
})
.catch(() => {
this.user = getUser() ?? null
})
},
// 用户名最多展示前 8 个字符,超出加省略号
get shortName() {
const n = (this.user && this.user.username) || ""
return n.length > 8 ? n.slice(0, 8) + "…" : n
},
doLogout() {
logout().finally(() => location.reload())
},
}))
})
</script>
{/* Top Footer Grid */}
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-8 mb-10 pb-10 border-b border-black/10">
{/* Column 1: Brand & Manifesto */}
<div class="md:col-span-5 space-y-4">
<div class="flex items-center gap-2">
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
{SITE_NAME}
</span>
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest rounded-full">
2026
</span>
</div>
<p class="text-xs text-[var(--c-gray-800)] leading-relaxed max-w-md">
{t('Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.')}
</p>
{/* Location Badge */}
<div class="flex flex-wrap gap-3 text-[11px] text-[var(--c-gray-700)] uppercase tracking-widest pt-1">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
{/* Column 2: Explore */}
<div class="md:col-span-4 md:col-start-7 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Explore</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href={href(locale, ROUTES.runwayLooks)} class="hover:text-black transition-colors">{t('all runway looks')}</a></li>
<li><a href={href(locale, ROUTES.streetSnaps)} class="hover:text-black transition-colors">{t('Street Style')}</a></li>
<li><a href={href(locale, ROUTES.account)} class="hover:text-black transition-colors">{t('my account')}</a></li>
</ul>
</div>
{/* Column 3: Connect */}
<div class="md:col-span-2 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Connect</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href="#" class="hover:text-black transition-colors">Instagram</a></li>
<li><a href="#" class="hover:text-black transition-colors">Newsletter</a></li>
<li><a href="#" class="hover:text-black transition-colors">Contact</a></li>
</ul>
</div>
</div>
<div class="mt-6 text-[11px] text-[var(--c-gray-600)] uppercase tracking-widest">
© 2026 {SITE_NAME}. {t('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>
<style>
html,
body {
margin: 0;
}
</style>