Files
frontend_v2/src/components/StreetSnaps.astro
toom1996 36f3ad039f
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-09-03 19:45:36 +08:00

480 lines
24 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 { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro)
// 登录门禁弹窗文案
const loginModalTitle = t('login')
const loginModalPrompt = t('login to continue')
const loginModalAccountLabel = t('username or email')
const loginModalPasswordLabel = t('password')
const loginModalSubmitLabel = t('login')
const loginModalClose = t('close')
const loginModalFail = t('login failed')
// 年份筛选:近 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} data-login-fail={loginModalFail}>
<!-- 顶部:极简眉头 -->
<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><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) + '"; requireLogin() && 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><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 + '"; requireLogin() && 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><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 + '"; requireLogin() && loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="snapsclear" type="button" @click="requireLogin() && 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 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="isAuthed && lastPage > 1" aria-label="Pagination">
<button 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 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 class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
<!-- 登录门禁弹窗:未登录点筛选/翻页时弹出内联登录表单 -->
<div x-show="showLoginModal" x-cloak class="fixed inset-0 z-[100] flex items-center justify-center bg-black/40" @click.self="closeLoginModal()">
<div class="bg-white border border-black/10 shadow-lg w-full max-w-md p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="font-serif text-2xl uppercase">{loginModalTitle}</h3>
<button type="button" @click="closeLoginModal()" class="text-xs uppercase tracking-widest hover:opacity-60">{loginModalClose}</button>
</div>
<p class="text-xs text-zinc-500 uppercase tracking-wide mb-6">{loginModalPrompt}</p>
<form @submit.prevent="modalLogin()" class="space-y-5">
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalAccountLabel}</label>
<input type="text" x-model="loginModalAccount" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<div>
<label class="block text-[11px] font-mono uppercase tracking-widest text-zinc-500 mb-2">{loginModalPasswordLabel}</label>
<input type="password" x-model="loginModalPassword" required class="w-full bg-transparent border-0 border-b border-black/30 rounded-none px-0 py-2 text-sm outline-none focus:border-black focus:ring-0" />
</div>
<p x-show="loginModalError" x-text="loginModalError" class="text-xs text-red-600 uppercase tracking-wide"></p>
<button type="submit" :disabled="loginModalLoading" class="w-full border border-black py-3 text-[12px] font-bold uppercase tracking-[0.2em] hover:bg-black hover:text-white transition-colors disabled:opacity-40">
<span x-show="!loginModalLoading">{loginModalSubmitLabel}</span>
<span x-show="loginModalLoading" x-cloak>…</span>
</button>
</form>
</div>
</div>
</div>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
<script>
import { getStreetSnaps, toAbs, getUser, login, 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,
isAuthed: false,
reqToken: 0,
// 登录门禁弹窗状态(硬门禁:未登录点筛选/翻页时弹出内联登录表单)
showLoginModal: false,
loginModalAccount: "",
loginModalPassword: "",
loginModalError: "",
loginModalLoading: false,
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
favMap: {} as Record<string, boolean>,
init() {
this.isAuthed = !!getUser()
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)
},
// ===== 登录门禁 =====
// 未登录返回 false 并弹出登录框;已登录返回 true。
requireLogin(): boolean {
if (getUser()) return true
this.showLoginModal = true
return false
},
async modalLogin() {
this.loginModalError = ""
this.loginModalLoading = true
try {
await login({ account: this.loginModalAccount, password: this.loginModalPassword })
this.showLoginModal = false
this.loginModalAccount = ""
this.loginModalPassword = ""
// 登录成功后按当前筛选刷新,用户无需再次点击
this.loadPage(1)
} catch {
this.loginModalError = this.$el.dataset.loginFail || "login failed"
} finally {
this.loginModalLoading = false
}
},
closeLoginModal() {
this.showLoginModal = false
this.loginModalError = ""
},
// 已激活筛选 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.requireLogin()) return
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) {
if (!getUser()) { this.showLoginModal = true; return false }
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>