docusaurus-plugin-album 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ import Link from '@docusaurus/Link';
2
+ import type React from 'react';
3
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
4
+ import type { AlbumPostMeta } from '../types';
5
+ import styles from './styles.module.css';
6
+
7
+ export type { AlbumPostMeta } from '../types';
8
+
9
+ type Props = {
10
+ posts: AlbumPostMeta[];
11
+ onCurrentDateChange?: (year: string, month: string) => void;
12
+ onScrubberVisibleChange?: (visible: boolean) => void;
13
+ scrollToYearRef?: React.MutableRefObject<((year: string) => void) | null>;
14
+ };
15
+
16
+ const BATCH_SIZE = 12;
17
+
18
+ export function parseDate(post: AlbumPostMeta): {
19
+ year: string;
20
+ month: string;
21
+ displayDate: string;
22
+ } {
23
+ if (post.year && post.month) {
24
+ return {
25
+ year: post.year,
26
+ month: post.month,
27
+ displayDate: post.displayDate ?? `${post.year}年 ${post.month}月`,
28
+ };
29
+ }
30
+
31
+ const permalinkMatch = post.permalink.match(/\/(\d{4})\/(\d{2})\//);
32
+ if (permalinkMatch) {
33
+ const year = permalinkMatch[1];
34
+ const month = String(Number(permalinkMatch[2]));
35
+ return { year, month, displayDate: `${year}年 ${month}月` };
36
+ }
37
+
38
+ if (post.date) {
39
+ const parsed = new Date(post.date);
40
+ if (!Number.isNaN(parsed.getTime())) {
41
+ const year = `${parsed.getUTCFullYear()}`;
42
+ const month = `${parsed.getUTCMonth() + 1}`;
43
+ return { year, month, displayDate: `${year}年 ${month}月` };
44
+ }
45
+ }
46
+
47
+ return { year: '2026', month: '1', displayDate: '' };
48
+ }
49
+
50
+ function AlbumMasonryGrid({
51
+ posts,
52
+ onCurrentDateChange,
53
+ onScrubberVisibleChange,
54
+ scrollToYearRef,
55
+ }: Props) {
56
+ // 1. Filter out posts without cover and pre-normalize date metadata once
57
+ const validPosts = useMemo(() => {
58
+ return posts
59
+ .filter((p) => Boolean(p.frontMatter?.cover))
60
+ .map((p) => {
61
+ if (p.year && p.month && p.displayDate !== undefined) {
62
+ return p;
63
+ }
64
+ const { year, month, displayDate } = parseDate(p);
65
+ return { ...p, year, month, displayDate };
66
+ });
67
+ }, [posts]);
68
+
69
+ // 2. Infinite scroll state
70
+ const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
71
+ const sentinelRef = useRef<HTMLDivElement>(null);
72
+ const gridRef = useRef<HTMLDivElement>(null);
73
+ const pendingScrollYearRef = useRef<string | null>(null);
74
+
75
+ useEffect(() => {
76
+ const sentinel = sentinelRef.current;
77
+ if (!sentinel || visibleCount >= validPosts.length) {
78
+ return;
79
+ }
80
+
81
+ const observer = new IntersectionObserver(
82
+ (entries) => {
83
+ if (entries[0]?.isIntersecting) {
84
+ setVisibleCount((prev) => Math.min(prev + BATCH_SIZE, validPosts.length));
85
+ }
86
+ },
87
+ { rootMargin: '350px' },
88
+ );
89
+
90
+ observer.observe(sentinel);
91
+ return () => observer.disconnect();
92
+ }, [validPosts.length, visibleCount]);
93
+
94
+ // Handle year jump from TimelineScrubber (auto-expands visibleCount if target year is not yet rendered)
95
+ useEffect(() => {
96
+ if (!scrollToYearRef) {
97
+ return;
98
+ }
99
+
100
+ scrollToYearRef.current = (year: string) => {
101
+ const targetIdx = validPosts.findIndex((p) => p.year === year);
102
+ if (targetIdx === -1) {
103
+ return;
104
+ }
105
+
106
+ const targetPost = validPosts[targetIdx];
107
+ onCurrentDateChange?.(targetPost.year || year, targetPost.month || '');
108
+
109
+ if (targetIdx < visibleCount) {
110
+ const targetCard = gridRef.current?.querySelector<HTMLElement>(`[data-year="${year}"]`);
111
+ targetCard?.scrollIntoView({ behavior: 'smooth', block: 'center' });
112
+ } else {
113
+ pendingScrollYearRef.current = year;
114
+ setVisibleCount((prev) =>
115
+ Math.min(validPosts.length, Math.max(prev, targetIdx + BATCH_SIZE)),
116
+ );
117
+ }
118
+ };
119
+
120
+ return () => {
121
+ scrollToYearRef.current = null;
122
+ };
123
+ }, [validPosts, visibleCount, onCurrentDateChange, scrollToYearRef]);
124
+
125
+ useEffect(() => {
126
+ if (visibleCount === 0) {
127
+ return;
128
+ }
129
+ const pendingYear = pendingScrollYearRef.current;
130
+ if (!pendingYear) {
131
+ return;
132
+ }
133
+
134
+ const targetCard = gridRef.current?.querySelector<HTMLElement>(`[data-year="${pendingYear}"]`);
135
+ if (targetCard) {
136
+ pendingScrollYearRef.current = null;
137
+ requestAnimationFrame(() => {
138
+ targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
139
+ });
140
+ }
141
+ }, [visibleCount]);
142
+
143
+ // 3. Responsive column count calculation via matchMedia breakpoints
144
+ const [columnCount, setColumnCount] = useState(4);
145
+ useEffect(() => {
146
+ const mq1200 = window.matchMedia('(min-width: 1200px)');
147
+ const mq850 = window.matchMedia('(min-width: 850px)');
148
+ const mq560 = window.matchMedia('(min-width: 560px)');
149
+
150
+ const updateCols = () => {
151
+ if (mq1200.matches) {
152
+ setColumnCount(4);
153
+ } else if (mq850.matches) {
154
+ setColumnCount(3);
155
+ } else if (mq560.matches) {
156
+ setColumnCount(2);
157
+ } else {
158
+ setColumnCount(1);
159
+ }
160
+ };
161
+
162
+ updateCols();
163
+ mq1200.addEventListener('change', updateCols);
164
+ mq850.addEventListener('change', updateCols);
165
+ mq560.addEventListener('change', updateCols);
166
+
167
+ return () => {
168
+ mq1200.removeEventListener('change', updateCols);
169
+ mq850.removeEventListener('change', updateCols);
170
+ mq560.removeEventListener('change', updateCols);
171
+ };
172
+ }, []);
173
+
174
+ // 4. Distribute visible items into columns round-robin
175
+ const currentPosts = useMemo(() => {
176
+ return validPosts.slice(0, visibleCount);
177
+ }, [validPosts, visibleCount]);
178
+
179
+ const columns = useMemo(() => {
180
+ const cols: AlbumPostMeta[][] = Array.from({ length: columnCount }, () => []);
181
+ currentPosts.forEach((post, idx) => {
182
+ cols[idx % columnCount].push(post);
183
+ });
184
+ return cols;
185
+ }, [currentPosts, columnCount]);
186
+
187
+ // 5. Unified scroll listener using per-column O(log N) binary search
188
+ const lastReportedRef = useRef<{ year: string; month: string; visible: boolean }>({
189
+ year: '',
190
+ month: '',
191
+ visible: false,
192
+ });
193
+
194
+ useEffect(() => {
195
+ if (!onCurrentDateChange && !onScrubberVisibleChange) {
196
+ return;
197
+ }
198
+
199
+ let ticking = false;
200
+ const evaluateScroll = () => {
201
+ const isVisible = window.scrollY > 120;
202
+ if (onScrubberVisibleChange && isVisible !== lastReportedRef.current.visible) {
203
+ lastReportedRef.current.visible = isVisible;
204
+ onScrubberVisibleChange(isVisible);
205
+ }
206
+
207
+ if (onCurrentDateChange && gridRef.current && currentPosts.length > 0 && columnCount > 0) {
208
+ const colElements = gridRef.current.children;
209
+ const viewportCenter = window.innerHeight * 0.35;
210
+ let bestCard: HTMLElement | null = null;
211
+ let minDistance = Number.POSITIVE_INFINITY;
212
+
213
+ // Within each column, cards are ordered top-to-bottom monotonically, enabling binary search
214
+ for (let c = 0; c < colElements.length; c += 1) {
215
+ const cards = colElements[c].children;
216
+ let low = 0;
217
+ let high = cards.length - 1;
218
+
219
+ while (low <= high) {
220
+ const mid = (low + high) >> 1;
221
+ const card = cards[mid] as HTMLElement;
222
+ const top = card.getBoundingClientRect().top;
223
+ const distance = Math.abs(top - viewportCenter);
224
+
225
+ if (distance < minDistance) {
226
+ minDistance = distance;
227
+ bestCard = card;
228
+ }
229
+
230
+ if (top < viewportCenter) {
231
+ low = mid + 1;
232
+ } else {
233
+ high = mid - 1;
234
+ }
235
+ }
236
+ }
237
+
238
+ if (bestCard) {
239
+ const year = bestCard.dataset.year || '';
240
+ const month = bestCard.dataset.month || '';
241
+ if (
242
+ year &&
243
+ (year !== lastReportedRef.current.year || month !== lastReportedRef.current.month)
244
+ ) {
245
+ lastReportedRef.current.year = year;
246
+ lastReportedRef.current.month = month;
247
+ onCurrentDateChange(year, month);
248
+ }
249
+ }
250
+ }
251
+
252
+ ticking = false;
253
+ };
254
+
255
+ const handleScroll = () => {
256
+ if (!ticking) {
257
+ window.requestAnimationFrame(evaluateScroll);
258
+ ticking = true;
259
+ }
260
+ };
261
+
262
+ window.addEventListener('scroll', handleScroll, { passive: true });
263
+ evaluateScroll();
264
+
265
+ return () => window.removeEventListener('scroll', handleScroll);
266
+ }, [currentPosts.length, columnCount, onCurrentDateChange, onScrubberVisibleChange]);
267
+
268
+ if (validPosts.length === 0) {
269
+ return (
270
+ <div className={styles.endMessage}>
271
+ <p>目前尚無相簿文章,請新增帶有 cover 封面的相簿 Markdown 筆記。</p>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ return (
277
+ <div className={styles.masonryWrapper}>
278
+ <div ref={gridRef} className={styles.masonryGrid}>
279
+ {columns.map((colPosts, colIdx) => (
280
+ <div key={`col-${colPosts[0]?.permalink ?? colIdx}`} className={styles.masonryColumn}>
281
+ {colPosts.map((post, rowIdx) => {
282
+ const isAboveFold = rowIdx === 0;
283
+ return (
284
+ <Link
285
+ key={post.permalink}
286
+ to={post.permalink}
287
+ className={styles.albumCard}
288
+ data-album-card="true"
289
+ data-year={post.year}
290
+ data-month={post.month}
291
+ >
292
+ <div className={styles.cardImageWrapper}>
293
+ <img
294
+ src={post.frontMatter.cover}
295
+ alt={post.title}
296
+ className={styles.cardImage}
297
+ loading={isAboveFold ? 'eager' : 'lazy'}
298
+ fetchPriority={isAboveFold && colIdx === 0 ? 'high' : undefined}
299
+ decoding="async"
300
+ />
301
+
302
+ {/* Pinterest Style Hover Overlay */}
303
+ <div className={styles.cardOverlay}>
304
+ <div className={styles.cardOverlayContent}>
305
+ <h2 className={styles.cardTitle}>{post.title}</h2>
306
+
307
+ <div className={styles.cardMetaRow}>
308
+ {post.frontMatter.location && (
309
+ <span className={styles.pillBadge} title="拍攝地點">
310
+ 📍 {post.frontMatter.location}
311
+ </span>
312
+ )}
313
+ {post.frontMatter.album_series && (
314
+ <span
315
+ className={`${styles.pillBadge} ${styles.seriesBadge}`}
316
+ title="相簿系列"
317
+ >
318
+ 📁 {post.frontMatter.album_series}
319
+ </span>
320
+ )}
321
+ <span className={styles.cardDate}>{post.displayDate}</span>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </Link>
327
+ );
328
+ })}
329
+ </div>
330
+ ))}
331
+ </div>
332
+
333
+ {/* Infinite Scroll Sentinel */}
334
+ {visibleCount < validPosts.length && <div ref={sentinelRef} className={styles.sentinel} />}
335
+
336
+ {visibleCount >= validPosts.length && (
337
+ <div className={styles.endMessage}>
338
+ <span>✨ 已經瀏覽完所有相簿囉 ✨</span>
339
+ </div>
340
+ )}
341
+ </div>
342
+ );
343
+ }
344
+
345
+ export default memo(AlbumMasonryGrid);
@@ -0,0 +1,205 @@
1
+ import type { BlogSidebar } from '@docusaurus/plugin-content-blog';
2
+ import { BlogPostProvider, useBlogPost } from '@docusaurus/plugin-content-blog/client';
3
+ import { HtmlClassNameProvider, ThemeClassNames } from '@docusaurus/theme-common';
4
+ import BlogLayout from '@theme/BlogLayout';
5
+ import BlogPostItem from '@theme/BlogPostItem';
6
+ import type { Props } from '@theme/BlogPostPage';
7
+ import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
8
+ import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
9
+ import BlogPostPaginator from '@theme/BlogPostPaginator';
10
+ import ContentVisibility from '@theme/ContentVisibility';
11
+ import TOC from '@theme/TOC';
12
+ import clsx from 'clsx';
13
+ import type React from 'react';
14
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
15
+ import type { AlbumFrontMatter } from '../types';
16
+ import AlbumHeroHeader from './AlbumHeroHeader';
17
+ import ImageLightbox, { type LightboxImage } from './ImageLightbox';
18
+ import styles from './styles.module.css';
19
+
20
+ function AlbumPostPageContent({
21
+ sidebar,
22
+ children,
23
+ }: {
24
+ sidebar: BlogSidebar;
25
+ children: ReactNode;
26
+ }): ReactNode {
27
+ const { metadata, toc } = useBlogPost();
28
+ const { nextItem, prevItem, frontMatter, title, date, permalink } = metadata;
29
+ const {
30
+ hide_table_of_contents: hideTableOfContents,
31
+ toc_min_heading_level: tocMinHeadingLevel,
32
+ toc_max_heading_level: tocMaxHeadingLevel,
33
+ cover,
34
+ cover_caption: coverCaption,
35
+ location,
36
+ album_series: albumSeries,
37
+ } = frontMatter as AlbumFrontMatter;
38
+
39
+ // Lightbox state & image collection
40
+ const articleRef = useRef<HTMLDivElement>(null);
41
+ const [lightboxImages, setLightboxImages] = useState<LightboxImage[]>([]);
42
+ const [lightboxIndex, setLightboxIndex] = useState(0);
43
+ const [lightboxOpen, setLightboxOpen] = useState(false);
44
+
45
+ useEffect(() => {
46
+ if (!articleRef.current || !permalink) {
47
+ return;
48
+ }
49
+
50
+ const collectedImages: LightboxImage[] = [];
51
+
52
+ // 1. Add cover image first if available
53
+ if (cover) {
54
+ collectedImages.push({
55
+ src: cover,
56
+ alt: title,
57
+ caption: coverCaption,
58
+ });
59
+ }
60
+
61
+ // 2. Collect all images in the markdown body (strictly exclude author avatars and header elements)
62
+ const domImages = articleRef.current.querySelectorAll<HTMLImageElement>(
63
+ '.markdown img, [class*="markdown"] img',
64
+ );
65
+ const candidateImages =
66
+ domImages.length > 0
67
+ ? Array.from(domImages)
68
+ : Array.from(articleRef.current.querySelectorAll<HTMLImageElement>('img')).filter(
69
+ (img) =>
70
+ !img.closest('.avatar') &&
71
+ !img.classList.contains('avatar__photo') &&
72
+ !img.closest('header'),
73
+ );
74
+
75
+ for (const img of candidateImages) {
76
+ if (
77
+ img.closest('.avatar') ||
78
+ img.classList.contains('avatar__photo') ||
79
+ img.closest('header')
80
+ ) {
81
+ continue;
82
+ }
83
+
84
+ if (!img.getAttribute('loading')) {
85
+ img.loading = 'lazy';
86
+ }
87
+ if (!img.getAttribute('decoding')) {
88
+ img.decoding = 'async';
89
+ }
90
+
91
+ if (img.src !== cover) {
92
+ const imageIndex = collectedImages.length;
93
+ collectedImages.push({
94
+ src: img.src,
95
+ alt: img.alt,
96
+ caption: img.title || img.alt,
97
+ });
98
+ img.dataset.lightboxIndex = String(imageIndex);
99
+ } else {
100
+ img.dataset.lightboxIndex = '0';
101
+ }
102
+ }
103
+
104
+ setLightboxImages(collectedImages);
105
+ }, [cover, coverCaption, title, permalink]);
106
+
107
+ // Event delegation on article container instead of per-image onclick closures
108
+ const handleArticleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
109
+ const target = (e.target as HTMLElement | null)?.closest?.(
110
+ 'img[data-lightbox-index]',
111
+ ) as HTMLImageElement | null;
112
+ if (!target) {
113
+ return;
114
+ }
115
+
116
+ const idx = Number(target.dataset.lightboxIndex);
117
+ if (!Number.isNaN(idx)) {
118
+ setLightboxIndex(idx);
119
+ setLightboxOpen(true);
120
+ }
121
+ }, []);
122
+
123
+ const handleOpenCoverLightbox = useCallback(() => {
124
+ if (cover) {
125
+ setLightboxIndex(0);
126
+ setLightboxOpen(true);
127
+ }
128
+ }, [cover]);
129
+
130
+ const handleCloseLightbox = useCallback(() => {
131
+ setLightboxOpen(false);
132
+ }, []);
133
+
134
+ return (
135
+ <BlogLayout
136
+ sidebar={sidebar}
137
+ toc={
138
+ !hideTableOfContents && toc.length > 0 ? (
139
+ <TOC
140
+ toc={toc}
141
+ minHeadingLevel={tocMinHeadingLevel}
142
+ maxHeadingLevel={tocMaxHeadingLevel}
143
+ />
144
+ ) : undefined
145
+ }
146
+ >
147
+ <ContentVisibility metadata={metadata} />
148
+
149
+ {/* Album Hero Header */}
150
+ <AlbumHeroHeader
151
+ title={title}
152
+ date={date}
153
+ cover={cover}
154
+ coverCaption={coverCaption}
155
+ location={location}
156
+ albumSeries={albumSeries}
157
+ onCoverClick={handleOpenCoverLightbox}
158
+ />
159
+
160
+ {/* Main article content with lazy-loading and zoomable images */}
161
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated click handler for markdown images */}
162
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: delegated click handler for markdown images */}
163
+ <div
164
+ ref={articleRef}
165
+ className={clsx('album-post-article', styles.albumPostArticle)}
166
+ onClick={handleArticleClick}
167
+ >
168
+ <BlogPostItem>{children}</BlogPostItem>
169
+ </div>
170
+
171
+ {/* Paginator */}
172
+ {(nextItem || prevItem) && <BlogPostPaginator nextItem={nextItem} prevItem={prevItem} />}
173
+
174
+ {/* Full-screen Lightbox */}
175
+ <ImageLightbox
176
+ images={lightboxImages}
177
+ currentIndex={lightboxIndex}
178
+ isOpen={lightboxOpen}
179
+ onClose={handleCloseLightbox}
180
+ onIndexChange={setLightboxIndex}
181
+ />
182
+ </BlogLayout>
183
+ );
184
+ }
185
+
186
+ export default function AlbumPostPage(props: Props): ReactNode {
187
+ const BlogPostContent = props.content;
188
+ return (
189
+ <BlogPostProvider content={props.content} isBlogPostPage>
190
+ <HtmlClassNameProvider
191
+ className={clsx(
192
+ ThemeClassNames.wrapper.blogPages,
193
+ ThemeClassNames.page.blogPostPage,
194
+ 'album-post-page',
195
+ )}
196
+ >
197
+ <BlogPostPageMetadata />
198
+ <BlogPostPageStructuredData />
199
+ <AlbumPostPageContent sidebar={props.sidebar}>
200
+ <BlogPostContent />
201
+ </AlbumPostPageContent>
202
+ </HtmlClassNameProvider>
203
+ </BlogPostProvider>
204
+ );
205
+ }
@@ -0,0 +1,161 @@
1
+ import type React from 'react';
2
+ import { memo, useCallback, useEffect } from 'react';
3
+ import type { LightboxImage } from '../types';
4
+ import styles from './styles.module.css';
5
+
6
+ export type { LightboxImage } from '../types';
7
+
8
+ type Props = {
9
+ images: LightboxImage[];
10
+ currentIndex: number;
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ onIndexChange: (index: number) => void;
14
+ };
15
+
16
+ function ImageLightbox({ images, currentIndex, isOpen, onClose, onIndexChange }: Props) {
17
+ const total = images.length;
18
+ const currentImage = images[currentIndex];
19
+
20
+ const handlePrev = useCallback(
21
+ (e?: React.MouseEvent) => {
22
+ e?.stopPropagation();
23
+ if (total <= 1) {
24
+ return;
25
+ }
26
+ onIndexChange((currentIndex - 1 + total) % total);
27
+ },
28
+ [currentIndex, total, onIndexChange],
29
+ );
30
+
31
+ const handleNext = useCallback(
32
+ (e?: React.MouseEvent) => {
33
+ e?.stopPropagation();
34
+ if (total <= 1) {
35
+ return;
36
+ }
37
+ onIndexChange((currentIndex + 1) % total);
38
+ },
39
+ [currentIndex, total, onIndexChange],
40
+ );
41
+
42
+ // Preload adjacent images while lightbox is open for instant prev/next switching
43
+ useEffect(() => {
44
+ if (!isOpen || total <= 1) {
45
+ return;
46
+ }
47
+
48
+ const nextIdx = (currentIndex + 1) % total;
49
+ const prevIdx = (currentIndex - 1 + total) % total;
50
+ const preloadIndices = nextIdx === prevIdx ? [nextIdx] : [nextIdx, prevIdx];
51
+
52
+ for (const idx of preloadIndices) {
53
+ const targetSrc = images[idx]?.src;
54
+ if (targetSrc) {
55
+ const img = new Image();
56
+ img.decoding = 'async';
57
+ img.src = targetSrc;
58
+ }
59
+ }
60
+ }, [isOpen, currentIndex, total, images]);
61
+
62
+ // Keyboard navigation
63
+ useEffect(() => {
64
+ if (!isOpen) {
65
+ return;
66
+ }
67
+
68
+ const handleKeyDown = (e: KeyboardEvent) => {
69
+ if (e.key === 'Escape') {
70
+ onClose();
71
+ } else if (e.key === 'ArrowLeft') {
72
+ handlePrev();
73
+ } else if (e.key === 'ArrowRight') {
74
+ handleNext();
75
+ }
76
+ };
77
+
78
+ document.addEventListener('keydown', handleKeyDown);
79
+ const originalOverflow = document.body.style.overflow;
80
+ document.body.style.overflow = 'hidden';
81
+
82
+ return () => {
83
+ document.removeEventListener('keydown', handleKeyDown);
84
+ document.body.style.overflow = originalOverflow;
85
+ };
86
+ }, [isOpen, onClose, handlePrev, handleNext]);
87
+
88
+ if (!isOpen || !currentImage) {
89
+ return null;
90
+ }
91
+
92
+ return (
93
+ // biome-ignore lint/a11y/useKeyWithClickEvents: Escape key is handled globally on document in useEffect
94
+ <div
95
+ className={styles.lightboxOverlay}
96
+ onClick={onClose}
97
+ role="dialog"
98
+ aria-modal="true"
99
+ aria-label="相片檢視燈箱"
100
+ >
101
+ {/* Photo counter */}
102
+ {total > 1 && (
103
+ <div className={styles.lightboxCounter}>
104
+ {currentIndex + 1} / {total}
105
+ </div>
106
+ )}
107
+
108
+ {/* Close button */}
109
+ <button
110
+ type="button"
111
+ className={styles.lightboxCloseBtn}
112
+ onClick={onClose}
113
+ title="關閉 (ESC)"
114
+ aria-label="關閉燈箱"
115
+ >
116
+ ✕
117
+ </button>
118
+
119
+ {/* Prev / Next buttons */}
120
+ {total > 1 && (
121
+ <>
122
+ <button
123
+ type="button"
124
+ className={`${styles.lightboxNavBtn} ${styles.lightboxPrevBtn}`}
125
+ onClick={handlePrev}
126
+ title="上一張 (←)"
127
+ aria-label="上一張相片"
128
+ >
129
+ ‹
130
+ </button>
131
+ <button
132
+ type="button"
133
+ className={`${styles.lightboxNavBtn} ${styles.lightboxNextBtn}`}
134
+ onClick={handleNext}
135
+ title="下一張 (→)"
136
+ aria-label="下一張相片"
137
+ >
138
+ ›
139
+ </button>
140
+ </>
141
+ )}
142
+
143
+ {/* Main Image content */}
144
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation container only */}
145
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation container only */}
146
+ <div className={styles.lightboxContent} onClick={(e) => e.stopPropagation()}>
147
+ <img
148
+ src={currentImage.src}
149
+ alt={currentImage.alt || '相簿圖片'}
150
+ className={styles.lightboxImage}
151
+ decoding="async"
152
+ />
153
+ {(currentImage.caption || currentImage.alt) && (
154
+ <div className={styles.lightboxCaption}>{currentImage.caption || currentImage.alt}</div>
155
+ )}
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ export default memo(ImageLightbox);