Files
frontend_v2/src/components/StreetSnaps.astro
toom1996 c85c561a99 update
2026-09-13 00:49:42 +08:00

404 lines
20 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 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>