Files
frontend_v2/src/layouts/Layout.astro
toom1996 3c7c5d8855 update
2026-08-29 15:47:58 +08:00

198 lines
9.8 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 { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils'
import { LOCALES, LOCALE_NAMES } from '@/i18n/config'
import { setApiLocale } from '@/lib/api'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
import LanguageDetector from '@/components/LanguageDetector.astro'
const { title } = Astro.props as { title?: string }
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一带上 ?locale=,
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
const hiLabel = t('Hi')
const logoutLabel = t('Logout')
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 = {
// 走秀下拉菜单项(href 统一指向 /shows)
runways: [
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks), current: true },
{ label: 'Spring / Summer 2026', href: href(locale, ROUTES.shows) },
{ label: 'Fall / Winter 2025', href: href(locale, ROUTES.shows) },
{ label: 'Resort 2026', href: href(locale, ROUTES.shows) },
{ label: 'Pre-Fall 2025', href: href(locale, ROUTES.shows) },
{ label: 'Couture FW25', href: href(locale, ROUTES.shows) },
{ label: 'Menswear SS26', href: href(locale, ROUTES.shows) },
{ label: 'Menswear FW25', href: href(locale, ROUTES.shows) },
],
// 语言列表(code + 本地名称)
languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })),
}
---
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<!-- TODO <LanguageDetector /> -->
<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>
</head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 顶部主导航栏 -->
<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.portfolio)} 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.shows)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</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-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>
<a href={href(locale, ROUTES.latestProjects)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<a href={href(locale, ROUTES.about)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
<a href={href(locale, ROUTES.contact)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
</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-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-2" id="auth-slot" data-hi-label={hiLabel} data-logout-label={logoutLabel}>
<a href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
<a href={href(locale, ROUTES.register)} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
</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, fetchMe, clearSession } from "@/lib/api"
const slot = document.getElementById("auth-slot")
const hiLabel = slot?.dataset.hiLabel ?? "Hi"
const logoutLabel = slot?.dataset.logoutLabel ?? "Logout"
function render(user) {
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
slot.innerHTML = `<span class="opacity-70">${hiLabel}, ${user.username}</span><button id="logout-btn" class="hover:opacity-60 transition-opacity cursor-pointer bg-transparent border-0 p-0 font-bold text-[13px] uppercase tracking-[0.5px]">${logoutLabel}</button>`
const btn = slot.querySelector("#logout-btn")
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
}
const cached = getUser()
if (cached) render(cached)
fetchMe().then((user) => render(user)).catch(() => {})
</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>
</body>
</html>
<style>
html,
body {
margin: 0;
}
</style>