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

This commit is contained in:
toom1996
2026-09-02 21:50:30 +08:00
parent 50e5775dc8
commit 64af75c0a9
23 changed files with 1730 additions and 285 deletions

View File

@ -1,102 +1,277 @@
---
import Layout from '@/layouts/Layout.astro'
import { getI18n } from '@/i18n/utils'
import { href, ROUTES } from '@/lib/routes'
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')
const accountLabel = t('account')
const emailLabel = t('email')
const signOutLabel = t('sign out')
const collectionsLabel = t('my collections')
const historyLabel = t('my history')
const soonLabel = t('coming soon')
const titleLabel = t("my account")
const pleaseLabel = t("please log in")
const loginLabel = t("login")
const historyLabel = t("my history")
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
const archiveUrl = href(locale, ROUTES.runwayLooks)
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
const removeLabel = t("remove")
const itemsLabel = t("items")
const clearHistoryLabel = t("clear history")
---
<Layout title={titleLabel}>
<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>
<main class="account-root mx-auto w-full max-w-7xl px-6 py-10 md:px-8 md:py-14" x-data="accountPage" data-locale={locale}>
<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>
<!-- 未登录:引导登录 -->
<div x-show="!user" x-cloak class="border border-black/15 bg-white px-8 py-20 text-center">
<p class="mb-6 text-[10px] uppercase tracking-[0.32em] text-black/45">{pleaseLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-6 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
<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 x-show="user" x-cloak class="space-y-10">
<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>
<!-- 报头:头像 + 用户名 -->
<section class="flex items-center gap-5 border-b border-black/15 pb-8">
<div class="flex h-16 w-16 shrink-0 items-center justify-center border border-black text-2xl tracking-[-0.04em]" x-text="(user?.username || '?').slice(0,2).toUpperCase()"></div>
<div>
<p class="mb-2 text-[10px] uppercase tracking-[0.32em] text-black/45">{t("member profile")}</p>
<h1 class="text-4xl uppercase leading-none tracking-[-0.04em] md:text-5xl" x-text="user?.username"></h1>
</div>
</section>
<!-- 主体:左侧导航 + 右侧内容 -->
<div class="grid gap-8 lg:grid-cols-[200px_1fr]">
<!-- 侧栏导航 -->
<aside class="lg:sticky lg:top-8 lg:self-start">
<nav class="flex gap-1 overflow-x-auto border-b border-black/15 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0">
<button @click="tab='saved'" :class="tab==='saved' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{t("saved looks")}</button>
<button @click="tab='history'" :class="tab==='history' ? 'text-black border-black lg:border-l-black' : 'text-black/55 hover:text-black border-transparent lg:border-l-transparent'" class="whitespace-nowrap border-b-2 px-4 py-3 text-left text-[11px] uppercase tracking-[0.22em] transition-colors lg:w-full lg:border-b lg:border-l-2 lg:border-black/10">{historyLabel}</button>
</nav>
</aside>
<!-- 内容面板 -->
<section class="js-surface js-panel border border-black/15 bg-white p-6 md:p-10">
<!-- Saved looks -->
<div x-show="tab==='saved'" x-cloak>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{t("saved looks")}</h2>
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="favoritesCount"></span> {itemsLabel}</span>
</div>
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1Z"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no saved looks yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="f in favorites" :key="f.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(f)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeFav(f.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(f)"><p class="truncate text-[12px] font-medium" x-text="f.title"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
</div>
<!-- 收藏分页(服务端分页,与收藏总量解耦,10万也常数级) -->
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="favLastPage > 1" aria-label="Saved pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
<template x-for="(p, i) in favPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
</nav>
</div>
<!-- History -->
<div x-show="tab==='history'" x-cloak>
<div class="mb-6">
<h2 class="text-2xl uppercase tracking-[-0.03em] md:text-3xl">{historyLabel}</h2>
</div>
<div class="mb-4 flex items-center justify-between">
<span class="text-[10px] uppercase tracking-[0.24em] text-black/40"><span x-text="histTotal"></span> {itemsLabel}</span>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[10px] uppercase tracking-[0.22em] text-black/45 underline-offset-4 hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border border-dashed border-black/20 py-20 text-center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" class="mb-5 h-10 w-10 text-black/30"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<p class="mb-6 text-sm text-black/45">{t("no history yet")}</p>
<a href={archiveUrl} class="accent-btn inline-flex items-center justify-center border border-black bg-white px-5 py-3 text-[11px] font-bold uppercase tracking-[0.24em] text-black transition-colors hover:bg-black hover:text-white">{t("browse the archive")}</a>
</div>
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="h in history" :key="h.id">
<div class="js-surface group relative overflow-hidden border border-black/15 bg-white hover:border-black">
<a :href="itemHref(h)" class="block aspect-[3/4] overflow-hidden bg-[#f1f1f1]">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
</a>
<button @click="removeHist(h.id)" class="absolute right-2 top-2 border border-black/10 bg-white px-2.5 py-1 text-[9px] uppercase tracking-[0.18em] text-black/70 backdrop-blur transition-colors hover:bg-black hover:text-white">{removeLabel}</button>
<div class="p-3">
<a :href="itemHref(h)"><p class="truncate text-[12px] font-medium" x-text="h.data?.title || '…'"></p></a>
<p class="mt-0.5 truncate text-[10px] uppercase tracking-[0.16em] text-black/45" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
</div>
<nav class="mt-8 flex flex-wrap items-center justify-center gap-2" x-show="histLastPage > 1" aria-label="History pagination">
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
<template x-for="(p, i) in histPageList" :key="i">
<span>
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
<button type="button" x-show="p.page !== null" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
</span>
</template>
<button type="button" class="border border-black/20 px-3 py-1.5 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
</nav>
</div>
</section>
</div>
</div>
</main>
</Layout>
<script>
import { getUser, logout as logoutApi } from "@/lib/api"
import { getUser, toAbs } from "@/lib/api"
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)
document.addEventListener("alpine:init", () => {
Alpine.data("accountPage", () => ({
user: null,
user: null as any,
locale: "en",
tab: "saved",
favorites: [] as any[],
favoritesCount: 0,
favPage: 1,
favPerPage: 24,
favLastPage: 1,
favTotal: 0,
history: [] as any[],
historyCount: 0,
histPage: 1,
histPerPage: 24,
histLastPage: 1,
histTotal: 0,
init() {
const loc = this.$el.dataset.locale || "en"
this.locale = this.$el.dataset.locale || "en"
const u = getUser()
if (!u) {
window.location.href = clientHref(loc, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(loc, ROUTES.account))
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
return
}
this.user = u
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
this.loadFavPage(1)
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
this.loadHistPage(1)
},
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级)
async loadFavPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchFavoritesPage(p, this.favPerPage)
this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage
this.favPage = p
this.favoritesCount = r.total
} catch {
const local = getFavorites()
this.favorites = local
this.favTotal = local.length
this.favLastPage = 1
this.favPage = 1
this.favoritesCount = local.length
}
},
goFavPage(p: number) {
if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get favPageList() {
const cur = this.favPage, last = this.favLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
coverUrl(x: any): string {
const c = x.data?.cover || x.cover
return c ? toAbs(c) : PLACEHOLDER
},
itemHref(f: any): string {
return clientHref(this.locale, ROUTES.item(f.id))
},
removeFav(id: string) {
removeFavorite(id)
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
},
// ===== 浏览历史(服务端分页)=====
async loadHistPage(page: number) {
const p = Math.max(1, page | 0)
try {
const r = await fetchHistoryPage(p, this.histPerPage)
this.history = r.items
this.histTotal = r.total
this.histLastPage = r.lastPage
this.histPage = p
} catch {
this.history = []
this.histTotal = 0
this.histLastPage = 1
this.histPage = 1
}
},
goHistPage(p: number) {
if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p)
},
// 窗口化页码:首尾页 + 当前页 ±1,其余用 … 折叠
get histPageList() {
const cur = this.histPage, last = this.histLastPage
if (last <= 1) return []
const set = new Set([1, last, cur, cur - 1, cur + 1].filter((n) => n >= 1 && n <= last))
const arr = [...set].sort((a, b) => a - b)
const out: ({ label: string; page: number } | { label: string; page: null })[] = []
let prev = 0
for (const n of arr) {
if (n - prev > 1) out.push({ label: "…", page: null })
out.push({ label: String(n), page: n })
prev = n
}
return out
},
removeHist(id: string) {
removeHistory(id)
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
},
clearAllHistory() {
clearHistory()
this.loadHistPage(1)
},
logout() {
logoutApi().finally(() => window.location.reload())
}
}))
})
</script>

