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,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);
|