update
This commit is contained in:
348
src/components/StreetSnap.astro
Normal file
348
src/components/StreetSnap.astro
Normal file
@ -0,0 +1,348 @@
|
||||
---
|
||||
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
|
||||
}
|
||||
|
||||
const { snap, error = null } = 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 || ""),
|
||||
name: im.name || "",
|
||||
}))
|
||||
.filter((im) => im.url)
|
||||
|
||||
const homeHref = href(locale, ROUTES.home)
|
||||
const archiveHref = href(locale, ROUTES.streetSnaps)
|
||||
|
||||
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
|
||||
const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
---
|
||||
|
||||
<div
|
||||
id="street-snap-view"
|
||||
x-data="streetSnapView()"
|
||||
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>
|
||||
|
||||
{images.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="snap-gallery"
|
||||
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
<figure
|
||||
data-image-index={i}
|
||||
data-image-url={im.url}
|
||||
data-image-name={im.name}
|
||||
@click={`openLb(${i})`}
|
||||
class="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"
|
||||
/>
|
||||
|
||||
{im.name && (
|
||||
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||
{im.name}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
</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 && images.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
|
||||
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"
|
||||
>
|
||||
{images.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>
|
||||
))}
|
||||
</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>
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("streetSnapView", () => ({
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
|
||||
init() {
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#snap-gallery [data-image-index]"
|
||||
).length
|
||||
this.initThumbLazyLoading()
|
||||
},
|
||||
|
||||
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 || ""
|
||||
},
|
||||
|
||||
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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user