404 lines
20 KiB
Plaintext
404 lines
20 KiB
Plaintext
---
|
||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||
import { getI18n } from '@/i18n/utils'
|
||
import "@/styles/look-grid.css"
|
||
|
||
const { locale, t } = getI18n(Astro)
|
||
|
||
// 年份筛选:近 10 年(含当年),末项兜底
|
||
const currentYear = new Date().getFullYear()
|
||
const yearFloor = currentYear - 9
|
||
const yearOptions: { value: number | string; label: string }[] = []
|
||
for (let y = currentYear; y >= yearFloor; y--) {
|
||
yearOptions.push({ value: y, label: String(y) })
|
||
}
|
||
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
|
||
|
||
// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc
|
||
const sortOptions: { value: string; label: string }[] = [
|
||
{ value: "image_count", label: t('Most photos') },
|
||
{ value: "year_desc", label: t('Newest year') },
|
||
{ value: "year_asc", label: t('Oldest year') },
|
||
]
|
||
|
||
// 地区筛选:值即后端 street_snap.city 列存储的规范英文名(与 backfill_city 词典保持一致)。
|
||
// 标签用 t(city) —— 英文环境回退显示英文(巴黎→Paris),中文环境显示中文(Paris→巴黎)。
|
||
const cityOptions: { value: string; label: string }[] = [
|
||
{ value: "Paris", label: t('Paris') },
|
||
{ value: "New York", label: t('New York') },
|
||
{ value: "Milan", label: t('Milan') },
|
||
{ value: "London", label: t('London') },
|
||
{ value: "Copenhagen", label: t('Copenhagen') },
|
||
{ value: "Florence", label: t('Florence') },
|
||
{ value: "Berlin", label: t('Berlin') },
|
||
{ value: "Tokyo", label: t('Tokyo') },
|
||
{ value: "Seoul", label: t('Seoul') },
|
||
{ value: "Shanghai", label: t('Shanghai') },
|
||
{ value: "Beijing", label: t('Beijing') },
|
||
{ value: "Rome", label: t('Rome') },
|
||
{ value: "Madrid", label: t('Madrid') },
|
||
{ value: "Amsterdam", label: t('Amsterdam') },
|
||
{ value: "Antwerp", label: t('Antwerp') },
|
||
{ value: "Los Angeles", label: t('Los Angeles') },
|
||
{ value: "Moscow", label: t('Moscow') },
|
||
{ value: "Mumbai", label: t('Mumbai') },
|
||
]
|
||
---
|
||
|
||
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
|
||
<!-- 顶部:极简眉头 -->
|
||
<header class="snapshead">
|
||
<div>
|
||
<Breadcrumb>
|
||
<span slot="current">{t('Street Style')}</span>
|
||
</Breadcrumb>
|
||
<h1 class="snapsh1">{t('All Street Snaps')}</h1>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 已激活筛选 pills -->
|
||
<div class="snapspills" x-show="pills.length > 0" x-cloak>
|
||
<template x-for="(p, i) in pills" :key="i">
|
||
<span class="snapspill">
|
||
<span x-text="p.label"></span>
|
||
<button class="x" type="button" @click="p.clear()">×</button>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 主体:左 sidebar(REFINE 筛选) + 右卡片网格 -->
|
||
<div class="snapsbody">
|
||
<!-- LEFT SIDEBAR -->
|
||
<aside class="snapsaside">
|
||
<div class="snapsaside-head">
|
||
<h2 class="snapsaside-h">REFINE</h2>
|
||
</div>
|
||
|
||
<div class="snapsaside-body">
|
||
<!-- Year -->
|
||
<section class="snapsg">
|
||
<header class="snapsg-h" @click="toggleGroup('year')">
|
||
<span>{t('Year')}</span>
|
||
<span class="snapsg-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="snapsg-b" x-show="!collapsedGroups.year">
|
||
<ul class="snapschecks snapschecks--flat">
|
||
{yearOptions.map((o) => (
|
||
<li><label login-required><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Sort -->
|
||
<section class="snapsg">
|
||
<header class="snapsg-h" @click="toggleGroup('sort')">
|
||
<span>{t('Sort')}</span>
|
||
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.sort }">
|
||
<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="snapsg-b" x-show="!collapsedGroups.sort">
|
||
<ul class="snapschecks snapschecks--flat">
|
||
{sortOptions.map((o) => (
|
||
<li><label login-required><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- City / 地区 -->
|
||
<section class="snapsg">
|
||
<header class="snapsg-h" @click="toggleGroup('city')">
|
||
<span>{t('City')}</span>
|
||
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.city }">
|
||
<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="snapsg-b" x-show="!collapsedGroups.city">
|
||
<ul class="snapschecks snapschecks--flat">
|
||
{cityOptions.map((o) => (
|
||
<li><label login-required><input type="radio" name="snapCity" value={o.value} :checked={'selectedCity === "' + o.value + '"'} :class={`{'is-checked': selectedCity === '${o.value}'}`} @click.prevent={'selectedCity = selectedCity === "' + o.value + '" ? "" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<button login-required class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN:卡片网格(左封面 + 右标题 + 缩略图条带) -->
|
||
<main class="snapsmain">
|
||
<div class="snapscontent" :class="{ 'is-loading': loading }">
|
||
|
||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝尺寸抖动 -->
|
||
<template x-if="loading && items.length === 0">
|
||
<div class="snapsgrid" aria-hidden="true">
|
||
<template x-for="i in 4" :key="'skel-'+i">
|
||
<div class="snapscard snapscard--skel">
|
||
<div class="snapscard-left">
|
||
<div class="snapscard-img"></div>
|
||
</div>
|
||
<div class="snapscard-right">
|
||
<div class="snapscard-info">
|
||
<div class="snapsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
||
<div class="snapsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
||
</div>
|
||
<div class="snapscard-strip">
|
||
<div class="snapscard-thumb"></div>
|
||
<div class="snapscard-thumb"></div>
|
||
<div class="snapscard-thumb"></div>
|
||
<div class="snapscard-thumb"></div>
|
||
<div class="snapscard-thumb"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Empty -->
|
||
<template x-if="!loading && items.length === 0">
|
||
<div class="snapsempty">No street snaps match the current filters</div>
|
||
</template>
|
||
|
||
<!-- Card List -->
|
||
<template x-if="!loading && items.length > 0">
|
||
<div class="snapsgrid">
|
||
<template x-for="it in items" :key="it.id">
|
||
<div class="showscard-wrap relative group">
|
||
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
|
||
<div class="snapscard-left">
|
||
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
||
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
||
</div>
|
||
</div>
|
||
<div class="snapscard-right">
|
||
<div class="snapscard-info">
|
||
<h3 class="snapscard-title" x-text="titleParts(it)[0]"></h3>
|
||
<p class="snapscard-meta" x-text="titleParts(it)[1]"></p>
|
||
</div>
|
||
<div class="snapscard-strip">
|
||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
||
<div class="snapscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
||
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
|
||
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
|
||
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
|
||
<template x-for="(p, i) in pageList" :key="i">
|
||
<span class="pg-wrap">
|
||
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
|
||
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
|
||
</span>
|
||
</template>
|
||
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
|
||
</nav>
|
||
</main>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
||
|
||
|
||
|
||
<script>
|
||
import { getStreetSnaps, toAbs, getUser, type StreetSnapItem } from "@/lib/api"
|
||
import { clientHref, ROUTES } from "@/lib/routes"
|
||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||
|
||
const DEFAULT_SORT = "image_count"
|
||
|
||
document.addEventListener("alpine:init", () => {
|
||
Alpine.data("streetSnapsPage", () => {
|
||
const optNode = document.getElementById("snaps-year-opts")
|
||
const opts = optNode?.textContent ? JSON.parse(optNode.textContent) : { yearOptions: [], sortOptions: [] }
|
||
|
||
return {
|
||
locale: '',
|
||
SPIN_SVG,
|
||
yearOptions: opts.yearOptions || [],
|
||
sortOptions: opts.sortOptions || [],
|
||
cityOptions: opts.cityOptions || [],
|
||
selectedYear: '' as string,
|
||
selectedSort: DEFAULT_SORT as string,
|
||
selectedCity: '' as string,
|
||
|
||
// 侧栏分组折叠状态:默认全部收起(不做持久化,每次进页都是关着的)
|
||
collapsedGroups: { year: true, sort: true, city: true } as Record<string, boolean>,
|
||
|
||
items: [] as (StreetSnapItem & { _imgLoaded?: boolean })[],
|
||
loading: false,
|
||
page: 1,
|
||
lastPage: 1,
|
||
reqToken: 0,
|
||
|
||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
||
favMap: {} as Record<string, boolean>,
|
||
|
||
init() {
|
||
this.locale = this.$el.dataset.locale || 'en'
|
||
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
|
||
const city = new URLSearchParams(window.location.search).get('city')
|
||
const allowed = (this.cityOptions || []).map((c: any) => c.value)
|
||
if (city && allowed.includes(city)) this.selectedCity = city
|
||
// 首页公开:未登录也直接加载首屏,不弹登录框
|
||
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
|
||
this.loadPage(1)
|
||
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
|
||
},
|
||
|
||
// ===== 侧栏筛选 =====
|
||
toggleGroup(key: string) {
|
||
this.collapsedGroups[key] = !this.collapsedGroups[key]
|
||
},
|
||
clearAll() {
|
||
this.selectedYear = ''
|
||
this.selectedSort = DEFAULT_SORT
|
||
this.selectedCity = ''
|
||
this.loadPage(1)
|
||
},
|
||
|
||
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
|
||
get pills() {
|
||
const out: { label: string; clear: () => void }[] = []
|
||
if (this.selectedYear) {
|
||
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
|
||
out.push({
|
||
label: "YEAR · " + (hit ? hit.label : this.selectedYear),
|
||
clear: () => { this.selectedYear = ''; this.loadPage(1) },
|
||
})
|
||
}
|
||
if (this.selectedSort !== DEFAULT_SORT) {
|
||
const hit = this.sortOptions.find((o: any) => o.value === this.selectedSort)
|
||
out.push({
|
||
label: "SORT · " + (hit ? hit.label : this.selectedSort),
|
||
clear: () => { this.selectedSort = DEFAULT_SORT; this.loadPage(1) },
|
||
})
|
||
}
|
||
if (this.selectedCity) {
|
||
const hit = this.cityOptions.find((o: any) => o.value === this.selectedCity)
|
||
out.push({
|
||
label: "CITY · " + (hit ? hit.label : this.selectedCity),
|
||
clear: () => { this.selectedCity = ''; this.loadPage(1) },
|
||
})
|
||
}
|
||
return out
|
||
},
|
||
|
||
// 卡片右侧缩略图条带:固定 5 个槽位,缺图留 placeholder
|
||
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同),先抽成纯 URL 数组
|
||
getCardSlots(it: StreetSnapItem) {
|
||
// 缩略图条带跳过主图(it.cover 即 images[0]),从第二张起,避免首张缩略图与主图重复。
|
||
const imgs = (it.images || []).map((im: any) => (im && (im.image || im)) || "")
|
||
return makeCardSlots(imgs)
|
||
},
|
||
|
||
async loadPage(page: number) {
|
||
const myToken = ++this.reqToken
|
||
this.loading = true
|
||
this.items = []
|
||
try {
|
||
const json = await getStreetSnaps({
|
||
page,
|
||
year: this.selectedYear || undefined,
|
||
city: this.selectedCity || undefined,
|
||
sort: this.selectedSort,
|
||
})
|
||
if (myToken !== this.reqToken) return
|
||
const rows = json.data.map((item: StreetSnapItem) => ({ ...item, _imgLoaded: false }))
|
||
this.lastPage = json.last_page
|
||
this.page = page
|
||
this.items = rows
|
||
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
|
||
} catch {
|
||
if (myToken !== this.reqToken) return
|
||
this.items = []
|
||
} finally {
|
||
if (myToken === this.reqToken) this.loading = false
|
||
}
|
||
},
|
||
|
||
get pageList() {
|
||
return buildPageList(this.lastPage, this.page)
|
||
},
|
||
|
||
goPage(p: number) {
|
||
if (this.loading) return
|
||
if (p < 1 || p > this.lastPage || p === this.page) return
|
||
this.loadPage(p)
|
||
const main = this.$el.querySelector(".snapsmain")
|
||
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
|
||
else window.scrollTo({ top: 0, behavior: "smooth" })
|
||
},
|
||
prevPage() { this.goPage(this.page - 1) },
|
||
nextPage() { this.goPage(this.page + 1) },
|
||
|
||
toAbsUrl(url: string) { return toAbs(url) },
|
||
// 街拍详情与走秀统一收口 /item/[id],前端按编码前缀 r/s 分流(见后端 hashid.EncodeTyped)
|
||
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
|
||
|
||
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
|
||
titleParts(it: StreetSnapItem): string[] {
|
||
const parts = (it.title || "").split("\t")
|
||
return [parts[0] || it.title || "", parts.slice(1).join(" ").trim()]
|
||
},
|
||
cardTitle(it: StreetSnapItem): string {
|
||
return (it.title || "").replace(/\t/g, " · ")
|
||
},
|
||
|
||
// ===== 收藏(图片收藏入口)=====
|
||
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
|
||
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
|
||
async markFavs() {
|
||
const ids = this.items.map((i: any) => String(i.id))
|
||
if (!ids.length) return
|
||
// 本地即时填充(同设备秒显)
|
||
const m: Record<string, boolean> = { ...this.favMap }
|
||
for (const f of getFavorites()) {
|
||
const id = String(f.id)
|
||
if (!(id in m)) m[id] = true
|
||
}
|
||
this.favMap = m
|
||
if (!getUser()) return
|
||
try {
|
||
const set = await checkFavorited(ids)
|
||
const m2: Record<string, boolean> = { ...this.favMap }
|
||
for (const id of ids) m2[id] = set.has(id)
|
||
this.favMap = m2
|
||
} catch {
|
||
/* 失败维持本地态 */
|
||
}
|
||
},
|
||
favState(id: string) { return !!this.favMap[String(id)] },
|
||
toggleFav(it: any) {
|
||
const id = String(it.id)
|
||
const now = !this.favState(id)
|
||
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
|
||
this.favMap = { ...this.favMap, [id]: now }
|
||
return now
|
||
},
|
||
}
|
||
})
|
||
})
|
||
</script>
|