Files
frontend_v2/src/pages/shows.astro
toom1996 5a8dbf41cb
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-11 22:29:47 +08:00

1008 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import Layout from "../layouts/Layout.astro"
// 全部秀场档案库HBX 风左筛选 + 右 4 列商品卡。
// 筛选交互(设计师多选 / 系列类型 / 季节 / 年份 / 排序)全部通过 querystring 同步到后端,
// 真正由 `/api/public/articles` 的 brand_ids / collection_type / season / year / sort 参数承担。
// 侧边栏品牌列表featured=1 取 ~198 个有走秀档案的代表品牌,含 article_count 作为计数。
// dev 期import.meta.env.DEV自动兜底到本地后端避免每次手敲环境变量生产走 API_SSR 构建参数。
import { getBrands, getYears, type BrandEntry } from "../lib/api"
let sidebarBrands: BrandEntry[] = []
let yearChips: number[] = []
// SSR 期从后端拉精选品牌(有走秀档案的代表品牌,含计数),运行期前端用同一封装再拉。
try {
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
// 年份 chips从 back-end 拉最近 30 条秀场,提取独特年份(按降序去重),构造最近 6 年。
// 为 SSR 提供稳健的初始值,运行期会按筛选再补;不可达时给一组 fallback 区间。
let yearSeed = [2026, 2025, 2024, 2023, 2022, 2021]
try {
const ys = await getYears(30)
if (ys.length) yearChips = ys
} catch {}
if (!yearChips.length) yearChips = yearSeed
// URL 链接:保留当前路径以便 client-side filter 同步到地址栏
const pathname = "/shows"
---
<Layout title="All Shows — Portfolio Studio">
<!-- 首屏绘制前同步侧边栏折叠状态,杜绝刷新时的宽度闪动(无闪烁) -->
<script is:inline>
try {
if (localStorage.getItem("showsaside-collapsed") === "1") {
document.documentElement.classList.add("showsaside-collapsed")
}
} catch (e) {}
</script>
<div class="showsroot">
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div class="showshead-l">
<span class="showskicker">Runway Archive · 2026 Edition</span>
<h1 class="showsh1">All Shows</h1>
</div>
</header>
<!-- 已激活筛选 pills -->
<div id="showspills" class="showspills" hidden></div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside" id="showsaside">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
<button id="showstoggle" class="showsaside-toggle" type="button" aria-label="折叠/展开筛选">
<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
<path d="M7.5 3l-4 3 4 3" stroke="currentColor" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="showsaside-body">
<!-- Designer 多选 -->
<section class="showsg">
<header class="showsg-h" data-group="designers" role="button" tabindex="0" aria-expanded="true">
<span>DESIGNERS</span>
<span class="showsg-right">
<span class="showsg-count" id="brand-count">0</span>
<span class="showsg-chev" aria-hidden="true"><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">
<input id="brand-search" class="showssearch" placeholder="Search designer…" autocomplete="off" />
<ul id="brand-list" class="showschecks">
<!-- 由 JS 把 sidebarBrands 渲染进来 -->
</ul>
<a id="brand-showmore" class="showsmore">Show all 198 designers ▾</a>
</div>
</section>
<!-- Collection type - 多选复选框 -->
<section class="showsg">
<header class="showsg-h" data-group="collection" role="button" tabindex="0" aria-expanded="true"><span>COLLECTION</span><span class="showsg-chev" aria-hidden="true"><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">
<ul id="ct-checks" class="showschecks showschecks--flat">
<li><label><input type="checkbox" value="rtw" /> <span>Ready-to-Wear</span></label></li>
<li><label><input type="checkbox" value="menswear" /> <span>Menswear</span></label></li>
<li><label><input type="checkbox" value="couture" /> <span>Couture</span></label></li>
<li><label><input type="checkbox" value="resort" /> <span>Resort</span></label></li>
<li><label><input type="checkbox" value="pre_fall" /> <span>Pre-Fall</span></label></li>
</ul>
</div>
</section>
<!-- Season - 多选复选框 -->
<section class="showsg">
<header class="showsg-h" data-group="season" role="button" tabindex="0" aria-expanded="true"><span>SEASON</span><span class="showsg-chev" aria-hidden="true"><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">
<ul id="season-checks" class="showschecks showschecks--flat">
<li><label><input type="checkbox" value="spring" /> <span>Spring / Summer</span></label></li>
<li><label><input type="checkbox" value="fall" /> <span>Fall / Winter</span></label></li>
</ul>
</div>
</section>
<!-- Year - 多选复选框 -->
<section class="showsg">
<header class="showsg-h" data-group="year" role="button" tabindex="0" aria-expanded="true"><span>YEAR</span><span class="showsg-chev" aria-hidden="true"><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">
<ul id="year-checks" class="showschecks showschecks--flat">
{yearChips.map((y) => (
<li><label><input type="checkbox" value={String(y)} /> <span>{y}</span></label></li>
))}
</ul>
</div>
</section>
<button id="showsclear" class="showsclear" type="button">Clear all ×</button>
</div><!-- /.showsaside-body -->
</aside>
<!-- MAIN grid -->
<main class="showsmain">
<div id="showsgrid" class="showsgrid">
<!-- Skeletons 占位:避免 dev SSR 时空白闪烁 -->
{Array.from({ length: 12 }, () => (
<div class="showscard showscard--skel">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline" style="height:40px;margin:0"></div>
<div class="showsline short" style="height:14px;margin:12px 0 0"></div>
<div class="showsline" style="height:34px;margin:14px 0 0"></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>
))}
</div>
<div class="showsloadmore-wrap">
<button id="showsloadmore" class="showsloadmore" type="button">Load More ↓</button>
</div>
</main>
</div>
</div>
</Layout>
<!-- 注入 SSR 期品牌数据:避免 client-side 再发一次请求 -->
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<style is:global>
@reference "tailwindcss";
.showsroot {
max-width: 1480px;
/* margin: auto; */
padding-top: 28px;
/* font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); */
color: #0a0a0a;
}
/* ---------- 顶部眉头 ---------- */
.showshead {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 24px; padding: 22px 0 26px; border-bottom: 2px solid #000;
}
.showskicker {
display: inline-block;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
color: #000; margin-bottom: 10px;
}
.showsh1 {
font-family: var(--font-serif, ui-serif, Georgia, serif);
font-size: clamp(40px, 6vw, 72px); line-height: .9; font-weight: 900;
letter-spacing: -.02em; margin: 0 0 10px;
}
.showscount-label {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
color: #000; font-weight: 700; margin-right: 4px;
}
.showssortwrap {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px; text-transform: uppercase; letter-spacing: .16em; color: #000;
}
.showssort {
appearance: none; -webkit-appearance: none;
border: 1.5px solid #000; background: #fff; color: #000;
font-family: inherit; font-size: 11px; letter-spacing: .14em;
text-transform: uppercase; padding: 10px 32px 10px 14px;
cursor: pointer; border-radius: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2 4l4 4 4-4' stroke='%23000' fill='none' stroke-width='1.4'/></svg>");
background-repeat: no-repeat; background-position: right 10px center;
}
/* ---------- 已激活筛选 pills ---------- */
.showspills {
display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0;
}
.showspill {
display: inline-flex; align-items: center; gap: 8px;
border: 1.5px solid #000; padding: 6px 10px;
font-family: var(--font-mono, monospace); font-size: 10.5px;
text-transform: uppercase; letter-spacing: .14em; color: #000; background: #fff;
}
.showspill .x {
display: inline-flex; width: 14px; height: 14px; align-items: center; justify-content: center;
border: none; background: #000; color: #fff; cursor: pointer; font-family: inherit; font-size: 12px; line-height: 1;
}
/* ---------- 主体sidebar + grid ---------- */
.showsbody {
display: flex; gap: 36px;
align-items: flex-start;
}
@media (max-width: 900px) {
.showsbody { flex-direction: column; gap: 18px; }
.showsaside { position: static !important; max-height: none !important; width: 100% !important; }
}
/* ---------- LEFT SIDEBAR可折叠 ---------- */
.showsaside {
flex: 0 0 240px;
/* border: 1.5px solid #000; */
background: #fff;
position: sticky; top: 20px;
max-height: calc(100vh - 40px);
border-radius: 0;
transition: flex-basis .25s ease;
display: flex;
flex-direction: column;
}
html.showsaside-collapsed .showsaside {
flex: 0 0 44px;
}
html.showsaside-collapsed .showsaside-body { display: none; }
.showsaside-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 14px 12px;
border-bottom: 1px solid #000;
flex-shrink: 0;
}
.showsaside-h {
font-family: var(--font-mono, monospace);
font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
font-weight: 700; margin: 0; line-height: 1;
}
html.showsaside-collapsed .showsaside-h {
opacity: 0; pointer-events: none;
width: 0; overflow: hidden;
}
.showsaside-toggle {
appearance: none; -webkit-appearance: none;
background: #fff; border: 1.5px solid #000;
width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; border-radius: 0;
color: #000; padding: 0;
transition: transform .25s ease;
}
.showsaside-toggle:hover { background: #000; color: #fff; }
html.showsaside-collapsed .showsaside-toggle {
margin-left: auto;
transform: rotate(180deg);
}
.showsaside-body {
padding: 14px 14px 18px;
overflow-y: auto;
scrollbar-width: thin; scrollbar-color: #ccc #fff;
flex: 1; min-height: 0;
}
.showsaside-body::-webkit-scrollbar { width: 6px; }
.showsaside-body::-webkit-scrollbar-thumb { background: #ccc; }
.showsg { border-top: 1px solid #e6e6e6; padding: 12px 0 6px; }
.showsg:first-of-type { border-top: none; padding-top: 4px; }
.showsg-h {
display: flex; justify-content: space-between; align-items: center;
font-family: var(--font-mono, monospace);
font-size: 11px; letter-spacing: .22em; text-transform: uppercase;
font-weight: 700; color: #000; margin-bottom: 8px;
cursor: pointer; user-select: none;
}
.showsg-h:hover { color: #000; }
.showsg-right { display: inline-flex; align-items: center; gap: 10px; }
.showsg-chev {
display: inline-block; font-size: 9px; line-height: 1; color: #999;
transition: transform .2s ease; margin-left: 4px;
}
.showsg.is-collapsed .showsg-chev { transform: rotate(-90deg); }
.showsg.is-collapsed .showsg-b { display: none; }
.showsg-count {
color: #999; font-weight: 400;
font-family: var(--font-mono, monospace); font-size: 10px; letter-spacing: .14em;
}
.showssearch {
width: 100%; border: 1px solid #d4d4d4; padding: 7px 10px;
font-family: var(--font-mono, monospace); font-size: 11px;
text-transform: uppercase; letter-spacing: .08em; color: #000;
border-radius: 0; outline: none; background: #fafafa;
}
.showssearch:focus { border-color: #000; background: #fff; }
.showschecks { list-style: none; margin: 10px 0 0; padding: 0; max-height: 280px; overflow-y: auto; scrollbar-width: thin; }
.showschecks::-webkit-scrollbar { width: 4px; }
.showschecks::-webkit-scrollbar-thumb { background: #ddd; }
.showschecks li {
display: flex; align-items: center; gap: 8px;
padding: 5px 0;
font-family: var(--font-mono, monospace);
font-size: 11.5px; text-transform: uppercase; letter-spacing: .04em;
color: #000; cursor: pointer; user-select: none;
}
.showschecks li:hover { background: #f6f6f6; }
.showschecks li[data-letter] {
padding: 8px 0 3px; font-size: 10px; letter-spacing: .22em;
color: #8a8a8a; cursor: default; pointer-events: none;
border-top: 1px dotted #e6e6e6;
}
.showschecks li[data-letter]:first-child { border-top: none; padding-top: 4px; }
.showschecks input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; border: 1.5px solid #000;
background: #fff; cursor: pointer; flex-shrink: 0;
border-radius: 0; display: inline-grid; place-content: center;
}
.showschecks input[type="checkbox"]:checked { background: #000; }
.showschecks input[type="checkbox"]:checked::after {
content: ""; width: 6px; height: 6px; background: #fff;
}
.showschecks .ct { color: #999; font-size: 10px; margin-left: auto; }
/* 扁平复选框组COLLECTION / SEASON / YEAR不按字母分组更紧凑 */
.showschecks--flat li { padding: 4px 0; }
.showschecks--flat li label {
display: inline-flex; align-items: center; gap: 8px;
cursor: pointer; width: 100%;
}
.showschecks--flat li:hover { background: #f6f6f6; }
.showschecks--flat li input[type="checkbox"] {
width: 13px; height: 13px;
}
.showsmore {
display: inline-block; margin-top: 10px;
font-family: var(--font-mono, monospace); font-size: 10.5px;
text-transform: uppercase; letter-spacing: .16em; color: #555;
cursor: pointer;
}
.showsmore:hover { color: #000; }
.showsclear {
margin-top: 18px; width: 100%;
border: 1.5px solid #000; background: #fff; color: #000;
padding: 11px 12px; font-family: var(--font-mono, monospace);
font-size: 11px; text-transform: uppercase; letter-spacing: .22em;
font-weight: 700; cursor: pointer; border-radius: 0;
transition: all .15s;
}
.showsclear:hover { background: #000; color: #fff; }
/* ---------- MAIN GRID ---------- */
.showsmain { flex: 1; min-width: 0; }
.showsgrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 22px;
position: relative;
}
/* 切换筛选时的顶部加载进度条 */
.showsgrid.is-loading::before {
content: ""; position: absolute; top: -4px; left: 0; height: 2px; width: 38%;
background: #000; z-index: 5; animation: hbxProg 1s ease-in-out infinite;
}
@keyframes hbxProg {
0% { left: -38%; } 100% { left: 100%; }
}
@media (max-width: 560px) { .showsgrid { grid-template-columns: repeat(1, 1fr); } }
.showscard {
position: relative; display: flex; gap: 14px;
text-decoration: none; color: inherit;
background: #fff;
/* border: 1px solid #e6e6e6; */
padding: 12px;
transition: border-color .2s;
}
/* .showscard:hover { border-color: #000; } */
.showscard:hover .showscard-img img { transform: scale(1.04); }
/* 左:主图(窄,占 ~30% */
.showscard-left {
flex: 0 0 30%;
min-width: 110px;
}
.showscard-img {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
background: #f1f1f1;
overflow: hidden;
border-radius: 0;
}
.showscard-img img {
width: 100%; height: 100%; object-fit: cover;
transition: transform .6s cubic-bezier(.2,.8,.2,1), opacity .45s ease;
opacity: 0; /* 固定灰底占位:图片未加载时显示容器灰底,加载完成后淡入 */
}
.showscard-img img.is-loaded { opacity: 1; }
/* 图片加载中的小 spinner灰底之上居中显示加载完成/失败淡出 */
.showsspin {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #bdbdbd; opacity: 1;
transition: opacity .3s ease; pointer-events: none; z-index: 2;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin { opacity: 0; }
.showsspin svg { width: 22px; height: 22px; display: block; }
.showscard-thumb .showsspin svg { width: 15px; height: 15px; }
.showsspin .ring { transform-origin: 12px 12px; animation: hbxSpin .8s linear infinite; }
@keyframes hbxSpin { to { transform: rotate(360deg); } }
.showsbadge {
position: absolute; top: 6px; right: 6px;
font-family: var(--font-mono, monospace);
font-size: 9px; letter-spacing: .14em; text-transform: uppercase;
font-weight: 700; color: #fff; background: #000; padding: 3px 6px;
}
.showsflag-featured {
position: absolute; top: 6px; left: 6px;
font-family: var(--font-mono, monospace);
font-size: 9px; letter-spacing: .14em; text-transform: uppercase;
font-weight: 700; color: #000; background: #fff; padding: 3px 6px;
border: 1px solid #000;
}
/* 右列:上信息 + 下缩略图(缩略图位于主图右侧) */
.showscard-right {
flex: 1; min-width: 0;
display: flex; flex-direction: column;justify-content: space-between;
}
.showscard-info {
flex: 0 0 auto; min-width: 0;
display: flex; flex-direction: column;
padding: 2px 2px 0;
}
.showscard-title {
font-family: var(--font-serif, serif);
font-size: 15.5px; font-weight: 700; line-height: 1.32;
margin: 0 0 10px; color: #111;
letter-spacing: -.01em;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.showscard-meta {
font-family: var(--font-mono, monospace);
font-size: 9.5px; color: #888;
margin: 0 0 14px; line-height: 1.55;
letter-spacing: .04em; text-transform: uppercase;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.showscard-meta-foot {
display: flex; flex-direction: column; gap: 6px;
}
.showscard-meta-line {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-mono, monospace);
font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase;
color: #444;
}
.showscard-meta-line svg {
flex-shrink: 0; color: #888;
}
/* 右列底部5 列缩略图条、最后一张叠加 N+ 角标(位于主图右侧,与样图一致) */
.showscard-strip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin-top: 10px;
}
.showscard-thumb {
position: relative;
aspect-ratio: 3 / 4;
background: #f1f1f1;
overflow: hidden;
}
.showscard-thumb img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0;
transition: opacity .45s ease;
}
.showscard-thumb img.is-loaded { opacity: 1; }
.showscard-thumb.placeholder {
background: #f6f6f6;
}
.showscard-thumb-more {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-family: var(--font-mono, monospace);
font-size: 11px; font-weight: 700;
letter-spacing: .04em;
display: flex; align-items: center; justify-content: center;
}
@media (max-width: 700px) {
.showscard { flex-direction: column; gap: 10px; }
.showscard-left { flex: none; width: 100%; }
.showscard-right { width: 100%; }
}
/* skeleton */
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
background: #efefef;
animation: hbxShim 1.6s ease infinite;
}
.showscard--skel .showscard-img { background: #efefef; }
.showscard--skel .showsline {
height: 10px; margin: 8px 0 0;
}
.showscard--skel .showsline.short { width: 60%; }
@keyframes hbxShim {
0% { opacity: .85; }
50% { opacity: .55; }
100% { opacity: .85; }
}
/* 加载更多 */
.showsloadmore-wrap { display: flex; justify-content: center; margin-top: 38px; }
.showsloadmore {
border: 1.5px solid #000; background: #fff; color: #000;
padding: 13px 36px;
font-family: var(--font-mono, monospace);
font-size: 11px; text-transform: uppercase; letter-spacing: .22em; font-weight: 700;
cursor: pointer; border-radius: 0; transition: all .2s;
}
.showsloadmore:hover { background: #000; color: #fff; }
.showsloadmore[disabled] { opacity: .4; cursor: default; }
.showsempty {
grid-column: 1 / -1;
padding: 80px 20px; text-align: center;
font-family: var(--font-mono, monospace);
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
color: #999;
border: 1px dashed #ccc;
}
</style>
<script>
import { getArticles, toAbs, type ArticleItem, type BrandEntry } from "../lib/api"
type Item = ArticleItem
const root = document.querySelector<HTMLElement>(".showsroot")
const grid = document.getElementById("showsgrid") as HTMLElement | null
const totalEl = document.getElementById("showstotal")
const brandCountEl = document.getElementById("brand-count")
const brandListEl = document.getElementById("brand-list")
const brandSearchEl = document.getElementById("brand-search") as HTMLInputElement | null
const brandShowMore = document.getElementById("brand-showmore") as HTMLElement | null
const pillsEl = document.getElementById("showspills")
const sortEl = document.getElementById("showssort") as HTMLSelectElement | null
const loadBtn = document.getElementById("showsloadmore") as HTMLButtonElement | null
const brandnEl = document.getElementById("showsbrandn")
// SSR 期注入的品牌清单(包含 article_count
const brandsData: BrandEntry[] = (() => {
const node = document.getElementById("showsbrands-data")
if (!node?.textContent) return []
try { return JSON.parse(node.textContent) } catch { return [] }
})()
if (brandnEl) brandnEl.textContent = String(brandsData.length || 198)
// 筛选状态COLLECTION / SEASON / YEAR 用 Set 实现多选)
const state = {
brand_ids: new Set<number>(),
ct: new Set<string>(),
season: new Set<string>(),
year: new Set<string>(),
sort: "newest",
page: 1,
size: 24,
total: 0,
brandsExpanded: false,
keyword: "",
}
// ============ 侧边栏DESIGNERS 按字母分组 ============
const grouped = (() => {
const map = new Map<string, BrandEntry[]>()
for (const b of brandsData) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
})()
const renderBrands = () => {
if (!brandListEl) return
brandListEl.innerHTML = ""
const frag = document.createDocumentFragment()
const kw = state.keyword
let shown = 0
const MAX_INITIAL = 60
let truncated = false
const appendBrand = (b: BrandEntry) => {
const li = document.createElement("li")
const cb = document.createElement("input")
cb.type = "checkbox"; cb.value = String(b.id)
cb.checked = state.brand_ids.has(b.id)
cb.addEventListener("change", () => {
cb.checked ? state.brand_ids.add(b.id) : state.brand_ids.delete(b.id)
syncPills()
loadPage(1)
})
const name = document.createElement("span"); name.textContent = b.name
const ct = document.createElement("span"); ct.className = "ct"; ct.textContent = b.article_count ? String(b.article_count) : ""
li.appendChild(cb); li.appendChild(name); li.appendChild(ct)
frag.appendChild(li)
shown++
}
if (kw) {
// 搜索模式:跨字母平铺所有匹配项(忽略 60 上限 / 字母分组)
for (const [, list] of grouped) {
for (const b of list) {
if (b.name.toLowerCase().includes(kw)) appendBrand(b)
}
}
} else {
for (const [letter, list] of grouped) {
if (!state.brandsExpanded && shown >= MAX_INITIAL) { truncated = true; break }
const h = document.createElement("li")
h.dataset.letter = letter
h.textContent = letter
frag.appendChild(h)
for (const b of list) {
if (!state.brandsExpanded && shown >= MAX_INITIAL) { truncated = true; break }
appendBrand(b)
}
}
}
brandListEl.appendChild(frag)
if (brandShowMore) brandShowMore.style.display = (truncated && !kw) ? "" : "none"
if (brandCountEl) brandCountEl.textContent = String(state.brand_ids.size)
}
if (brandShowMore) {
brandShowMore.addEventListener("click", () => {
state.brandsExpanded = !state.brandsExpanded
brandShowMore.textContent = state.brandsExpanded ? "Show fewer ▴" : `Show all ${brandsData.length} designers ▾`
renderBrands()
})
brandShowMore.textContent = `Show all ${brandsData.length} designers ▾`
}
if (brandSearchEl) {
let timer: number | undefined
brandSearchEl.addEventListener("input", () => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
state.keyword = brandSearchEl.value.trim().toLowerCase()
renderBrands()
}, 120)
})
}
// ============ 侧边栏整体折叠localStorage 记住状态) ============
// 初始折叠状态已由页面顶部 <script is:inline> 同步到 <html> 根类,
// 在首屏绘制前生效,避免「先展开后折叠」的宽度闪动。此处只绑定切换。
const asideToggle = document.getElementById("showstoggle")
const lsKey = "showsaside-collapsed"
if (asideToggle) {
asideToggle.addEventListener("click", () => {
const collapsed = document.documentElement.classList.toggle("showsaside-collapsed")
try { localStorage.setItem(lsKey, collapsed ? "1" : "") } catch (e) {}
})
}
// ============ 每个筛选分组可上下折叠accordionlocalStorage 记忆) ============
document.querySelectorAll<HTMLElement>(".showsg-h[data-group]").forEach((h) => {
const key = "showsg-" + (h.dataset.group || "")
const g = h.closest(".showsg")
try { if (localStorage.getItem(key) === "1" && g) { g.classList.add("is-collapsed"); h.setAttribute("aria-expanded", "false") } } catch (e) {}
const toggle = () => {
if (!g) return
const collapsed = g.classList.toggle("is-collapsed")
h.setAttribute("aria-expanded", collapsed ? "false" : "true")
try { localStorage.setItem(key, collapsed ? "1" : "") } catch (e) {}
}
h.addEventListener("click", toggle)
h.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle() }
})
})
// ============ 侧边栏多选复选框组COLLECTION / SEASON / YEAR ============
// 用一组扁平复选框代替旧 chip。每个 input.change 写入对应的 Set立即刷新数据。
const wireCheckboxGroup = (
set: Set<string>,
containerId: string,
onChange: () => void
) => {
const container = document.getElementById(containerId)
if (!container) return
container.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
cb.checked ? set.add(cb.value) : set.delete(cb.value)
onChange()
})
})
}
wireCheckboxGroup(state.ct, "ct-checks", refreshAfterFilter)
wireCheckboxGroup(state.season, "season-checks", refreshAfterFilter)
wireCheckboxGroup(state.year, "year-checks", refreshAfterFilter)
function refreshAfterFilter() {
syncPills()
loadPage(1)
}
// ============ 排序 ============
if (sortEl) {
sortEl.addEventListener("change", () => {
state.sort = sortEl.value
syncPills()
loadPage(1)
})
}
// ============ Pills已激活筛选清单 ============
const syncPills = () => {
if (!pillsEl) return
const list: { label: string; clear: () => void }[] = []
if (state.brand_ids.size) {
const idArr = [...state.brand_ids]
const labels = idArr.map((id) => brandsData.find((b) => b.id === id)?.name ?? `#${id}`).slice(0, 4)
const more = idArr.length > 4 ? ` +${idArr.length - 4}` : ""
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { state.brand_ids.clear(); renderBrands(); }
})
}
if (state.ct.size) list.push({
label: "COLLECTION · " + [...state.ct].join("+").toUpperCase(),
clear: () => { state.ct.clear(); syncCheckboxGroup("ct-checks"); }
})
if (state.season.size) list.push({
label: "SEASON · " + [...state.season].join("+").toUpperCase(),
clear: () => { state.season.clear(); syncCheckboxGroup("season-checks"); }
})
if (state.year.size) list.push({
label: "YEAR · " + [...state.year].sort().join("+"),
clear: () => { state.year.clear(); syncCheckboxGroup("year-checks"); }
})
if (state.sort !== "newest") list.push({
label: "SORT · " + state.sort.replace("_", " ").toUpperCase(),
clear: () => { state.sort = "newest"; if (sortEl) sortEl.value = "newest" }
})
if (!list.length) { pillsEl.hidden = true; pillsEl.innerHTML = ""; return }
pillsEl.hidden = false
pillsEl.innerHTML = list.map((p, i) => `<span class="showspill" data-i="${i}">${p.label}<button class="x" type="button">×</button></span>`).join("")
pillsEl.querySelectorAll<HTMLElement>(".showspill").forEach((pill) => {
pill.querySelector(".x")?.addEventListener("click", () => {
const i = Number(pill.dataset.i)
list[i]?.clear()
syncPills()
loadPage(1)
})
})
if (brandCountEl) brandCountEl.textContent = String(state.brand_ids.size)
}
const syncCheckboxGroup = (containerId: string) => {
document.getElementById(containerId)?.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => { cb.checked = false })
}
// Clear all
document.getElementById("showsclear")?.addEventListener("click", () => {
state.brand_ids.clear()
state.ct.clear(); state.season.clear(); state.year.clear()
state.sort = "newest"
state.keyword = ""
if (brandSearchEl) brandSearchEl.value = ""
syncCheckboxGroup("ct-checks")
syncCheckboxGroup("season-checks")
syncCheckboxGroup("year-checks")
if (sortEl) sortEl.value = "newest"
renderBrands()
syncPills()
loadPage(1)
})
// ============ 卡片渲染(左大图 + 右上文字 + 下 5 张缩略图条) ============
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_CAM = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="1.5" y="3.5" width="9" height="6" stroke="currentColor" fill="none" stroke-width="1"/><circle cx="6" cy="6.5" r="1.8" stroke="currentColor" fill="none" stroke-width="1"/><path d="M4 3.5l1-1.2h2l1 1.2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
// 加载中 spinner细线旋转环 + 中心静态相机小图标灰调HBX 编辑风)
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="ring" 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>`
const fmtDate = (ts: number) => {
if (!ts) return ""
const d = new Date(ts * 1000)
const mm = String(d.getMonth() + 1).padStart(2, "0")
const dd = String(d.getDate()).padStart(2, "0")
return `${d.getFullYear()}-${mm}-${dd}`
}
const ctLabel = (ct?: string) => {
const map: Record<string, string> = {
rtw: "Ready-to-Wear", menswear: "Menswear", couture: "Couture",
resort: "Resort", pre_fall: "Pre-Fall",
}
return ct && map[ct] ? map[ct] : (ct || "")
}
const cardHtml = (it: Item) => {
const code = it.season_code || String(it.year || "")
const gallery = it.gallery || []
const totalCount = it.image_count || 0
// 角标:总数 > 30 一律 30+,否则展示 N+(即使 N<=5 也展示,保持与样图 5 槽位+末张叠加的视觉一致)
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
// 永远渲染 5 个缩略图槽位HBX 视觉一致性,不够补 .placeholder
const SLOTS = 5
const slots: string[] = []
for (let i = 0; i < SLOTS; i++) {
const src = gallery[i]
const isLast = i === SLOTS - 1
if (src) {
slots.push(`<div class="showscard-thumb"><span class="showsspin" aria-hidden="true">${SPIN_SVG}</span><img src="${toAbs(src)}" alt="" loading="lazy" />${isLast && totalCount > 0 ? `<span class="showscard-thumb-more">${moreLabel}</span>` : ""}</div>`)
} else {
slots.push(`<div class="showscard-thumb placeholder">${isLast && totalCount > 0 ? `<span class="showscard-thumb-more">${moreLabel}</span>` : ""}</div>`)
}
}
const isNew = (Date.now() / 1000 - Number(it.published_at || 0) < 60 * 60 * 24 * 14)
const isFeatured = it.image_count > 70
return `
<a class="showscard" href="/article?id=${it.id}" aria-label="${it.brand_name} ${it.title}">
<div class="showscard-left">
<div class="showscard-img">
${isFeatured ? '<span class="showsflag-featured">Featured</span>' : ""}
<span class="showsspin" aria-hidden="true">${SPIN_SVG}</span>
<img src="${toAbs(it.cover)}" alt="${it.title}" loading="lazy" />
${isNew ? '<span class="showsbadge">New Added</span>' : ""}
</div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title">${it.title}</h3>
<p class="showscard-meta">${code} · ${ctLabel(it.collection_type)} · ${it.brand_name}</p>
<div class="showscard-meta-foot">
<span class="showscard-meta-line">${ICON_USER}${it.brand_name}</span>
<span class="showscard-meta-line">${ICON_DATE}${fmtDate(it.published_at)}</span>
</div>
</div>
<div class="showscard-strip">${slots.join("")}</div>
</div>
</a>`
}
// 图片淡入 + spinner网络图加载完成前显示容器灰底占位与居中 spinner
// 加载完淡入图片并淡出 spinner消除硬切换跳变
const revealImages = (root: ParentNode = document) => {
const imgs = root.querySelectorAll<HTMLImageElement>(".showscard-img img, .showscard-thumb img")
imgs.forEach((img) => {
if (img.classList.contains("is-loaded")) return
const wrap = img.closest(".showscard-img, .showscard-thumb")
const done = () => { img.classList.add("is-loaded"); wrap?.classList.add("is-loaded") }
if (img.complete && img.naturalWidth > 0) {
done()
} else {
img.addEventListener("load", done, { once: true })
// 加载失败:隐藏 img保留容器灰底占位并淡出 spinner
img.addEventListener("error", () => { img.style.display = "none"; wrap?.classList.add("is-loaded") }, { once: true })
}
})
}
const renderItems = (items: Item[], append: boolean) => {
if (!grid) return
if (!append) {
grid.innerHTML = ""
}
if (!items.length && !append) {
grid.innerHTML = `<div class="showsempty">No shows match the current filters · 暂无符合条件的秀场</div>`
return
}
grid.insertAdjacentHTML("beforeend", items.map(cardHtml).join(""))
revealImages(grid)
}
// ============ 拉数(统一走 src/lib/api 的 getArticles ============
// 筛选/翻页时的骨架占位
const skeletonHtml = (n: number) => Array.from({ length: n }, () => `
<div class="showscard showscard--skel">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline" style="height:40px;margin:0"></div>
<div class="showsline short" style="height:14px;margin:12px 0 0"></div>
<div class="showsline" style="height:34px;margin:14px 0 0"></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>`).join("")
// 请求令牌:避免快速切换筛选时旧响应覆盖新结果
let reqToken = 0
const loadPage = async (page: number, append = false) => {
const myToken = ++reqToken
try {
if (loadBtn && append) { loadBtn.textContent = "Loading…"; loadBtn.disabled = true }
// 切换筛选(非追加)时立即进入 loading 态:显示骨架 + 顶部进度条
if (!append && grid) {
grid.innerHTML = skeletonHtml(12)
grid.classList.add("is-loading")
}
const json = await getArticles({
page,
size: state.size,
sort: state.sort,
brand_ids: [...state.brand_ids],
collection_type: [...state.ct],
season: [...state.season],
year: [...state.year],
with_images: 6,
})
if (myToken !== reqToken) return // 已有更新的请求,丢弃本次结果
const rows: Item[] = json.data
state.total = json.total
state.page = page
grid?.classList.remove("is-loading")
renderItems(rows, append)
if (totalEl) totalEl.textContent = `${state.total || (grid?.querySelectorAll(".showscard").length ?? rows.length)} Shows`
const last = json.last_page
if (page >= last || rows.length < state.size) {
if (loadBtn) { loadBtn.textContent = "— End of Archive —"; loadBtn.disabled = true }
} else if (loadBtn) {
loadBtn.textContent = "Load More ↓"
loadBtn.disabled = false
}
} catch {
if (myToken !== reqToken) return
grid?.classList.remove("is-loading")
renderItems([], false)
if (totalEl) totalEl.textContent = "—"
if (loadBtn) { loadBtn.textContent = "Retry"; loadBtn.disabled = false }
}
}
if (loadBtn) loadBtn.addEventListener("click", () => loadPage(state.page + 1, true))
// 初次渲染
renderBrands()
revealImages(document) // 首屏 SSR 已渲染的卡片图片同样淡入
loadPage(1, false)
</script>
</content>