629 lines
27 KiB
Plaintext
629 lines
27 KiB
Plaintext
---
|
||
import type { RawStreetSnap } from "@/lib/api"
|
||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||
import { getI18n } from "@/i18n/utils"
|
||
import { href, ROUTES } from '@/lib/routes'
|
||
import { ssrBase } from "@/lib/api"
|
||
|
||
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 full = (u: string): string => {
|
||
if (!u) return ""
|
||
if (/^https?:\/\//i.test(u)) return u
|
||
return ssrBase().replace(/\/$/, "") + u
|
||
}
|
||
|
||
const images = (snap?.images ?? [])
|
||
.map((im) => ({
|
||
id: im.id,
|
||
url: full(im.image || ""),
|
||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||
name: im.name || "",
|
||
}))
|
||
.filter((im) => im.url)
|
||
|
||
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
|
||
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 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">
|
||
<Breadcrumb>
|
||
<span slot="current" class="max-w-[60vw] truncate">{displayTitle}</span>
|
||
</Breadcrumb>
|
||
|
||
<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 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
|
||
<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>
|
||
)
|
||
}
|
||
|
||
{snap && previewImages.length > 0 && (
|
||
<div
|
||
x-show="lbOpen"
|
||
x-cloak
|
||
@keydown.window.escape="closeLb()"
|
||
@keydown.window.arrow-right="nextLb()"
|
||
@keydown.window.arrow-left="prevLb()"
|
||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||
aria-hidden="true"
|
||
>
|
||
<div
|
||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
|
||
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||
></div>
|
||
|
||
<button
|
||
login-required
|
||
type="button"
|
||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 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-[18px] h-[18px]" 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>
|
||
|
||
<button
|
||
type="button"
|
||
@click="closeLb()"
|
||
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 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('Close')}
|
||
>
|
||
✕
|
||
</button>
|
||
|
||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||
<div
|
||
id="lb-thumbs"
|
||
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
|
||
>
|
||
{previewImages.map((im, i) => (
|
||
<button
|
||
type="button"
|
||
data-lb-index={i}
|
||
data-thumb-url={im.url}
|
||
@click={`showLb(${i})`}
|
||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||
: ''"
|
||
aria-label={`${i + 1}`}
|
||
>
|
||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm">
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
</button>
|
||
))}
|
||
|
||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||
<button
|
||
type="button"
|
||
:data-lb-index="5 + i"
|
||
:data-thumb-url="im.url"
|
||
@click="showLb(5 + i)"
|
||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||
: ''"
|
||
:aria-label="String(5 + i + 1)"
|
||
>
|
||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click.stop="prevLb()"
|
||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||
aria-label={t('Previous image')}
|
||
>
|
||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6" /></svg>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
@click.stop="nextLb()"
|
||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||
aria-label={t('Next image')}
|
||
>
|
||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6" /></svg>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
@click.stop="prevLb()"
|
||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||
aria-label={t('Previous image')}
|
||
>
|
||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" /></svg>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
@click.stop="nextLb()"
|
||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||
aria-label={t('Next image')}
|
||
>
|
||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6 6-6" /></svg>
|
||
</button>
|
||
|
||
<div
|
||
id="lb-main"
|
||
@click.self="closeLb()"
|
||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||
x-bind:class="lbZoomed
|
||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||
: 'items-center'"
|
||
>
|
||
<img
|
||
id="lb-image"
|
||
src=""
|
||
alt=""
|
||
@click="toggleZoom()"
|
||
decoding="async"
|
||
class="block select-none transition-opacity duration-200"
|
||
x-bind:class="lbZoomed
|
||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
|
||
x-text="currentImageName"
|
||
></div>
|
||
|
||
<div
|
||
x-show="!lbZoomed"
|
||
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
||
>
|
||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<script>
|
||
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
|
||
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
|
||
|
||
document.addEventListener("alpine:init", () => {
|
||
Alpine.data("streetSnapView", () => ({
|
||
lbOpen: false,
|
||
lbIndex: 0,
|
||
lbZoomed: false,
|
||
imageCount: 0,
|
||
currentImageName: "",
|
||
|
||
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||
favIds: [],
|
||
favType: "snap_image",
|
||
galleryUid: "",
|
||
galleryTitle: "",
|
||
galleryBrand: "",
|
||
|
||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||
isAuthed: false,
|
||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||
preview: false,
|
||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
|
||
|
||
init() {
|
||
this.favType = this.$el.dataset.favType || "snap_image"
|
||
this.galleryUid = this.$el.dataset.galleryUid || ""
|
||
this.galleryTitle = this.$el.dataset.galleryTitle || ""
|
||
this.galleryBrand = this.$el.dataset.galleryBrand || ""
|
||
this.preview = this.$el.dataset.preview === "true"
|
||
this.isAuthed = !!getUser()
|
||
|
||
this.imageCount = document.querySelectorAll(
|
||
"#snap-gallery [data-image-index]"
|
||
).length
|
||
this.initThumbLazyLoading()
|
||
this.initImageFavs()
|
||
this.maybeLocateImageFromUrl()
|
||
|
||
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
|
||
if (getUser() && this.preview) this.loadFull()
|
||
// 登录后(如从门禁跳登录回跳)自动补全全量
|
||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
|
||
},
|
||
|
||
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
|
||
async loadFull() {
|
||
if (!this.preview) return
|
||
try {
|
||
const full = await getStreetSnapDetailAuthed(this.galleryUid)
|
||
if (!full || !Array.isArray(full.images)) return
|
||
const mapped = (full.images || []).slice(5).map((im: any) => ({
|
||
id: im.id || "",
|
||
url: toAbs(im.image || ""),
|
||
raw: im.image || "",
|
||
name: im.name || "",
|
||
}))
|
||
this.extraImages = mapped
|
||
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
|
||
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
|
||
await this.$nextTick()
|
||
this.imageCount = document.querySelectorAll(
|
||
"#snap-gallery [data-image-index]"
|
||
).length
|
||
this.initImageFavs()
|
||
this.initThumbLazyLoading()
|
||
this.maybeLocateImageFromUrl()
|
||
} catch {
|
||
/* 失败维持截断态,门禁仍在 */
|
||
}
|
||
},
|
||
|
||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||
maybeLocateImageFromUrl() {
|
||
if (typeof window === "undefined") return
|
||
const q = new URLSearchParams(window.location.search)
|
||
const want = q.get("img")
|
||
if (!want) return
|
||
const figures = Array.from(
|
||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||
)
|
||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||
if (idx < 0) return
|
||
const fig = figures[idx] as HTMLElement
|
||
requestAnimationFrame(() => {
|
||
if (q.get("lb") === "1") {
|
||
this.openLb(idx)
|
||
return
|
||
}
|
||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||
fig.classList.add("locate-flash")
|
||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||
})
|
||
},
|
||
|
||
getImageElement(index: number) {
|
||
return document.querySelector<HTMLElement>(
|
||
`#snap-gallery [data-image-index="${index}"]`
|
||
)
|
||
},
|
||
getImageUrl(index: number) {
|
||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||
},
|
||
getImageName(index: number) {
|
||
return this.getImageElement(index)?.dataset.imageName || ""
|
||
},
|
||
|
||
// ===== 单图收藏 =====
|
||
getImageFavId(index: number) {
|
||
return this.getImageElement(index)?.dataset.favId || ""
|
||
},
|
||
|
||
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
|
||
getImageFavUrl(index: number) {
|
||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||
},
|
||
|
||
allImageFavIds(): string[] {
|
||
return Array.from(
|
||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||
)
|
||
.map((el) => el.dataset.favId || "")
|
||
.filter(Boolean)
|
||
},
|
||
|
||
initImageFavs() {
|
||
const ids = this.allImageFavIds()
|
||
if (!ids.length) return
|
||
this.favIds = ids.filter((id) => isFavorited(id))
|
||
checkFavorited(ids).then((hit) => {
|
||
this.favIds = ids.filter((id) => hit.has(id))
|
||
})
|
||
},
|
||
|
||
isFav(id: string) {
|
||
return !!id && this.favIds.includes(id)
|
||
},
|
||
|
||
toggleFav(id: string, url: string) {
|
||
if (!id) return
|
||
toggleImageFavorite({
|
||
imageId: id,
|
||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||
imageUrl: url,
|
||
parentUid: this.galleryUid,
|
||
title: this.galleryTitle,
|
||
brand: this.galleryBrand,
|
||
})
|
||
this.favIds = this.isFav(id)
|
||
? this.favIds.filter((x: string) => x !== id)
|
||
: [...this.favIds, id]
|
||
},
|
||
|
||
get currentFavId() {
|
||
return this.getImageFavId(this.lbIndex)
|
||
},
|
||
|
||
get currentFavUrl() {
|
||
return this.getImageFavUrl(this.lbIndex)
|
||
},
|
||
|
||
openLb(index = 0) {
|
||
if (!this.imageCount) return
|
||
this.lbIndex = this.normalizeIndex(index)
|
||
this.lbZoomed = false
|
||
this.lbOpen = true
|
||
document.body.style.overflow = "hidden"
|
||
this.updateLightboxImage()
|
||
requestAnimationFrame(() => {
|
||
this.scrollThumbIntoView()
|
||
this.preloadAround()
|
||
})
|
||
},
|
||
closeLb() {
|
||
this.lbOpen = false
|
||
this.lbZoomed = false
|
||
document.body.style.overflow = ""
|
||
},
|
||
showLb(index: number) {
|
||
if (!this.imageCount) return
|
||
this.lbIndex = this.normalizeIndex(index)
|
||
this.lbZoomed = false
|
||
this.updateLightboxImage()
|
||
const main = document.getElementById("lb-main")
|
||
if (main) main.scrollTop = 0
|
||
requestAnimationFrame(() => {
|
||
this.scrollThumbIntoView()
|
||
this.preloadAround()
|
||
})
|
||
},
|
||
nextLb() { this.showLb(this.lbIndex + 1) },
|
||
prevLb() { this.showLb(this.lbIndex - 1) },
|
||
|
||
normalizeIndex(index: number) {
|
||
if (!this.imageCount) return 0
|
||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||
},
|
||
updateLightboxImage() {
|
||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||
if (!image) return
|
||
image.src = this.getImageUrl(this.lbIndex)
|
||
image.alt = this.getImageName(this.lbIndex)
|
||
this.currentImageName = this.getImageName(this.lbIndex)
|
||
},
|
||
toggleZoom() {
|
||
this.lbZoomed = !this.lbZoomed
|
||
const main = document.getElementById("lb-main")
|
||
if (main) main.scrollTop = 0
|
||
},
|
||
scrollThumbIntoView() {
|
||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||
if (!thumbs) return
|
||
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
|
||
if (!active) return
|
||
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
|
||
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
|
||
},
|
||
preloadAround() {
|
||
const indexes = [this.lbIndex, this.normalizeIndex(this.lbIndex - 1), this.normalizeIndex(this.lbIndex + 1)]
|
||
indexes.forEach((index: number) => {
|
||
const url = this.getImageUrl(index)
|
||
if (!url) return
|
||
const image = new Image()
|
||
image.decoding = "async"
|
||
image.src = url
|
||
})
|
||
},
|
||
initThumbLazyLoading() {
|
||
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
|
||
if (!thumbs.length) return
|
||
if (!("IntersectionObserver" in window)) {
|
||
thumbs.forEach((thumb) => { thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")` })
|
||
return
|
||
}
|
||
const observer = new IntersectionObserver(
|
||
(entries, obs) => {
|
||
entries.forEach((entry) => {
|
||
if (!entry.isIntersecting) return
|
||
const element = entry.target as HTMLElement
|
||
const url = element.dataset.thumbUrl
|
||
if (url) element.style.backgroundImage = `url("${url}")`
|
||
obs.unobserve(element)
|
||
})
|
||
},
|
||
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" }
|
||
)
|
||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||
},
|
||
}))
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
[x-cloak] { display: none !important; }
|
||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||
|
||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||
.fav-btn svg { fill: none; }
|
||
.fav-btn.is-fav svg { fill: currentColor; }
|
||
|
||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||
.locate-flash {
|
||
outline: 3px solid #111;
|
||
outline-offset: 4px;
|
||
}
|
||
|
||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||
@media (hover: none) {
|
||
.fav-btn { opacity: 1 !important; }
|
||
}
|
||
</style>
|