View File

@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro"
import StreetSnap from "@/components/StreetSnap.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -11,6 +12,8 @@ export const prerender = false
const { id } = Astro.params
const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro)
let article = null
let articleErr = null
let snap = null
@ -28,8 +31,93 @@ if (isSnap) {
article = data.article
articleErr = data.error
}
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save")
const savedLabel = t("saved")
---
<Layout>
{article ? <Item article={article} error={articleErr} /> : <StreetSnap snap={snap} error={snapErr} />}
</Layout>
<style>
#fav-fab.is-active .fav-heart { fill: currentColor; }
</style>
{(article || snap) && (
<button id="fav-fab" type="button"
data-id={meta.id}
data-type={meta.type}
data-title={meta.title}
data-cover={meta.cover}
data-brand={meta.brand}
data-save-label={saveLabel}
data-saved-label={savedLabel}
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
<span id="fav-label">{saveLabel}</span>
</button>
)}
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
<script>
import { getUser } from "@/lib/api"
import { isFavorited, toggleFavorite, checkFavorited, hydrateFavorited } from "@/lib/favorites"
import { recordHistory } from "@/lib/history"
const btn = document.getElementById("fav-fab")
const label = document.getElementById("fav-label")
if (btn) {
const meta = {
id: btn.dataset.id || "",
type: btn.dataset.type || "",
title: btn.dataset.title || "",
cover: btn.dataset.cover || "",
brand: btn.dataset.brand || "",
}
const saveLabel = btn.dataset.saveLabel || "save"
const savedLabel = btn.dataset.savedLabel || "saved"
if (meta.id) {
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
if (getUser()) recordHistory(meta.id)
const sync = () => {
const fav = isFavorited(meta.id)
if (label) label.textContent = fav ? savedLabel : saveLabel
btn.classList.toggle("is-active", fav)
}
sync()
// 登录态:异步问服务端当前篇是否已收藏并写回本地(换浏览器/设备后,爱心状态与服务端一致)
if (getUser()) {
checkFavorited([meta.id])
.then((set) => {
if (set.has(meta.id)) hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
sync()
})
.catch(() => {})
}
btn.addEventListener("click", () => {
if (!getUser()) {
window.location.href = "/login"
return
}
toggleFavorite({
id: meta.id,
type: meta.type,
title: meta.title,
cover: meta.cover,
brand: meta.brand,
} as any)
sync()
})
}
}
</script>

View File

@ -23,8 +23,17 @@ const failLabel = t('login failed')
const membersLabel = t('members')
const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio`
---
<body class="min-h-screen bg-[#f4f1eb] text-black antialiased selection:bg-black selection:text-white">
<!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<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">
@ -118,44 +127,45 @@ const homeUrl = href(locale, ROUTES.runwayLooks)
</div>
</section>
</main>
</body>
<script>
import { login, getUser } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
<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,
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)
const redirect = params.get("redirect")
init() {
if (getUser()) {
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
})
},
}))
})
</script>
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)
const redirect = params.get("redirect")
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
})
},
}))
})
</script>
</body>
</html>