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

This commit is contained in:
toom1996
2026-08-29 20:17:13 +08:00
parent a133413d74
commit e24bfe164d
29 changed files with 598 additions and 1517 deletions

View File

@ -0,0 +1,160 @@
---
// 品牌筛选弹窗(仅 markup + 样式)。
// Alpine 状态(modalOpen / modalKeyword / modalLetter / modalResults / modalLoading /
// modalSelected / SPIN_SVG 等)由父级 RunwayLooks 的 showsPage() 提供,本组件渲染在
// .showsroot 的 x-data 作用域内,故可直接引用这些变量,无需 props。
---
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak @keydown.escape.window="closeBrandModal()">
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="radio"
name="modalDesigner"
:value="b.id"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: rgba(0, 0, 0, 0.4);
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>

View File

@ -22,7 +22,7 @@ const { locale } = getI18n(Astro)
{note || "该模块正在筹备中,尚未对外开放。站点正按模块逐个完善,你可以先浏览已上线的走秀档案。"}
</p>
<div class="cs-actions">
<a class="cs-btn" href={href(locale, ROUTES.articles)}>浏览走秀档案 →</a>
<a class="cs-btn" href={href(locale, ROUTES.runwayLooks)}>浏览走秀档案 →</a>
<a class="cs-btn-ghost" href={href(locale, ROUTES.home)}>返回首页</a>
</div>
</section>

View File

@ -1,36 +0,0 @@
---
import { pathHasLocale, getRelativeLocaleUrl } from 'astro:i18n';
// 使用 pathHasLocale 安全判断当前路径是否已包含任何语言前缀(返回 boolean)
const hasLocale = pathHasLocale(Astro.url.pathname);
// 自动生成中文根路径
const cnRootUrl = getRelativeLocaleUrl('cn', '');
---
<script is:inline define:vars={{ hasLocale, cnRootUrl }}>
(() => {
try {
// 当前路径已带有语言前缀(如 /cn/ 或 /en/),直接跳过
if (hasLocale) return;
// 避免重复跳转
if (sessionStorage.getItem('lang-redirect')) return;
// 判定浏览器偏好语言
const langs = navigator.languages || [navigator.language || 'en'];
const isZh = langs.some((l) => l.toLowerCase().startsWith('zh'));
if (!isZh) return;
sessionStorage.setItem('lang-redirect', '1');
// 拼接真实跳转路径
const { pathname, search, hash } = location;
const cleanPath = pathname === '/' ? '' : pathname;
const targetUrl = `${cnRootUrl}${cleanPath.replace(/^\//, '')}${search}${hash}`;
location.replace(targetUrl);
} catch (e) {}
})();
</script>

View File

@ -1,7 +1,9 @@
---
import Breadcrumb from "@/components/Breadcrumb.astro"
import BrandModal from "@/components/BrandModal.astro"
import { getSsgHotBrands } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import "@/styles/look-grid.css"
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
@ -247,392 +249,22 @@ const seasonOptions: { value: string; label: string }[] = [
</main>
</div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak @keydown.escape.window="closeBrandModal()">
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="radio"
name="modalDesigner"
:value="b.id"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
<BrandModal />
</div>
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* 使用 @apply 精简并重构样式 */
.showsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.showspills {
@apply flex flex-wrap gap-2 pt-3.5;
}
.showspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
.showsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.showsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.showsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed {
@apply -rotate-90;
}
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
.showschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.showschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.showschecks li.letter-head {
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
}
.showschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类,
因为 @click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.showschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li {
@apply py-[4px];
}
.showschecks--flat li label {
@apply inline-flex items-center gap-2 cursor-pointer w-full;
}
.showsmore {
@apply block w-full mt-[12px] text-center text-[11px] uppercase tracking-[0.22em] text-black font-bold cursor-pointer;
}
.showsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
.showsmain {
@apply flex-1 min-w-0;
}
.showsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: hbxProg 1s ease-in-out infinite;
}
.showscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.showscard:hover .showscard-img img {
@apply scale-104;
}
.showscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded {
@apply opacity-100;
}
.showsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin {
@apply opacity-0;
}
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded {
@apply opacity-100;
}
.showscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
@apply bg-[#efefef];
animation: hbxShim 1.6s ease infinite;
}
.showspager {
@apply flex flex-wrap items-center justify-center gap-2 mt-[38px];
}
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current {
@apply bg-black text-white cursor-default;
}
.pg:disabled {
@apply opacity-30 cursor-default;
}
.pg.enabled:hover {
@apply bg-black text-white;
}
.pg-ellipsis {
@apply px-1 text-[#999] select-none text-[11px];
}
.pg-wrap {
@apply inline-flex items-center;
}
.showsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: rgba(0, 0, 0, 0.4);
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>
<script>
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
@ -716,13 +348,6 @@ const seasonOptions: { value: string; label: string }[] = [
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
},
toggleBrand(id: number) {
const idx = this.selectedBrands.indexOf(id)
if (idx >= 0) this.selectedBrands.splice(idx, 1)
else this.selectedBrands.push(id)
this.refreshFilter()
},
refreshFilter() {
this.loadPage(1)
},
@ -845,19 +470,7 @@ const seasonOptions: { value: string; label: string }[] = [
// ===== 分页导航 =====
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) {
return Array.from({ length: total }, (_: any, i: number) => i + 1)
}
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
@ -878,18 +491,9 @@ const seasonOptions: { value: string; label: string }[] = [
// 渲染文章url
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// 渲染thumb图片列表
// 渲染thumb图片列表(复用共享工具:固定 5 槽位 + 末位 moreLabel 兜底)
getCardSlots(it: ArticleItem) {
const gallery = it.gallery || []
const totalCount = it.image_count || 0
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
const slots = []
for (let i = 0; i < 5; i++) {
const src = gallery[i]
const isLast = i === 4
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
}
return slots
return makeCardSlots(it.gallery || [], it.image_count || 0)
}
}
})

