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.
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/lib/index.d.ts +26 -0
- package/lib/index.js +186 -0
- package/lib/theme/AlbumHeroHeader.tsx +91 -0
- package/lib/theme/AlbumListPage.tsx +107 -0
- package/lib/theme/AlbumMasonryGrid.tsx +345 -0
- package/lib/theme/AlbumPostPage.tsx +205 -0
- package/lib/theme/ImageLightbox.tsx +161 -0
- package/lib/theme/TimelineScrubber.tsx +47 -0
- package/lib/theme/styles.module.css +552 -0
- package/lib/types.d.ts +27 -0
- package/lib/types.js +2 -0
- package/package.json +73 -3
- package/src/theme/AlbumHeroHeader.tsx +91 -0
- package/src/theme/AlbumListPage.tsx +107 -0
- package/src/theme/AlbumMasonryGrid.tsx +345 -0
- package/src/theme/AlbumPostPage.tsx +205 -0
- package/src/theme/ImageLightbox.tsx +161 -0
- package/src/theme/TimelineScrubber.tsx +47 -0
- package/src/theme/styles.module.css +552 -0
|
@@ -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);
|