Files
frontend_v2/src/layouts/Layout.astro
toom1996 199300b203 update
2026-09-15 00:31:31 +08:00

287 lines
15 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 } 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)
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)取数统一带上 ?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'}>
<head>
<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 p-[30px_50px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.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 font-mono 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="{ open: false }" @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"
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] font-mono 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"
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
enforceIdleLogout()
document.addEventListener("alpine:init", () => {
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 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200">
{/* 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">
Portfolio Studio
</span>
<span class="text-[10px] font-mono bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
2026 Edition
</span>
</div>
<p class="text-xs text-zinc-600 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] font-mono text-zinc-500 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>
</div>
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
</div>
</footer>
{/* 全局登录门禁层(login-required 属性 + 统一登录浮层) */}
<AuthModal />
</body>
</html>
<style>
html,
body {
margin: 0;
}
</style>