View File

@ -3,6 +3,7 @@ import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
@ -173,186 +174,12 @@ const sortOptions: { value: string; label: string }[] = [
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions })}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
.snapsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.snapshead {
@apply flex justify-between items-end gap-6 py-5 pb-[22px];
}
.snapsh1 {
@apply text-[clamp(34px,5.5vw,64px)] leading-[0.95] font-black tracking-[-0.02em] my-0 mb-[8px];
}
/* 已激活筛选 pills */
.snapspills {
@apply flex flex-wrap gap-2 mb-4;
}
.snapspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.snapspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
/* 主体:左 sidebar + 右网格 */
.snapsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.snapsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.snapsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.snapsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none;
}
.snapsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.snapsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.snapsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.snapsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.snapsg-chev.is-collapsed { @apply -rotate-90; }
.snapschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.snapschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.snapschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类:
@click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.snapschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.snapschecks--flat li { @apply py-[4px]; }
.snapschecks--flat li label { @apply inline-flex items-center gap-2 cursor-pointer w-full; }
.snapsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
/* 主体内容区 */
.snapsmain { @apply flex-1 min-w-0; }
.snapscontent {
@apply relative;
}
.snapscontent.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: snapProg 1s ease-in-out infinite;
}
/* ===== 卡片网格(与 runway-looks 同款) ===== */
.snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.snapscard:hover .snapscard-img img {
@apply scale-104;
}
.snapscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.snapscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.snapscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.snapscard-img img.is-loaded {
@apply opacity-100;
}
.snapsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.snapscard-img.is-loaded .snapsspin,
.snapscard-thumb.is-loaded .snapsspin {
@apply opacity-0;
}
.snapscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.snapscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.snapscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.snapscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.snapscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.snapscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.snapscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.snapscard-thumb img.is-loaded {
@apply opacity-100;
}
.snapscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.snapscard--skel .snapscard-img,
.snapscard--skel .snapscard-thumb,
.snapscard--skel .snapsline {
@apply bg-[#efefef];
animation: snapShim 1.6s ease infinite;
}
/* ===== 分页 ===== */
.snappager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; }
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }
.pg.enabled:hover { @apply bg-black text-white; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; }
.snapsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes snapProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes snapShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
</style>
<script>
import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
const DEFAULT_SORT = "image_count"
@ -414,15 +241,10 @@ const sortOptions: { value: string; label: string }[] = [
},
// 卡片右侧缩略图条带:固定 5 个槽位,缺图留 placeholder
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同)
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同),先抽成纯 URL 数组
getCardSlots(it: StreetSnapItem) {
const imgs = it.images || []
const slots: { src: string; loaded: boolean }[] = []
for (let i = 0; i < 5; i++) {
const im: any = imgs[i]
slots.push({ src: im ? (im.image || im) : "", loaded: false })
}
return slots
const imgs = (it.images || []).map((im: any) => (im && (im.image || im)) || "")
return makeCardSlots(imgs)
},
async loadPage(page: number) {
@ -449,17 +271,7 @@ const sortOptions: { value: string; label: string }[] = [
},
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) return Array.from({ length: total }, (_: any, i: number) => i + 1)
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
return buildPageList(this.lastPage, this.page)
},
goPage(p: number) {
@ -474,7 +286,8 @@ const sortOptions: { value: string; label: string }[] = [
nextPage() { this.goPage(this.page + 1) },
toAbsUrl(url: string) { return toAbs(url) },
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.streetSnap(it.id)) },
// 街拍详情统一收口到 /item/[id](与服务端 /item 回落逻辑对应)
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
titleParts(it: StreetSnapItem): string[] {

View File

@ -1,210 +0,0 @@
---
import astroLogo from '@/assets/astro.svg';
import background from '@/assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-6-beta/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 6.0?</h2>
<p>
Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to
explore Astro 6.0's new features.
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="About" moduleZh="关于" />

View File

@ -1,262 +0,0 @@
---
// 账号表单(登录 / 注册通用),编辑风黑白设计,匹配全站视觉。
// - 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&rsquo;t have an account? Sign up &rarr;</a>
<a class="auth-link muted" href="#">Forgot your password?</a>
</>
) : (
<a class="auth-link" href={loginHref}>Already have an account? Sign in &rarr;</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/v1/auth/login" : "/api/v1/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>

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Contact" moduleZh="联系" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Latest Projects" moduleZh="最新项目" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Portfolio" moduleZh="作品集" />

View File

@ -1,6 +0,0 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "@/components/ComingSoon.astro"
---
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -1,285 +0,0 @@
---
import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { ssrBase } from "@/lib/api"
interface Props {
article: RawArticle | null
locale?: "en" | "cn"
error?: "missing" | "failed" | null
}
const { article, locale = "en", error = null } = Astro.props
// 把相对图片地址补全为绝对地址(服务端完成,避免浏览器再拼;base 由 ssr.ts 统一解析)
const full = (u: string): string =>
!u ? "" : /^https?:\/\//i.test(u) ? u : ssrBase().replace(/\/$/, "") + u
// 图片数组:服务端预处理为灯箱可用的 {id,url,name}
const images = (article?.images ?? []).map((im) => ({
id: im.id,
url: full(im.image || ""),
name: im.name || "",
}))
// 正文段落(如有)
const descParas = (article?.description || "")
.split(/\n\n+/)
.map((s) => s.trim())
.filter(Boolean)
// 文案(按语言切换)
const L = {
en: {
back: "← Back to Index",
source: "Source ↗",
runway: "Runway",
notFound: "Article not found",
missing: "No article ID specified",
failed: "Failed to load article. Please refresh or try later.",
},
cn: {
back: "← 返回首页",
source: "来源 ↗",
runway: "秀场",
notFound: "未找到该文章",
missing: "未指定文章 ID",
failed: "加载文章失败,请刷新或稍后再试",
},
}
const lbl = L[locale === "cn" ? "cn" : "en"]
const backLabel = lbl.back.replace("← ", "")
// 面包屑:首页 → 走秀档案(/shows) → 品牌筛选(带 brand_id) → 当前文章(复用项目 Breadcrumb 组件)
const homeHref = locale === "cn" ? "/cn" : "/en/"
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
const crumbs: { label: string; href: string }[] = [
{ label: "Runway Looks", href: archiveHref },
]
if (article?.brand_id) {
crumbs.push({
label: article.brand_name || "Brand",
href: `${archiveHref}?brand_id=${article.brand_id}`,
})
}
---
<!-- 外层 x-data 承载灯箱状态;图片数据由服务端注入,无需浏览器再 fetch -->
<div x-data={`articleView(${JSON.stringify(images)})`} class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white">
{article ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑(复用项目 Breadcrumb 组件):首页 / 走秀档案 / 品牌 / 当前文章 -->
<Breadcrumb items={crumbs}>
<span slot="current" class="max-w-[60vw] truncate">{article.title}</span>
</Breadcrumb>
<!-- 标题 -->
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">{article.title}</h1>
<!-- 图片集(服务端渲染,利于 SEO 与首屏) -->
{images.length > 0 && (
<section class="mt-16">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((im, i) => (
<figure @click={`openLb(${i})`} class="bg-[#f2f2f2] cursor-zoom-in group">
<img src={im.url} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" />
{im.name && <figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>}
</figure>
))}
</div>
</section>
)}
<!-- 正文(如有) -->
{descParas.length > 0 && (
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
{descParas.map((p) => <p>{p}</p>)}
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">{error === "missing" ? lbl.missing : lbl.failed}</p>
<a href={homeHref} class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors">← {backLabel}</a>
</div>
)}
<!-- 4. 全屏灯箱组件 (Lightbox,Alpine 客户端交互) -->
<div
x-show="lbOpen"
x-cloak
@keydown.window.escape="closeLb()"
@keydown.window.arrow-right="nextLb()"
@keydown.window.arrow-left="prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
>
<!-- 计数器 -->
<div class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md" x-text="`${lbIndex + 1} / ${images.length || 0}`"></div>
<!-- 关闭按钮 -->
<button @click="closeLb()" class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer" aria-label="关闭">
✕
</button>
<!-- 主舞台 -->
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<!-- 左侧缩略图纵向列表 -->
<div x-ref="lbThumbs" class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar">
<template x-for="(im, i) in images" :key="im.id || i">
<button
type="button"
@click="showLb(i)"
class="relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200"
:class="lbIndex === i ? 'opacity-100 scale-[1.03] ring-2 ring-[#111]' : 'opacity-40 hover:opacity-85'"
>
<img :src="im.url" alt="" loading="lazy" class="w-full h-full object-cover block" />
<span
class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm"
:class="lbIndex === i ? '!bg-[#111]' : ''"
x-text="String(i + 1).padStart(2, '0')"
></span>
</button>
</template>
</div>
<!-- 左侧上下翻页辅助按键 -->
<button @click.stop="prevLb()" type="button" class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer">
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6"/></svg>
</button>
<button @click.stop="nextLb()" type="button" class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer">
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6"/></svg>
</button>
<!-- 左右主切换按钮 -->
<button @click.stop="prevLb()" type="button" class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer">
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<button @click.stop="nextLb()" type="button" class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer">
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>
</button>
<!-- 大图显示区域(支持点击切换放大/缩小状态) -->
<div
x-ref="lbMain"
@click.self="closeLb()"
@touchstart="handleTouchStart($event)"
@touchend="handleTouchEnd($event)"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
:class="lbZoomed ? 'block overflow-y-auto overflow-x-hidden items-start' : 'items-center'"
>
<template x-if="images && images[lbIndex]">
<img
:src="images[lbIndex].url"
:alt="images[lbIndex].name || ''"
@click="toggleZoom()"
loading="lazy"
class="block select-none transition-opacity duration-200"
:class="lbZoomed ? 'w-full max-w-full h-auto max-h-none cursor-zoom-out' : 'max-w-full max-h-full object-contain cursor-zoom-in'"
/>
</template>
</div>
</div>
<!-- 图说标题 -->
<div class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6" x-text="images[lbIndex]?.name || ''"></div>
<!-- 提示文本 -->
<div x-show="!lbZoomed" class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200">
点击图片放大 · 上下滚动查看 · Esc 关闭
</div>
</div>
</div>
<!-- Alpine.js 业务逻辑:仅负责灯箱交互,图片数据由服务端注入 -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('articleView', (images = []) => ({
images,
lbOpen: false,
lbIndex: 0,
lbZoomed: false,
// 触摸滑动记录
touchStartX: 0,
touchStartY: 0,
// 打开灯箱
openLb(index = 0) {
if (!this.images?.length) return
this.lbIndex = index
this.lbZoomed = false
this.lbOpen = true
document.body.style.overflow = "hidden"
this.scrollThumbIntoView()
},
// 关闭灯箱
closeLb() {
this.lbOpen = false
this.lbZoomed = false
document.body.style.overflow = ""
},
// 切换视图
showLb(index) {
const total = this.images?.length || 0
if (total === 0) return
this.lbIndex = (index + total) % total
this.resetZoom()
this.scrollThumbIntoView()
},
nextLb() { this.showLb(this.lbIndex + 1) },
prevLb() { this.showLb(this.lbIndex - 1) },
toggleZoom() {
this.lbZoomed = !this.lbZoomed
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
},
resetZoom() {
this.lbZoomed = false
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
},
// 左侧缩略图自动居中滚到视野中央
scrollThumbIntoView() {
this.$nextTick(() => {
const thumbsEl = this.$refs.lbThumbs
if (!thumbsEl) return
const activeThumb = thumbsEl.children[this.lbIndex]
if (activeThumb) {
const target = activeThumb.offsetTop - (thumbsEl.clientHeight - activeThumb.clientHeight) / 2
thumbsEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
}
})
},
// 手势处理
handleTouchStart(e) {
if (e.touches.length === 1) {
this.touchStartX = e.touches[0].clientX
this.touchStartY = e.touches[0].clientY
}
},
handleTouchEnd(e) {
if (this.lbZoomed) return
const t = e.changedTouches[0]
const dx = t.clientX - this.touchStartX
const dy = t.clientY - this.touchStartY
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
dx < 0 ? this.nextLb() : this.prevLb()
}
}
}))
});
</script>
<style>
/* 隐藏缩略图侧边栏滚动条 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>