- 明暗切换:document 级委托(软导航换 DOM 后元素级监听全丢); E2E 再抓一坑:SVGElement 非 HTMLElement 实例,守卫用 Element 基类 - 图库:#lb 无条件 display:flex 覆盖 UA 隐藏 → 灯箱"永远打开"盖全页; 改 #lb:not([open]) 显式 none;时间戳标题清空;灯箱打磨(切换钮 实底阴影/n/21 计数器/空标题隐藏/原图链接暗底亮蓝) - 评论:site 'wait4x'→'wait4x.online' 对齐 Artalk 服务端(404 根因) - 书评字体:文楷→思源宋(@fontsource 预分片);文楷降级金句 --font-kai - 数据:21 张照片组修正 street→landscape(迁移器收到内部组 id 的坑) - 新增 tests/e2e.mjs:Playwright 30 项全功能 E2E 验证: node tests/e2e.mjs 30/30 PASS(含软导航后明暗切换回归项) Spec: 2026-10-25_fix-site-polish Co-authored-by: GLM-5.3
414 lines
14 KiB
Plaintext
414 lines
14 KiB
Plaintext
---
|
||
import { Image, getImage } from 'astro:assets';
|
||
import { getCollection } from 'astro:content';
|
||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||
import { t } from '../../i18n/ui';
|
||
import type { Locale } from '../../lib/locale';
|
||
import type { PhotoExif } from '../../lib/exif';
|
||
|
||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||
interface Props {
|
||
locale?: Locale;
|
||
}
|
||
const { locale = 'zh' } = Astro.props;
|
||
|
||
const photos = await getCollection('photos');
|
||
|
||
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
|
||
function formatExif(exif: PhotoExif | undefined): string {
|
||
if (!exif) return '';
|
||
const shutter =
|
||
exif.exposureTime === undefined
|
||
? undefined
|
||
: exif.exposureTime >= 1
|
||
? `${exif.exposureTime}s`
|
||
: `1/${Math.round(1 / exif.exposureTime)}s`;
|
||
return [
|
||
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
|
||
exif.lens,
|
||
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
|
||
shutter,
|
||
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
|
||
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
|
||
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
|
||
const ROW = 0.0092;
|
||
const GAP = 0.0162;
|
||
const spanFor = (w: number, h: number, cols: number) =>
|
||
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
|
||
|
||
const cards = await Promise.all(
|
||
photos.map(async (photo) => {
|
||
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
|
||
const w = photo.data.image.width ?? 4;
|
||
const h = photo.data.image.height ?? 3;
|
||
const full = await getImage({ src: photo.data.image, width: 1440 });
|
||
return {
|
||
group: photo.data.group,
|
||
title: photo.data.title,
|
||
alt: photo.data.alt ?? photo.data.title,
|
||
image: photo.data.image,
|
||
exif: formatExif(photo.data.exif),
|
||
fullSrc: full.src,
|
||
// 原图走仓外 store(构建时独立 rsync 到 /photos-originals/),git 不背 196M
|
||
original: `/photos-originals/${photo.id}.jpg`,
|
||
w,
|
||
h,
|
||
s3: spanFor(w, h, 3),
|
||
s2: spanFor(w, h, 2),
|
||
};
|
||
}),
|
||
);
|
||
const first = cards[0];
|
||
---
|
||
|
||
<BaseLayout title={`${t('gallery_title', locale)} · wait4x`}>
|
||
<h1 class="page-title">{t('gallery_title', locale)}</h1>
|
||
<div class="g-main">
|
||
<fieldset class="filters">
|
||
<legend class="sr-only">{t('gallery_filter_legend', locale)}</legend>
|
||
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
|
||
<label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label>
|
||
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
|
||
<label class="tap chip" for="f-street">{t('gallery_filter_street', locale)}</label>
|
||
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
|
||
<label class="tap chip" for="f-landscape">{t('gallery_filter_landscape', locale)}</label>
|
||
</fieldset>
|
||
|
||
<div class="stage">
|
||
<ul class="wall">
|
||
{cards.map((card) => (
|
||
<li
|
||
class="shot"
|
||
data-group={card.group}
|
||
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
class="shot-btn"
|
||
data-title={card.title}
|
||
data-exif={card.exif}
|
||
data-full={card.fullSrc}
|
||
data-original={card.original}
|
||
aria-label={t('gallery_view_aria', locale).replace('{title}', card.title) +
|
||
(card.exif ? t('gallery_view_aria_exif', locale).replace('{exif}', card.exif) : '')}
|
||
>
|
||
<Image
|
||
src={card.image}
|
||
alt={card.alt}
|
||
widths={[320, 560, 720]}
|
||
sizes="(min-width: 820px) 30vw, 46vw"
|
||
loading="lazy"
|
||
/>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<p class="g-note">{t('gallery_count', locale).replace('{n}', String(cards.length))}</p>
|
||
</div>
|
||
|
||
<dialog id="lb" aria-label={t('gallery_lightbox_label', locale)}>
|
||
<button id="lb-close" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_close', locale)}>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||
<path d="m5 5 14 14M19 5 5 19"></path>
|
||
</svg>
|
||
</button>
|
||
<div class="lb-figure">
|
||
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
|
||
</div>
|
||
<div class="lb-bottom">
|
||
<button id="lb-prev" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_prev', locale)}>
|
||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
|
||
</svg>
|
||
</button>
|
||
<div class="lb-meta">
|
||
<strong id="lb-title">{first?.title}</strong>
|
||
<span id="lb-exif">{first?.exif}</span>
|
||
<span id="lb-count">1 / {cards.length}</span>
|
||
<span class="lb-orig">
|
||
<a id="lb-original" href={first?.original} target="_blank" rel="noopener">{t('gallery_lightbox_original', locale)}</a>
|
||
<a id="lb-download" href={first?.original} download>{t('gallery_lightbox_download', locale)}</a>
|
||
</span>
|
||
</div>
|
||
<button id="lb-next" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_next', locale)}>
|
||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</dialog>
|
||
</BaseLayout>
|
||
|
||
<script>
|
||
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
|
||
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
|
||
document.addEventListener('astro:page-load', () => {
|
||
const lb = document.getElementById('lb') as HTMLDialogElement | null;
|
||
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
|
||
if (!lb || shots.length === 0) return;
|
||
|
||
const img = document.getElementById('lb-img') as HTMLImageElement;
|
||
const titleEl = document.getElementById('lb-title') as HTMLElement;
|
||
const exifEl = document.getElementById('lb-exif') as HTMLElement;
|
||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||
let idx = 0;
|
||
|
||
const show = (i: number) => {
|
||
idx = (i + shots.length) % shots.length;
|
||
const btn = shots[idx]!;
|
||
img.src = btn.dataset.full ?? '';
|
||
img.alt = btn.ariaLabel ?? '';
|
||
const title = btn.dataset.title ?? '';
|
||
titleEl.textContent = title;
|
||
titleEl.style.display = title ? '' : 'none';
|
||
exifEl.textContent = btn.dataset.exif ?? '';
|
||
exifEl.style.display = btn.dataset.exif ? '' : 'none';
|
||
const countEl = document.getElementById('lb-count');
|
||
if (countEl) countEl.textContent = `${idx + 1} / ${shots.length}`;
|
||
const orig = btn.dataset.original ?? '';
|
||
(document.getElementById('lb-original') as HTMLAnchorElement).href = orig;
|
||
const dl = document.getElementById('lb-download') as HTMLAnchorElement;
|
||
dl.href = orig;
|
||
dl.setAttribute('download', btn.dataset.title ?? 'photo');
|
||
if (!reduced.matches) {
|
||
img.style.opacity = '0';
|
||
requestAnimationFrame(() => {
|
||
img.style.opacity = '1';
|
||
});
|
||
}
|
||
if (!lb.open) lb.showModal();
|
||
};
|
||
|
||
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
|
||
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
|
||
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
|
||
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
|
||
lb.addEventListener('keydown', (e) => {
|
||
if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
show(idx - 1);
|
||
} else if (e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
show(idx + 1);
|
||
}
|
||
});
|
||
let x0: number | null = null;
|
||
lb.addEventListener('touchstart', (e) => {
|
||
x0 = e.touches[0]!.clientX;
|
||
}, { passive: true });
|
||
lb.addEventListener('touchend', (e) => {
|
||
if (x0 === null) return;
|
||
const dx = e.changedTouches[0]!.clientX - x0;
|
||
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
|
||
x0 = null;
|
||
}, { passive: true });
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
|
||
:global(main#main:has(.g-main)) {
|
||
max-width: calc(var(--measure) + 176px);
|
||
}
|
||
.page-title {
|
||
font-size: var(--text-h1);
|
||
line-height: var(--leading-heading);
|
||
color: var(--ink-1);
|
||
margin-top: var(--space-5);
|
||
text-wrap: balance;
|
||
}
|
||
|
||
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
|
||
.filters {
|
||
border: none;
|
||
padding: 0;
|
||
margin: var(--space-3) 0 var(--space-4);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-2);
|
||
}
|
||
.sr-only,
|
||
.f-input {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
margin: -1px;
|
||
clip-path: inset(50%);
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
.chip {
|
||
padding-inline: 13px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
color: var(--ink-3);
|
||
cursor: pointer;
|
||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||
}
|
||
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
|
||
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
|
||
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
|
||
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
|
||
|
||
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
|
||
.stage { container-type: inline-size; }
|
||
.wall {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
|
||
grid-auto-flow: dense;
|
||
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
|
||
}
|
||
@media (min-width: 820px) {
|
||
.wall { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
.shot {
|
||
grid-row-end: span var(--s2, 10);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
@media (min-width: 820px) {
|
||
.shot { grid-row-end: span var(--s3, 12); }
|
||
}
|
||
.shot-btn {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
cursor: zoom-in;
|
||
}
|
||
.shot-btn :global(img) {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.g-note {
|
||
margin-top: var(--space-3);
|
||
font-size: 12.5px;
|
||
color: var(--ink-4);
|
||
}
|
||
|
||
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
|
||
/* 关闭态显式 none:UA 对 dialog:not([open]) 的默认隐藏会被上面的
|
||
display:flex 覆盖——灯箱页面加载即全屏盖住图库的根因 */
|
||
#lb:not([open]) { display: none; }
|
||
#lb {
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100dvh;
|
||
max-width: none;
|
||
max-height: none;
|
||
margin: 0;
|
||
border: none;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0;
|
||
}
|
||
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
|
||
.lb-figure {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: var(--space-5) var(--space-3) var(--space-2);
|
||
}
|
||
#lb-img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
border-radius: 6px;
|
||
transition: opacity 160ms var(--ease);
|
||
}
|
||
.lb-bottom {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||
color: var(--code-ink);
|
||
font-family: var(--font-mono);
|
||
font-size: 12.5px;
|
||
}
|
||
.lb-orig {
|
||
display: inline-flex;
|
||
gap: var(--space-3);
|
||
margin-left: var(--space-2);
|
||
}
|
||
.lb-orig a {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: #9db4ff; /* 灯箱恒为暗底:用亮蓝替代主题 --accent(亮色态下对比不足) */
|
||
text-decoration: underline;
|
||
text-underline-offset: 0.2em;
|
||
}
|
||
.lb-meta {
|
||
flex: 1;
|
||
min-width: 0;
|
||
text-align: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.lb-meta strong {
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-family: var(--font-sans);
|
||
font-weight: 600;
|
||
}
|
||
.lb-meta span {
|
||
opacity: 0.82;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.lbnav {
|
||
flex: none;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||
background: rgba(20, 24, 30, 0.55);
|
||
color: #e6e9ee;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
|
||
#lb-close {
|
||
position: absolute;
|
||
top: var(--space-3);
|
||
right: var(--space-3);
|
||
}
|
||
@media (max-width: 560px) {
|
||
.lb-bottom { flex-wrap: wrap; }
|
||
.lb-orig {
|
||
display: inline-flex;
|
||
gap: var(--space-3);
|
||
margin-left: var(--space-2);
|
||
}
|
||
.lb-orig a {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: #9db4ff; /* 灯箱恒为暗底:用亮蓝替代主题 --accent(亮色态下对比不足) */
|
||
text-decoration: underline;
|
||
text-underline-offset: 0.2em;
|
||
}
|
||
.lb-meta { order: 3; flex-basis: 100%; }
|
||
}
|
||
</style>
|