198 lines
9.8 KiB
Plaintext
198 lines
9.8 KiB
Plaintext
---
|
||
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>
|