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,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);
@@ -0,0 +1,47 @@
1
+ import clsx from 'clsx';
2
+ import { memo } from 'react';
3
+ import styles from './styles.module.css';
4
+
5
+ type Props = {
6
+ years: string[];
7
+ activeYear: string;
8
+ activeMonth: string;
9
+ visible: boolean;
10
+ onSelectYear: (year: string) => void;
11
+ };
12
+
13
+ function TimelineScrubber({ years, activeYear, activeMonth, visible, onSelectYear }: Props) {
14
+ if (years.length === 0) {
15
+ return null;
16
+ }
17
+
18
+ return (
19
+ <nav
20
+ className={clsx(styles.scrubberContainer, visible && styles.scrubberVisible)}
21
+ aria-label="相簿時間軸"
22
+ >
23
+ {/* Floating active date bubble */}
24
+ <div className={styles.timelineBubble} aria-live="polite">
25
+ {activeYear ? `${activeYear}年 ${activeMonth ? `${activeMonth}月` : ''}` : '探索相簿'}
26
+ </div>
27
+
28
+ {/* Rail with clickable year ticks */}
29
+ <div className={styles.timelineRail}>
30
+ {years.map((year) => (
31
+ <button
32
+ key={year}
33
+ type="button"
34
+ className={clsx(styles.yearTick, year === activeYear && styles.yearTickActive)}
35
+ onClick={() => onSelectYear(year)}
36
+ title={`跳轉至 ${year} 年`}
37
+ aria-label={`${year} 年`}
38
+ >
39
+ {year}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ </nav>
44
+ );
45
+ }
46
+
47
+ export default memo(TimelineScrubber);