409 lines
20 KiB
Plaintext
409 lines
20 KiB
Plaintext
---
|
||
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>
|