611 lines
20 KiB
Vue
611 lines
20 KiB
Vue
<template>
|
||
<div class="gallery">
|
||
<!-- 标题区 -->
|
||
<div v-reveal="'up'" class="text-center mb-5 px-4">
|
||
<span class="gallery-title calligraphy text-xl text-[#a88c6b] tracking-[0.3em] font-bold block mb-2">{{ page.title }}</span>
|
||
<span class="gallery-subtitle text-[9px] text-[#8A8680] first-letter:uppercase lowercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
|
||
<span class="gallery-desc text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
|
||
</div>
|
||
|
||
<!-- 单图(默认居中卡幅 / 可配置宽度铺满) -->
|
||
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }"
|
||
class="w-full mx-auto mt-6" :class="singleWrapClass" :style="singleWrapStyle">
|
||
<div :class="singleFrameClass">
|
||
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错落双图 -->
|
||
<div v-else-if="page.type === 'overlap'" class="overlap-wrap relative w-full mt-2">
|
||
<div v-reveal="{ variant: 'left', delay: 100 }"
|
||
class="absolute left-0 top-0 w-[72%] h-[78%] z-10 -rotate-2" :class="frameClass">
|
||
<img :src="page.img1" class="preview-img" @click.stop="previewImage(page.img1)" />
|
||
</div>
|
||
<div v-reveal="{ variant: 'right', delay: 300 }"
|
||
class="absolute right-0 bottom-0 w-[56%] h-[62%] z-20 rotate-3" :class="frameClass">
|
||
<img :src="page.img2" class="preview-img" @click.stop="previewImage(page.img2)" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 一大五小:固定 6 槽占位 + 串行填内容(大图 decode 后再挂小图) -->
|
||
<div
|
||
v-else-if="page.type === 'one-large-five-small'"
|
||
ref="multiRootRef"
|
||
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20"
|
||
>
|
||
<div
|
||
class="col-span-2 row-span-2 relative olfs-slot"
|
||
:class="{ 'olfs-placeholder': shownCount < 1 }"
|
||
>
|
||
<div
|
||
v-if="shownCount >= 1"
|
||
v-reveal="{ variant: 'up', immediate: true }"
|
||
class="absolute inset-0"
|
||
:class="frameClass"
|
||
>
|
||
<img
|
||
ref="olfsLargeImgRef"
|
||
:src="page.images[0]"
|
||
class="preview-img olfs-img"
|
||
decoding="async"
|
||
@click.stop="previewImage(page.images[0])"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-for="i in [1, 2, 3, 4, 5]"
|
||
:key="i"
|
||
class="relative olfs-slot"
|
||
:class="{ 'olfs-placeholder': shownCount <= i }"
|
||
>
|
||
<div
|
||
v-if="shownCount > i"
|
||
v-reveal="{ variant: 'up', immediate: true }"
|
||
class="absolute inset-0"
|
||
:class="frameClass"
|
||
>
|
||
<img
|
||
:src="page.images[i]"
|
||
class="preview-img olfs-img"
|
||
decoding="async"
|
||
@click.stop="previewImage(page.images[i])"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 九宫格:串行挂载 + immediate(由父级 activate 触发;iOS 自观测) -->
|
||
<div v-else-if="page.type === 'nine-grid'" ref="multiRootRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
||
<div
|
||
v-for="(img, imgIdx) in (page.images || [])"
|
||
:key="imgIdx"
|
||
class="aspect-square relative nine-grid-slot"
|
||
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
|
||
>
|
||
<div
|
||
v-if="imgIdx < shownCount && nineGridAnimate"
|
||
v-reveal="getRevealConfig(imgIdx)"
|
||
class="absolute inset-0"
|
||
:class="frameClass"
|
||
>
|
||
<img
|
||
:src="img"
|
||
class="nine-grid-img preview-img"
|
||
decoding="async"
|
||
@click.stop="previewImage(img)"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-else-if="imgIdx < shownCount"
|
||
class="absolute inset-0"
|
||
:class="frameClass"
|
||
>
|
||
<img
|
||
:src="img"
|
||
class="nine-grid-img preview-img"
|
||
loading="eager"
|
||
decoding="async"
|
||
@click.stop="previewImage(img)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 瀑布流:串行挂载 + immediate -->
|
||
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
|
||
<div
|
||
v-for="(img, imgIdx) in (page.images || [])"
|
||
:key="imgIdx"
|
||
>
|
||
<div
|
||
v-if="imgIdx < shownCount"
|
||
v-reveal="{ variant: 'up', immediate: true }"
|
||
class="g-masonry-item"
|
||
:class="frameClass"
|
||
>
|
||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 轮播滑动 -->
|
||
<div v-else-if="page.type === 'carousel'" class="relative mt-6">
|
||
<div ref="trackRef" class="g-carousel-track no-scrollbar" @scroll="onScroll">
|
||
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" class="g-carousel-slide" :class="frameClass">
|
||
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
|
||
</div>
|
||
</div>
|
||
<button class="g-arrow g-prev" @click="goTo(index - 1)" aria-label="上一张">‹</button>
|
||
<button class="g-arrow g-next" @click="goTo(index + 1)" aria-label="下一张">›</button>
|
||
<div class="g-dots">
|
||
<button v-for="(img, imgIdx) in page.images" :key="imgIdx"
|
||
class="g-dot" :class="{ active: imgIdx === index }" @click="goTo(imgIdx)"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 拍立得:串行挂载 + immediate -->
|
||
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
|
||
<div
|
||
v-for="(img, imgIdx) in (page.images || [])"
|
||
:key="imgIdx"
|
||
>
|
||
<div
|
||
v-if="imgIdx < shownCount"
|
||
v-reveal="{ variant: 'scale', immediate: true }"
|
||
class="g-polaroid-item"
|
||
:style="{ transform: polaroidTransform(imgIdx) }"
|
||
>
|
||
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式:{{ page.type }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||
import { borderClass } from '@/composables/borderStyles'
|
||
import { createSequentialMount } from '@/composables/useSequentialMount'
|
||
|
||
const NINE_STEP_MS = 300
|
||
const NINE_SETTLE_MS = 400
|
||
const MULTI_STEP_MS = 220
|
||
const MULTI_SETTLE_MS = 300
|
||
const OLFS_STEP_MS = 300
|
||
const OLFS_SETTLE_MS = 320
|
||
const OLFS_DECODE_TIMEOUT_MS = 1200
|
||
|
||
/** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动
|
||
* iOS 性能足够,跳过停滚,这两种类型也走自观测(进视口自动挂载) */
|
||
const isIOSClient = (() => {
|
||
if (typeof navigator === 'undefined') return false
|
||
const ua = navigator.userAgent || ''
|
||
const platform = navigator.platform || ''
|
||
if (/iPhone|iPad|iPod/i.test(ua)) return true
|
||
if (platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) return true
|
||
return false
|
||
})()
|
||
const SEQUENTIAL_SELF_TYPES = new Set(
|
||
isIOSClient ? ['masonry', 'polaroid', 'nine-grid', 'one-large-five-small'] : ['masonry', 'polaroid']
|
||
)
|
||
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
|
||
|
||
const props = defineProps({
|
||
page: { type: Object, required: true },
|
||
/** 九宫格/一大五小:父级停滚后设为 true 才串行挂载 */
|
||
activate: { type: Boolean, default: false },
|
||
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
||
nineGridAnimate: { type: Boolean, default: false },
|
||
})
|
||
const emit = defineEmits(['preview-image', 'ready'])
|
||
|
||
const shownCount = ref(0)
|
||
const multiRootRef = ref(null)
|
||
const olfsLargeImgRef = ref(null)
|
||
let readyEmitted = false
|
||
let preloadStarted = false
|
||
let multiObserver = null
|
||
let multiStarted = false
|
||
let olfsToken = 0
|
||
|
||
const seq = createSequentialMount()
|
||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||
|
||
const waitImgReady = (img, timeoutMs) => {
|
||
if (!img) return Promise.resolve()
|
||
const decode = () => {
|
||
if (typeof img.decode === 'function') return img.decode().catch(() => {})
|
||
if (img.complete) return Promise.resolve()
|
||
return new Promise((resolve) => {
|
||
img.addEventListener('load', resolve, { once: true })
|
||
img.addEventListener('error', resolve, { once: true })
|
||
})
|
||
}
|
||
return Promise.race([
|
||
decode(),
|
||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||
])
|
||
}
|
||
|
||
const emitReady = () => {
|
||
if (readyEmitted) return
|
||
readyEmitted = true
|
||
emit('ready')
|
||
}
|
||
|
||
/** 关闭九宫格动画时提前暖缓存 */
|
||
const preloadNineGridImages = () => {
|
||
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
|
||
const urls = (props.page.images || []).filter(Boolean)
|
||
if (!urls.length) return
|
||
preloadStarted = true
|
||
for (const url of urls) {
|
||
const img = new Image()
|
||
img.decoding = 'async'
|
||
img.src = url
|
||
}
|
||
}
|
||
|
||
/** 注:曾尝试给安卓在 onMounted 阶段后台预热所有图,
|
||
* 实测反而更卡顿(下载集中在画廊接近视口时)。已移除。
|
||
* 若需预热,应只针对单张且错开更长时间间隔,且仅在用户即将到达时触发。 */
|
||
|
||
const runNineSequential = async () => {
|
||
const total = (props.page.images || []).length
|
||
console.log('[PG] runNineSequential start, total=', total)
|
||
const { cancelled } = await seq.run({
|
||
total,
|
||
stepMs: NINE_STEP_MS,
|
||
settleMs: NINE_SETTLE_MS,
|
||
setCount: (n) => { console.log('[PG] nine setCount', n); shownCount.value = n },
|
||
})
|
||
console.log('[PG] runNineSequential end, cancelled=', cancelled)
|
||
if (!cancelled) emitReady()
|
||
}
|
||
|
||
const showNineInstant = async () => {
|
||
console.log('[PG] showNineInstant, count=', (props.page.images || []).length)
|
||
shownCount.value = (props.page.images || []).length
|
||
await nextTick()
|
||
emitReady()
|
||
}
|
||
|
||
/** 一大五小:先大图并等 decode,再慢挂小图 */
|
||
const runOlfsSequential = async () => {
|
||
const token = ++olfsToken
|
||
const total = Math.min(6, (props.page.images || []).length)
|
||
console.log('[PG] runOlfsSequential start, total=', total, 'token=', token)
|
||
if (!total) {
|
||
emitReady()
|
||
return
|
||
}
|
||
|
||
shownCount.value = 0
|
||
await nextTick()
|
||
if (token !== olfsToken) { console.log('[PG] olfs cancelled at start'); return }
|
||
|
||
await sleep(OLFS_STEP_MS)
|
||
if (token !== olfsToken) { console.log('[PG] olfs cancelled before 1'); return }
|
||
shownCount.value = 1
|
||
await nextTick()
|
||
console.log('[PG] olfs shownCount=1, olfsLargeImgRef=', !!olfsLargeImgRef.value)
|
||
await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS)
|
||
if (token !== olfsToken) { console.log('[PG] olfs cancelled after large'); return }
|
||
|
||
for (let n = 2; n <= total; n += 1) {
|
||
await sleep(OLFS_STEP_MS)
|
||
if (token !== olfsToken) { console.log('[PG] olfs cancelled at n=', n); return }
|
||
shownCount.value = n
|
||
console.log('[PG] olfs shownCount=', n)
|
||
await nextTick()
|
||
}
|
||
|
||
if (token !== olfsToken) return
|
||
await sleep(OLFS_SETTLE_MS)
|
||
console.log('[PG] olfs done')
|
||
emitReady()
|
||
}
|
||
|
||
const runMultiSequential = async () => {
|
||
const type = props.page?.type
|
||
if (!SEQUENTIAL_SELF_TYPES.has(type) || multiStarted) return
|
||
multiStarted = true
|
||
const total = (props.page.images || []).length
|
||
await seq.run({
|
||
total,
|
||
stepMs: MULTI_STEP_MS,
|
||
settleMs: MULTI_SETTLE_MS,
|
||
setCount: (n) => { console.log('[PG] multi setCount', n, 'type=', type); shownCount.value = n },
|
||
})
|
||
}
|
||
|
||
const unbindMultiObserver = () => {
|
||
multiObserver?.disconnect()
|
||
multiObserver = null
|
||
}
|
||
|
||
/** 按 page.type 调度对应的串行挂载函数(iOS 上九宫格/一大五小也走此路径) */
|
||
const runSequentialByType = async () => {
|
||
if (multiStarted) {
|
||
console.log('[PG] runSequentialByType skip (multiStarted)')
|
||
return
|
||
}
|
||
const type = props.page?.type
|
||
console.log('[PG] runSequentialByType start', { type, nineGridAnimate: props.nineGridAnimate, imgCount: (props.page?.images || []).length })
|
||
if (type === 'nine-grid') {
|
||
if (props.nineGridAnimate) await runNineSequential()
|
||
else await showNineInstant()
|
||
return
|
||
}
|
||
if (type === 'one-large-five-small') {
|
||
await runOlfsSequential()
|
||
return
|
||
}
|
||
// masonry / polaroid
|
||
await runMultiSequential()
|
||
}
|
||
|
||
const bindMultiObserver = async () => {
|
||
unbindMultiObserver()
|
||
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
|
||
console.warn('[PG] bindMultiObserver skip, type not in set:', props.page?.type, 'isIOS:', isIOSClient)
|
||
return
|
||
}
|
||
await nextTick()
|
||
const el = multiRootRef.value
|
||
console.log('[PG] bindMultiObserver', { type: props.page?.type, hasEl: !!el, imgCount: (props.page?.images || []).length, isIOS: isIOSClient })
|
||
if (!el || typeof IntersectionObserver === 'undefined') {
|
||
if (!multiStarted) {
|
||
multiStarted = true
|
||
console.log('[PG] no el/IO → runSequentialByType')
|
||
runSequentialByType()
|
||
}
|
||
return
|
||
}
|
||
// iOS 上九宫格/一大五小:跳过观察器直接串行挂载(反正不停滚,等到进视口才挂反而突兀)
|
||
if (isIOSClient && (props.page?.type === 'nine-grid' || props.page?.type === 'one-large-five-small')) {
|
||
if (!multiStarted) {
|
||
multiStarted = true
|
||
console.log('[PG] iOS direct → runSequentialByType')
|
||
runSequentialByType()
|
||
}
|
||
return
|
||
}
|
||
// masonry/polaroid 仍用观察器:露出 30% 再串行,避免进屏前播完
|
||
multiObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
console.log('[PG] IO entry', { isIntersecting: entry.isIntersecting, ratio: entry.intersectionRatio, type: props.page?.type })
|
||
if (!entry.isIntersecting) continue
|
||
multiObserver?.disconnect()
|
||
multiObserver = null
|
||
multiStarted = true
|
||
console.log('[PG] IO triggered → runSequentialByType')
|
||
runSequentialByType()
|
||
break
|
||
}
|
||
},
|
||
{ threshold: [0, 0.1, 0.2, 0.3, 0.5], rootMargin: '0px 0px -10% 0px' }
|
||
)
|
||
multiObserver.observe(el)
|
||
console.log('[PG] observer bound')
|
||
}
|
||
|
||
watch(
|
||
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
|
||
async (on) => {
|
||
if (!on || readyEmitted) return
|
||
const type = props.page?.type
|
||
if (type === 'nine-grid') {
|
||
if (props.nineGridAnimate) await runNineSequential()
|
||
else await showNineInstant()
|
||
return
|
||
}
|
||
if (type === 'one-large-five-small') {
|
||
await runOlfsSequential()
|
||
}
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
|
||
watch(
|
||
() => [props.page?.type, props.page?.images],
|
||
async () => {
|
||
seq.cancel()
|
||
olfsToken += 1
|
||
unbindMultiObserver()
|
||
multiStarted = false
|
||
shownCount.value = 0
|
||
readyEmitted = false
|
||
if (props.page?.type === 'nine-grid') {
|
||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||
// iOS 自观测路径:仍需绑定观察器
|
||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
|
||
return
|
||
}
|
||
if (props.page?.type === 'one-large-five-small') {
|
||
// iOS 自观测路径:仍需绑定观察器
|
||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) await bindMultiObserver()
|
||
return
|
||
}
|
||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
|
||
await bindMultiObserver()
|
||
}
|
||
},
|
||
{ deep: true, flush: 'post' }
|
||
)
|
||
|
||
watch(
|
||
() => props.nineGridAnimate,
|
||
() => {
|
||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
console.log('[PG] onMounted', { type: props.page?.type, imgCount: (props.page?.images || []).length, isIOS: isIOSClient, inSelfSet: SEQUENTIAL_SELF_TYPES.has(props.page?.type) })
|
||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
seq.cancel()
|
||
olfsToken += 1
|
||
unbindMultiObserver()
|
||
})
|
||
|
||
function getRevealConfig(imgIdx) {
|
||
const row = Math.floor(imgIdx / 3)
|
||
const col = imgIdx % 3
|
||
|
||
if (imgIdx === 4) {
|
||
return { variant: 'scale', delay: 0, immediate: true }
|
||
}
|
||
if (col === 1) {
|
||
return {
|
||
variant: row === 0 ? 'down' : 'up',
|
||
delay: 0,
|
||
immediate: true,
|
||
}
|
||
}
|
||
return {
|
||
variant: col === 0 ? 'left' : 'right',
|
||
delay: 0,
|
||
immediate: true,
|
||
}
|
||
}
|
||
|
||
const singleWrapClass = computed(() =>
|
||
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
|
||
)
|
||
const singleWrapStyle = computed(() => {
|
||
if (props.page.singleWidth === 'full') {
|
||
const bleed = { width: 'calc(100% + 48px)', marginLeft: '-24px', marginRight: '-24px' }
|
||
return props.page.singleKeepRatio ? bleed : { ...bleed, aspectRatio: '4 / 5' }
|
||
}
|
||
return { aspectRatio: '4 / 5' }
|
||
})
|
||
const singleFrameClass = computed(() =>
|
||
frameClass.value + (props.page.singleWidth === 'full' && props.page.singleKeepRatio ? ' gf-natural' : '')
|
||
)
|
||
|
||
const index = ref(0)
|
||
const trackRef = ref(null)
|
||
|
||
function goTo(i) {
|
||
const len = props.page.images?.length || 0
|
||
if (!len) return
|
||
index.value = (i + len) % len
|
||
const el = trackRef.value
|
||
if (el) el.scrollTo({ left: index.value * el.clientWidth, behavior: 'smooth' })
|
||
}
|
||
function onScroll() {
|
||
const el = trackRef.value
|
||
if (!el) return
|
||
const i = Math.round(el.scrollLeft / el.clientWidth)
|
||
if (i !== index.value) index.value = i
|
||
}
|
||
|
||
function previewImage(src) {
|
||
if (src) emit('preview-image', src)
|
||
}
|
||
|
||
const polaroidAngles = [-5, 4, -3, 6, -4, 3, -2, 5]
|
||
const polaroidShift = [0, 10, -8, 6, -10, 8, 4, -6]
|
||
function polaroidTransform(i) {
|
||
const a = polaroidAngles[i % polaroidAngles.length]
|
||
const s = polaroidShift[i % polaroidShift.length]
|
||
return `rotate(${a}deg) translateY(${s}px)`
|
||
}
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.gallery { width: 100%; font-family: var(--font-sans); }
|
||
.preview-img { cursor: zoom-in; }
|
||
.gallery-title {
|
||
font-family: var(--font-calligraphy);
|
||
font-weight: 700;
|
||
}
|
||
.gallery-desc { font-family: var(--font-kai); }
|
||
|
||
.nine-grid-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.nine-grid-slot {
|
||
background: transparent;
|
||
contain: layout paint;
|
||
}
|
||
.nine-grid-placeholder {
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
.olfs-slot {
|
||
min-width: 0;
|
||
min-height: 0;
|
||
contain: layout paint;
|
||
}
|
||
.olfs-placeholder {
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
background: transparent !important;
|
||
}
|
||
.olfs-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.overlap-wrap {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
margin-inline: auto;
|
||
height: clamp(360px, 56vh, 520px);
|
||
padding: 12px 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.g-masonry { column-count: 2; column-gap: 10px; }
|
||
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }
|
||
|
||
.g-carousel-track {
|
||
display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
|
||
border-radius: 10px; scrollbar-width: none;
|
||
}
|
||
.g-carousel-slide { flex: 0 0 100%; min-width: 100%; height: 52vh; scroll-snap-align: center; }
|
||
.g-carousel-slide img { width: 100%; height: 100%; object-fit: cover; }
|
||
.g-arrow {
|
||
position: absolute; top: 50%; transform: translateY(-50%);
|
||
width: 34px; height: 34px; border-radius: 9999px; border: none;
|
||
background: rgba(255,255,255,0.78); color: #a88c6b; font-size: 20px; line-height: 1;
|
||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.12); backdrop-filter: blur(6px);
|
||
}
|
||
.g-prev { left: 8px; }
|
||
.g-next { right: 8px; }
|
||
.g-dots { display: flex; justify-content: center; gap: 6px; margin-top: 10px; }
|
||
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
|
||
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
|
||
|
||
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
|
||
.g-polaroid-item {
|
||
width: 42%; background: #fff; padding: 8px 8px 24px; border-radius: 2px;
|
||
box-shadow: 0 6px 16px rgba(0,0,0,0.12); transition: transform 0.4s ease;
|
||
}
|
||
.g-polaroid-item:hover { z-index: 10; }
|
||
.g-polaroid-item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; display: block; }
|
||
|
||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||
</style>
|