update
This commit is contained in:
225
src/components/StreetSnapCard.astro
Normal file
225
src/components/StreetSnapCard.astro
Normal file
@ -0,0 +1,225 @@
|
||||
---
|
||||
import type { RawStreetSnap } from "@/lib/api"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
snap: RawStreetSnap | null
|
||||
error?: "missing" | "failed" | null
|
||||
preview?: boolean // 后端未登录截断图片集时返回 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
|
||||
}
|
||||
|
||||
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const images = (snap?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
}))
|
||||
.filter((im) => im.url)
|
||||
|
||||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||||
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||
const PREVIEW_LIMIT = 5
|
||||
const previewImages = images.slice(0, PREVIEW_LIMIT)
|
||||
|
||||
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
|
||||
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
||||
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
||||
|
||||
const homeHref = href(locale, ROUTES.home)
|
||||
|
||||
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
|
||||
const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
---
|
||||
|
||||
<div
|
||||
id="street-snap-view"
|
||||
x-data="streetSnapView()"
|
||||
data-fav-type="snap_image"
|
||||
data-gallery-uid={snap?.id ?? ""}
|
||||
data-gallery-title={displayTitle}
|
||||
data-gallery-brand=""
|
||||
data-preview={preview ? "true" : "false"}
|
||||
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||
>
|
||||
{
|
||||
snap ? (
|
||||
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
|
||||
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
|
||||
{previewImages.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="snap-gallery"
|
||||
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{previewImages.map((im, i) => (
|
||||
<figure
|
||||
data-image-index={i}
|
||||
data-image-url={im.url}
|
||||
data-image-name={im.name}
|
||||
data-fav-id={im.id}
|
||||
data-fav-url={im.raw}
|
||||
@click={`openLb(${i})`}
|
||||
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||
>
|
||||
<img
|
||||
src={im.url}
|
||||
alt={im.name}
|
||||
loading={i < 6 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
|
||||
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
data-fav-id={im.id}
|
||||
data-fav-url={im.raw}
|
||||
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
|
||||
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" 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>
|
||||
|
||||
{im.name && (
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||
{im.name}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
) )}
|
||||
|
||||
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
|
||||
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
|
||||
{lockedCount > 0 &&
|
||||
Array.from({ length: lockedCount }).map(() => (
|
||||
<figure
|
||||
x-show="!isAuthed && preview"
|
||||
x-cloak
|
||||
login-redirect
|
||||
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
|
||||
>
|
||||
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
|
||||
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
|
||||
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
|
||||
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
|
||||
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<figure
|
||||
:data-image-index="5 + i"
|
||||
:data-image-url="im.url"
|
||||
:data-image-name="im.name"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click="openLb(5 + i)"
|
||||
class="relative bg-[#f2f2f2] cursor-zoom-in group"
|
||||
>
|
||||
<img
|
||||
:src="im.url"
|
||||
:alt="im.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
:data-fav-id="im.id"
|
||||
:data-fav-url="im.raw"
|
||||
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
|
||||
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" 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>
|
||||
<template x-if="im.name">
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
</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" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
|
||||
</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"
|
||||
>
|
||||
← {t('Back to Index')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
街拍无细节图分组,故不传 details 插槽。 */}
|
||||
{snap && previewImages.length > 0 && (
|
||||
<GalleryLightbox images={previewImages} aspect="3/4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
||||
import { createGalleryView } from "@/lib/gallery"
|
||||
|
||||
// 图集 + 大图灯箱的共享逻辑全在 lib/gallery.ts;街拍详情没有额外扩展,只补一个 init 调共享初始化。
|
||||
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 Item.astro 说明)。
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data(
|
||||
"streetSnapView",
|
||||
(): ReturnType<typeof createGalleryView> & { init(): void } => ({
|
||||
...createGalleryView({
|
||||
rootSelector: "#street-snap-view",
|
||||
gallerySelector: "#snap-gallery",
|
||||
favType: "snap_image",
|
||||
fetchDetail: getStreetSnapDetailAuthed,
|
||||
}),
|
||||
|
||||
init() {
|
||||
this.galleryInit()
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user