@ -93,129 +93,128 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<div class="showsbody">
|
||||
<!-- LEFT SIDEBAR -->
|
||||
<aside class="showsaside">
|
||||
<div class="showsaside-head">
|
||||
<h2 class="showsaside-h">REFINE</h2>
|
||||
</div>
|
||||
<div class="showsaside-head">
|
||||
<h2 class="showsaside-h">REFINE</h2>
|
||||
</div>
|
||||
|
||||
<div class="showsaside-body">
|
||||
<!-- Designer 多选 -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>DESIGNERS</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-count" x-text="selectedBrands.length">0</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collection type -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
||||
<span>COLLECTION</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{collectionOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
<div class="showsaside-body">
|
||||
<!-- Designer 多选 -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>{t('brands')}</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Season -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('season')">
|
||||
<span>SEASON</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{seasonOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Collection type -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
||||
<span>COLLECTION</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{collectionOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Year -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('year')">
|
||||
<span>YEAR</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Season -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('season')">
|
||||
<span>SEASON</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{seasonOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||||
</div>
|
||||
<!-- Year -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('year')">
|
||||
<span>YEAR</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><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>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<main class="showsmain">
|
||||
<div class="showsgrid" :class="{ 'is-loading': loading }">
|
||||
|
||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
|
||||
<template x-if="loading && items.length === 0">
|
||||
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
||||
<div class="showscard showscard--skel" aria-hidden="true">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img"></div>
|
||||
</div>
|
||||
<div class="showscard-right">
|
||||
<div class="showscard-info">
|
||||
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[38px] rounded-[2px]"></div>
|
||||
</div>
|
||||
<div class="showscard-strip">
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div class="showsgrid" :class="{ 'is-loading': loading }">
|
||||
|
||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
|
||||
<template x-if="loading && items.length === 0">
|
||||
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
||||
<div class="showscard showscard--skel" aria-hidden="true">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img"></div>
|
||||
</div>
|
||||
<div class="showscard-right">
|
||||
<div class="showscard-info">
|
||||
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[38px] rounded-[2px]"></div>
|
||||
</div>
|
||||
<div class="showscard-strip">
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && items.length === 0">
|
||||
<div class="showsempty">No shows match the current filters</div>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && items.length === 0">
|
||||
<div class="showsempty">No shows match the current filters</div>
|
||||
</template>
|
||||
|
||||
<!-- Card List -->
|
||||
<template x-for="it in items" :key="it.id">
|
||||
<!-- Card List -->
|
||||
<template x-for="it in items" :key="it.id">
|
||||
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||
@ -238,12 +237,12 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
||||
</div>
|
||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
262
src/components/pages/AuthForm.astro
Normal file
262
src/components/pages/AuthForm.astro
Normal file
@ -0,0 +1,262 @@
|
||||
---
|
||||
// 账号表单(登录 / 注册通用),编辑风黑白设计,匹配全站视觉。
|
||||
// - mode: "login" | "register"
|
||||
// - 提交:POST /api/auth/login 或 /api/auth/register,成功后写入本地会话并跳转。
|
||||
// - 后端不可用时也能优雅显示错误(不破坏设计)。
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
interface Props {
|
||||
mode?: "login" | "register"
|
||||
}
|
||||
const { mode = "login" } = Astro.props
|
||||
const { locale } = getI18n(Astro)
|
||||
|
||||
const isLogin = mode === "login"
|
||||
const title = isLogin ? "Sign In" : "Create Account"
|
||||
const kickerRight = isLogin ? "Sign In" : "Create Account"
|
||||
const subtitle = isLogin
|
||||
? "Sign in to your account to access the runway archive."
|
||||
: "Create an account to save shows and build your own archive."
|
||||
const submitLabel = isLogin ? "Sign In" : "Create Account"
|
||||
// 登录/注册成功后回跳目标:优先 ?redirect=,否则回 /en/
|
||||
const redirect = Astro.url.searchParams.get("redirect") || href(locale, ROUTES.home)
|
||||
|
||||
const loginHref = href(locale, ROUTES.login)
|
||||
const registerHref = href(locale, ROUTES.register)
|
||||
---
|
||||
|
||||
<section class="auth-wrap">
|
||||
<div class="auth-kicker">
|
||||
<span>Account</span>
|
||||
<span>{kickerRight}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="auth-title">{title}</h1>
|
||||
<p class="auth-sub">{subtitle}</p>
|
||||
|
||||
<form id="auth-form" class="auth-form" data-mode={mode} data-redirect={redirect} novalidate>
|
||||
{!isLogin && (
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Username</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
placeholder="yourname"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Email</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Password</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete={isLogin ? "current-password" : "new-password"}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!isLogin && (
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Confirm Password</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="password"
|
||||
name="confirm"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<p id="auth-error" class="auth-error" hidden></p>
|
||||
|
||||
<button type="submit" class="auth-submit">{submitLabel}</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-foot">
|
||||
{isLogin ? (
|
||||
<>
|
||||
<a class="auth-link" href={registerHref}>Don’t have an account? Sign up →</a>
|
||||
<a class="auth-link muted" href="#">Forgot your password?</a>
|
||||
</>
|
||||
) : (
|
||||
<a class="auth-link" href={loginHref}>Already have an account? Sign in →</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.auth-wrap {
|
||||
@apply w-full max-w-[460px] mx-auto;
|
||||
padding-top: clamp(40px, 7vw, 90px);
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.auth-kicker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #000;
|
||||
padding-top: 18px;
|
||||
@apply font-mono text-[11px] tracking-[0.25em] uppercase text-[#666];
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
@apply font-serif font-black tracking-[-0.02em] text-black;
|
||||
line-height: 0.9;
|
||||
font-size: clamp(44px, 9vw, 88px);
|
||||
margin: 0.16em 0 0.18em;
|
||||
}
|
||||
|
||||
.auth-sub {
|
||||
@apply font-mono text-[12px] text-[#666];
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 34px;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
@apply block mb-[22px];
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
@apply font-mono text-[11px] uppercase tracking-[0.2em] text-black mb-[8px];
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
@apply w-full border border-black bg-white px-[14px] py-[12px] font-sans text-[14px] text-black;
|
||||
outline: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.auth-input::placeholder {
|
||||
@apply text-black/30;
|
||||
}
|
||||
.auth-input:focus {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
@apply font-mono text-[12px] text-black;
|
||||
border: 1px solid #000;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
padding: 10px 12px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
@apply w-full bg-black text-white border border-black px-[18px] py-[14px] text-[12px] uppercase tracking-[0.18em] font-bold cursor-pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.auth-submit:hover {
|
||||
@apply bg-white text-black;
|
||||
}
|
||||
.auth-submit:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.auth-foot {
|
||||
@apply mt-[26px] pt-[22px] border-t border-zinc-200 flex flex-col gap-[10px];
|
||||
@apply font-mono text-[12px] text-[#666];
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
@apply text-black no-underline;
|
||||
}
|
||||
.auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.auth-link.muted {
|
||||
@apply text-[#666];
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { saveSession } from "@/lib/api"
|
||||
|
||||
const form = document.getElementById("auth-form") as HTMLFormElement | null
|
||||
if (form) {
|
||||
const errEl = document.getElementById("auth-error") as HTMLParagraphElement | null
|
||||
const btn = form.querySelector('button[type="submit"]') as HTMLButtonElement | null
|
||||
const mode = (form.dataset.mode || "login") as "login" | "register"
|
||||
const redirect = form.dataset.redirect || "/"
|
||||
|
||||
const showErr = (msg: string) => {
|
||||
if (!errEl) return
|
||||
errEl.textContent = msg
|
||||
errEl.hidden = false
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
if (errEl) errEl.hidden = true
|
||||
const data = Object.fromEntries(new FormData(form).entries()) as Record<string, string>
|
||||
|
||||
if (mode === "register" && data.password !== data.confirm) {
|
||||
showErr("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
const label = mode === "login" ? "Sign In" : "Create Account"
|
||||
const orig = btn?.textContent ?? label
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = "…"
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const json: any = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
showErr(json?.message || json?.error || `Unable to ${label.toLowerCase()}. Please try again.`)
|
||||
return
|
||||
}
|
||||
const token = json?.data?.token ?? json?.token
|
||||
const user = json?.data?.user ?? json?.user
|
||||
if (token && user) {
|
||||
saveSession(token, user)
|
||||
location.href = redirect
|
||||
return
|
||||
}
|
||||
showErr("Unexpected response. Please try again.")
|
||||
} catch {
|
||||
showErr("Network error. Please try again.")
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false
|
||||
btn.textContent = orig
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user