update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-01 00:08:05 +08:00
parent 97b023bbbd
commit 50e5775dc8
9 changed files with 434 additions and 141 deletions

View File

@ -347,6 +347,7 @@ const seasonOptions: { value: string; label: string }[] = [
const ct = new URLSearchParams(window.location.search).get('collection')
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
this.loadPage(1)
},
@ -393,7 +394,7 @@ const seasonOptions: { value: string; label: string }[] = [
},
refreshFilter() {
if (!this.requireLogin()) return
// 首页公开:筛选切换后重新加载第 1 页,未登录也可查看
this.loadPage(1)
},

View File

@ -296,6 +296,7 @@ const cityOptions: { value: string; label: string }[] = [
const city = new URLSearchParams(window.location.search).get('city')
const allowed = (this.cityOptions || []).map((c: any) => c.value)
if (city && allowed.includes(city)) this.selectedCity = city
// 首页公开:未登录也直接加载首屏,不弹登录框
this.loadPage(1)
},

View File

@ -51,6 +51,8 @@ export const dict: Record<string, TranslationEntry> = {
'please log in': { cn: '请先登录' },
'login to continue': { cn: '登录以继续使用筛选与翻页' },
'login prompt': { cn: '登录后可筛选与翻页浏览全部档案' },
'members': { cn: '会员专区' },
'back to site': { cn: '返回网站' },
'email': { cn: '邮箱' },
'my collections': { cn: '我的收藏' },
'my history': { cn: '浏览历史' },

View File

@ -2,7 +2,7 @@
import '@/styles/global.css'
import { href, ROUTES } from '@/lib/routes'
import { getI18n } from '@/i18n/utils'
import { LOCALES, LOCALE_NAMES } from '@/i18n/config'
import { LOCALE_NAMES } from '@/i18n/config'
import { setApiLocale } from '@/lib/api'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
@ -17,8 +17,7 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
const hiLabel = t('Hi')
const logoutLabel = t('Logout')
// (账号区现改为 Alpine 组件,译文直接在模板内用 t() 输出,无需 data 属性中转)
const pathName = Astro.url.pathname;
@ -141,8 +140,27 @@ const headers = {
))}
</ul>
</div>
<div class="flex items-center gap-3" 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 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<div class="flex items-center gap-3" x-data="authMenu">
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏) -->
<a x-show="!user" 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" 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>
<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-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>
@ -154,18 +172,35 @@ const headers = {
<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, logout } 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", () => { logout().finally(() => location.reload()) })
}
const cached = getUser()
if (cached) render(cached)
fetchMe().then((user) => render(user)).catch(() => {})
document.addEventListener("alpine:init", () => {
Alpine.data("authMenu", () => ({
user: 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">

View File

@ -177,13 +177,27 @@ async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const fullPath = "/api/v1" + path
const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString())
const url = getBase() + fullPath + qs
const res = await fetch(url, {
headers: {
"X-Sign": sig,
"X-Sign-Ts": ts,
"X-Sign-Nonce": nonce,
},
})
const headers: Record<string, string> = {
"X-Sign": sig,
"X-Sign-Ts": ts,
"X-Sign-Nonce": nonce,
}
// 已登录则自动附带 Bearer(翻页/筛选等需鉴权的列表请求才能通过后端校验)。
// 未登录时不带,首页(page<=1)仍可直接公开访问。
const token = getAccessToken()
if (token) headers["Authorization"] = `Bearer ${token}`
let res = await fetch(url, { headers })
// 登录态下 access 过期:用 refresh 续期一次后重试(与 authedFetch 行为一致)。
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers["Authorization"] = `Bearer ${newToken}`
res = await fetch(url, { headers })
} catch {
/* 刷新失败:保留原 401,下面统一抛出,由调用方处理 */
}
}
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T
}

View File

@ -2,7 +2,9 @@
import Layout from '@/layouts/Layout.astro'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
const { locale, t } = getI18n(Astro)
const titleLabel = t('my account')
const pleaseLabel = t('please log in')
const loginLabel = t('login')
@ -14,46 +16,72 @@ const historyLabel = t('my history')
const soonLabel = t('coming soon')
---
<Layout title={titleLabel}>
<section class="mx-auto w-full max-w-2xl px-6 py-24" x-data="accountPage()" data-locale={locale}>
<h1 class="font-serif text-4xl uppercase mb-2">{titleLabel}</h1>
<div x-show="!user" x-cloak class="mt-10 space-y-4">
<p class="text-sm text-zinc-500">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-block border border-black px-6 py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="mt-10">
<dl class="space-y-4 text-sm">
<div class="flex gap-4 border-b border-zinc-100 pb-3">
<dt class="w-32 text-[11px] font-mono uppercase tracking-widest text-zinc-400 pt-1">{accountLabel}</dt>
<dd class="font-medium" x-text="user?.username"></dd>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8">
<div class="mb-10 flex items-center justify-between gap-3">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span>
</div>
<div class="flex gap-4 border-b border-zinc-100 pb-3">
<dt class="w-32 text-[11px] font-mono uppercase tracking-widest text-zinc-400 pt-1">{emailLabel}</dt>
<dd class="font-medium" x-text="user?.email"></dd>
<div class="mb-10">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
</dl>
<button @click="logout()" class="mt-8 border border-black px-6 py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white">{signOutLabel}</button>
<div class="space-y-3 text-sm text-white/70">
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p>
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p>
</div>
</aside>
<div class="mt-16 space-y-10">
<section>
<h2 class="font-serif text-2xl uppercase mb-3">{collectionsLabel}</h2>
<p class="text-sm text-zinc-400">{soonLabel}</p>
</section>
<section>
<h2 class="font-serif text-2xl uppercase mb-3">{historyLabel}</h2>
<p class="text-sm text-zinc-400">{soonLabel}</p>
</section>
</div>
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10">
<div x-show="!user" x-cloak class="space-y-6">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="space-y-8">
<div>
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p>
<dl class="space-y-4">
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt>
<dd class="flex-1 font-medium" x-text="user?.username"></dd>
</div>
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd>
</div>
</dl>
</div>
<div class="flex flex-wrap gap-3">
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a>
</div>
<div class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
</div>
</div>
</section>
</div>
</section>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,

View File

@ -1,59 +1,147 @@
---
import Layout from '@/layouts/Layout.astro'
import '@/styles/global.css'
import { getI18n } from '@/i18n/utils'
const { locale, t } = getI18n(Astro)
import { href, ROUTES } from '@/lib/routes'
import { LOCALE_NAMES } from '@/i18n/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const pathName = Astro.url.pathname
const locales = toCodes(i18n!.locales)
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
const loginLabel = t('login')
const promptLabel = t('login prompt')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
---
<Layout title={loginLabel}>
<section class="mx-auto w-full max-w-md px-6 py-24">
<h1 class="font-serif text-4xl tracking-tight uppercase mb-2">{loginLabel}</h1>
<p class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 mb-10">{promptLabel}</p>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" 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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" 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="h-[14px] w-[14px] 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>
<form x-data="loginForm()" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required
class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>
</li>
))}
</ul>
</div>
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
<section class="relative hidden overflow-hidden bg-black px-10 py-12 text-white md:flex md:flex-col md:justify-between lg:px-14 lg:py-16">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.12),transparent_30%),linear-gradient(135deg,rgba(255,255,255,0.04),transparent_55%)]"></div>
<div class="pointer-events-none absolute -bottom-16 -right-10 select-none font-serif text-[220px] font-black leading-none text-white/[0.04]">PS</div>
<div class="relative z-10 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.42em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.35em] text-white">2026</span>
</div>
<p x-show="error" x-text="error" x-cloak class="text-xs text-red-600 uppercase tracking-wide"></p>
<div class="relative z-10">
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
</div>
<button type="submit" :disabled="loading"
class="w-full border border-black py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white disabled:opacity-40">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
</section>
</Layout>
<div class="relative z-10 max-w-md">
<div class="mb-8 h-px w-24 bg-white/20"></div>
<p class="text-sm leading-relaxed text-white/75">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 flex flex-wrap gap-3 text-[10px] font-mono uppercase tracking-[0.28em] text-white/50">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
</section>
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
<div class="w-full max-w-[440px] rounded-[32px] border border-black/10 bg-white/80 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.06)] backdrop-blur-sm md:p-8 lg:p-10">
<div class="mb-8 flex items-center justify-between gap-4 border-b border-black/10 pb-5">
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.36em] text-black/45">{membersLabel}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{loginLabel}</h1>
</div>
<a href={homeUrl} class="inline-flex items-center justify-center rounded-full border border-black/10 bg-black px-2.5 py-1.5 text-[9px] font-mono uppercase tracking-[0.28em] text-white transition-colors hover:bg-white hover:text-black">
Studio
</a>
</div>
<p class="mb-8 text-[10px] font-mono uppercase tracking-[0.28em] text-black/45">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-7" novalidate>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
<button type="submit" :disabled="loading" class="flex w-full items-center justify-center border border-black bg-black px-4 py-3.5 text-[11px] font-bold uppercase tracking-[0.28em] text-white transition-all duration-200 hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
</button>
</form>
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/55 transition-colors hover:text-black">
<span aria-hidden="true">←</span>
{backLabel}
</a>
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
</div>
</div>
</section>
</main>
</body>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
@ -61,9 +149,13 @@ const failLabel = t('login failed')
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => { this.error = this.$el.dataset.failLabel || "login failed" })
.finally(() => { this.loading = false })
}
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>

View File

@ -2,7 +2,9 @@
import Layout from '@/layouts/Layout.astro'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
const { locale, t } = getI18n(Astro)
const titleLabel = t('my account')
const pleaseLabel = t('please log in')
const loginLabel = t('login')
@ -14,46 +16,72 @@ const historyLabel = t('my history')
const soonLabel = t('coming soon')
---
<Layout title={titleLabel}>
<section class="mx-auto w-full max-w-2xl px-6 py-24" x-data="accountPage()" data-locale={locale}>
<h1 class="font-serif text-4xl uppercase mb-2">{titleLabel}</h1>
<div x-show="!user" x-cloak class="mt-10 space-y-4">
<p class="text-sm text-zinc-500">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-block border border-black px-6 py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="mt-10">
<dl class="space-y-4 text-sm">
<div class="flex gap-4 border-b border-zinc-100 pb-3">
<dt class="w-32 text-[11px] font-mono uppercase tracking-widest text-zinc-400 pt-1">{accountLabel}</dt>
<dd class="font-medium" x-text="user?.username"></dd>
<main class="mx-auto w-full max-w-6xl px-6 py-12 md:px-8 lg:px-12 lg:py-16" x-data="accountPage" data-locale={locale}>
<div class="grid gap-8 lg:grid-cols-[0.9fr_1.5fr]">
<aside class="rounded-[30px] border border-black/10 bg-black p-6 text-white shadow-[0_20px_60px_rgba(0,0,0,0.08)] md:p-8">
<div class="mb-10 flex items-center justify-between gap-3">
<span class="text-[10px] font-mono uppercase tracking-[0.38em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.32em] text-white">2026</span>
</div>
<div class="flex gap-4 border-b border-zinc-100 pb-3">
<dt class="w-32 text-[11px] font-mono uppercase tracking-widest text-zinc-400 pt-1">{emailLabel}</dt>
<dd class="font-medium" x-text="user?.email"></dd>
<div class="mb-10">
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.38em] text-white/50">Member profile</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{titleLabel}</h1>
</div>
</dl>
<button @click="logout()" class="mt-8 border border-black px-6 py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white">{signOutLabel}</button>
<div class="space-y-3 text-sm text-white/70">
<p class="text-[11px] font-mono uppercase tracking-[0.32em] text-white/45">Portfolio Studio</p>
<p class="leading-relaxed">Curated access to runway archives, editorial notes, and private collections.</p>
</div>
</aside>
<div class="mt-16 space-y-10">
<section>
<h2 class="font-serif text-2xl uppercase mb-3">{collectionsLabel}</h2>
<p class="text-sm text-zinc-400">{soonLabel}</p>
</section>
<section>
<h2 class="font-serif text-2xl uppercase mb-3">{historyLabel}</h2>
<p class="text-sm text-zinc-400">{soonLabel}</p>
</section>
</div>
<section class="rounded-[30px] border border-black/10 bg-[#f7f5f1] p-6 shadow-[0_18px_50px_rgba(0,0,0,0.04)] md:p-8 lg:p-10">
<div x-show="!user" x-cloak class="space-y-6">
<p class="text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={href(locale, ROUTES.login) + '?redirect=' + encodeURIComponent(href(locale, ROUTES.account))}
class="inline-flex items-center justify-center border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<div x-show="user" x-cloak class="space-y-8">
<div>
<p class="mb-4 text-[10px] font-mono uppercase tracking-[0.32em] text-black/45">Account</p>
<dl class="space-y-4">
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{accountLabel}</dt>
<dd class="flex-1 font-medium" x-text="user?.username"></dd>
</div>
<div class="flex gap-5 border-b border-black/10 pb-4">
<dt class="w-28 pt-1 text-[10px] font-mono uppercase tracking-[0.26em] text-black/45">{emailLabel}</dt>
<dd class="flex-1 font-medium break-all" x-text="user?.email"></dd>
</div>
</dl>
</div>
<div class="flex flex-wrap gap-3">
<button @click="logout()" class="border border-black bg-black px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{signOutLabel}</button>
<a href={href(locale, ROUTES.runwayLooks)} class="border border-black/15 bg-transparent px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t('View archive')}</a>
</div>
<div class="space-y-8 pt-4">
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{collectionsLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
<section class="border-t border-black/10 pt-6">
<h2 class="mb-3 font-serif text-2xl uppercase tracking-[-0.04em]">{historyLabel}</h2>
<p class="text-sm text-black/45">{soonLabel}</p>
</section>
</div>
</div>
</section>
</div>
</section>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,

View File

@ -1,59 +1,147 @@
---
import Layout from '@/layouts/Layout.astro'
import '@/styles/global.css'
import { getI18n } from '@/i18n/utils'
const { locale, t } = getI18n(Astro)
import { href, ROUTES } from '@/lib/routes'
import { LOCALE_NAMES } from '@/i18n/config'
import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n';
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const pathName = Astro.url.pathname
const locales = toCodes(i18n!.locales)
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
? pathName.replace(`/${locale}`, '')
: pathName
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
const loginLabel = t('login')
const promptLabel = t('login prompt')
const accountLabel = t('username or email')
const passwordLabel = t('password')
const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
---
<Layout title={loginLabel}>
<section class="mx-auto w-full max-w-md px-6 py-24">
<h1 class="font-serif text-4xl tracking-tight uppercase mb-2">{loginLabel}</h1>
<p class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 mb-10">{promptLabel}</p>
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<div class="absolute right-0 top-0 z-30 p-6 md:p-10" 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 rounded-full border border-black/10 bg-white/85 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.26em] text-black backdrop-blur-sm transition-colors hover:bg-black hover:text-white" 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="h-[14px] w-[14px] 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>
<form x-data="loginForm()" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required
class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
<ul x-show="open" x-transition.opacity.duration.150ms class="absolute right-0 top-full mt-2 min-w-[170px] border border-black/10 bg-white py-2 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
{languages.map((l) => (
<li>
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 text-[10px] uppercase tracking-[0.22em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/60']}>{l.label}</a>
</li>
))}
</ul>
</div>
<main class="grid min-h-screen md:grid-cols-[1.1fr_1.25fr]">
<section class="relative hidden overflow-hidden bg-black px-10 py-12 text-white md:flex md:flex-col md:justify-between lg:px-14 lg:py-16">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.12),transparent_30%),linear-gradient(135deg,rgba(255,255,255,0.04),transparent_55%)]"></div>
<div class="pointer-events-none absolute -bottom-16 -right-10 select-none font-serif text-[220px] font-black leading-none text-white/[0.04]">PS</div>
<div class="relative z-10 flex items-center justify-between">
<span class="text-[10px] font-mono uppercase tracking-[0.42em] text-white/60">Archive</span>
<span class="border border-white/20 bg-white/5 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.35em] text-white">2026</span>
</div>
<p x-show="error" x-text="error" x-cloak class="text-xs text-red-600 uppercase tracking-wide"></p>
<div class="relative z-10">
<p class="mb-6 text-[10px] font-mono uppercase tracking-[0.42em] text-white/50">Member access</p>
<h2 class="font-serif text-5xl uppercase leading-[0.88] tracking-[-0.06em] lg:text-[5.4rem]">Portfolio<br />Studio</h2>
<p class="mt-8 text-[11px] font-mono uppercase tracking-[0.34em] text-white/50">Runway · Street · Archive</p>
</div>
<button type="submit" :disabled="loading"
class="w-full border border-black py-3 text-[12px] font-bold uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white disabled:opacity-40">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak>…</span>
</button>
</form>
</section>
</Layout>
<div class="relative z-10 max-w-md">
<div class="mb-8 h-px w-24 bg-white/20"></div>
<p class="text-sm leading-relaxed text-white/75">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 flex flex-wrap gap-3 text-[10px] font-mono uppercase tracking-[0.28em] text-white/50">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
<span>•</span>
<span>Tokyo</span>
<span>•</span>
<span>New York</span>
</div>
</div>
</section>
<section class="flex items-center justify-center px-6 py-12 md:px-8 lg:px-12">
<div class="w-full max-w-[440px] rounded-[32px] border border-black/10 bg-white/80 p-6 shadow-[0_24px_80px_rgba(0,0,0,0.06)] backdrop-blur-sm md:p-8 lg:p-10">
<div class="mb-8 flex items-center justify-between gap-4 border-b border-black/10 pb-5">
<div>
<p class="mb-2 text-[10px] font-mono uppercase tracking-[0.36em] text-black/45">{membersLabel}</p>
<h1 class="font-serif text-4xl uppercase leading-none tracking-[-0.06em] md:text-5xl">{loginLabel}</h1>
</div>
<a href={homeUrl} class="inline-flex items-center justify-center rounded-full border border-black/10 bg-black px-2.5 py-1.5 text-[9px] font-mono uppercase tracking-[0.28em] text-white transition-colors hover:bg-white hover:text-black">
Studio
</a>
</div>
<p class="mb-8 text-[10px] font-mono uppercase tracking-[0.28em] text-black/45">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-7" novalidate>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<div>
<label class="mb-2 block text-[10px] font-mono uppercase tracking-[0.34em] text-black/50">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required class="w-full border-0 border-b border-black/15 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors placeholder:text-black/30 focus:border-black" placeholder="" />
</div>
<p x-show="error" x-text="error" x-cloak class="text-[10px] font-mono uppercase tracking-[0.18em] text-red-600"></p>
<button type="submit" :disabled="loading" class="flex w-full items-center justify-center border border-black bg-black px-4 py-3.5 text-[11px] font-bold uppercase tracking-[0.28em] text-white transition-all duration-200 hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-60">
<span x-show="!loading">{loginLabel}</span>
<span x-show="loading" x-cloak class="inline-flex items-center gap-2">Loading</span>
</button>
</form>
<div class="mt-8 flex items-center justify-between border-t border-black/10 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.32em] text-black/55 transition-colors hover:text-black">
<span aria-hidden="true">←</span>
{backLabel}
</a>
<span class="text-[9px] font-mono uppercase tracking-[0.28em] text-black/35">Editorial access</span>
</div>
</div>
</section>
</main>
</body>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
document.addEventListener("alpine:init", () => {
Alpine.data("loginForm", () => ({
account: "",
password: "",
error: "",
loading: false,
init() {
if (getUser()) {
const loc = this.$el.dataset.locale || "en"
window.location.href = clientHref(loc, ROUTES.account)
}
},
submit() {
this.error = ""
this.loading = true
login({ account: this.account, password: this.password })
.then(() => {
const params = new URLSearchParams(window.location.search)
@ -61,9 +149,13 @@ const failLabel = t('login failed')
const loc = this.$el.dataset.locale || "en"
window.location.href = redirect || clientHref(loc, ROUTES.account)
})
.catch(() => { this.error = this.$el.dataset.failLabel || "login failed" })
.finally(() => { this.loading = false })
}
.catch(() => {
this.error = this.$el.dataset.failLabel || "login failed"
})
.finally(() => {
this.loading = false
})
},
}))
})
</script>