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,91 @@
1
+ import type React from 'react';
2
+ import { memo, useCallback, useMemo } from 'react';
3
+ import styles from './styles.module.css';
4
+
5
+ type Props = {
6
+ title: string;
7
+ date: string;
8
+ cover?: string;
9
+ coverCaption?: string;
10
+ location?: string;
11
+ albumSeries?: string;
12
+ onCoverClick?: () => void;
13
+ };
14
+
15
+ function AlbumHeroHeader({
16
+ title,
17
+ date,
18
+ cover,
19
+ coverCaption,
20
+ location,
21
+ albumSeries,
22
+ onCoverClick,
23
+ }: Props) {
24
+ const displayDate = useMemo(() => {
25
+ if (!date) {
26
+ return '';
27
+ }
28
+ const parsed = new Date(date);
29
+ if (!Number.isNaN(parsed.getTime())) {
30
+ return `${parsed.getUTCFullYear()}年 ${parsed.getUTCMonth() + 1}月 ${parsed.getUTCDate()}日`;
31
+ }
32
+ return date;
33
+ }, [date]);
34
+
35
+ const handleKeyDown = useCallback(
36
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
37
+ if (!onCoverClick) {
38
+ return;
39
+ }
40
+ if (e.key === 'Enter' || e.key === ' ') {
41
+ e.preventDefault();
42
+ onCoverClick();
43
+ }
44
+ },
45
+ [onCoverClick],
46
+ );
47
+
48
+ return (
49
+ <header className={styles.postHero}>
50
+ {cover && (
51
+ // biome-ignore lint/a11y/useSemanticElements: div container wraps block-level caption and cover image
52
+ <div
53
+ className={styles.heroImageContainer}
54
+ onClick={onCoverClick}
55
+ onKeyDown={handleKeyDown}
56
+ title="點擊檢視封面大圖"
57
+ role="button"
58
+ tabIndex={0}
59
+ >
60
+ <img
61
+ src={cover}
62
+ alt={title}
63
+ className={styles.heroCoverImage}
64
+ loading="eager"
65
+ fetchPriority="high"
66
+ decoding="async"
67
+ />
68
+ {coverCaption && <div className={styles.heroCaption}>{coverCaption}</div>}
69
+ </div>
70
+ )}
71
+
72
+ <div className={styles.heroMetaBody}>
73
+ <h1 className={styles.heroTitle}>{title}</h1>
74
+
75
+ <div className={styles.heroBadgesRow}>
76
+ {location && (
77
+ <span className={`${styles.heroBadge} ${styles.locationBadge}`}>📍 {location}</span>
78
+ )}
79
+ {albumSeries && (
80
+ <span className={`${styles.heroBadge} ${styles.seriesBadge}`}>📁 {albumSeries}</span>
81
+ )}
82
+ {displayDate && (
83
+ <span className={`${styles.heroBadge} ${styles.heroDateBadge}`}>📅 {displayDate}</span>
84
+ )}
85
+ </div>
86
+ </div>
87
+ </header>
88
+ );
89
+ }
90
+
91
+ export default memo(AlbumHeroHeader);
@@ -0,0 +1,107 @@
1
+ import { PageMetadata } from '@docusaurus/theme-common';
2
+ import Layout from '@theme/Layout';
3
+ import SearchMetadata from '@theme/SearchMetadata';
4
+ import { useCallback, useMemo, useRef, useState } from 'react';
5
+ import AlbumMasonryGrid, { type AlbumPostMeta, parseDate } from './AlbumMasonryGrid';
6
+ import styles from './styles.module.css';
7
+ import TimelineScrubber from './TimelineScrubber';
8
+
9
+ type Props = {
10
+ metadata: {
11
+ blogTitle: string;
12
+ blogDescription: string;
13
+ permalink: string;
14
+ [key: string]: unknown;
15
+ };
16
+ posts?: readonly AlbumPostMeta[];
17
+ items?: readonly {
18
+ content: {
19
+ metadata: AlbumPostMeta;
20
+ };
21
+ }[];
22
+ sidebar?: unknown;
23
+ };
24
+
25
+ const DEFAULT_TITLE = 'Album 相簿';
26
+ const DEFAULT_DESCRIPTION = '用快門記錄旅途風光與生活剪影,在光影與時間軸中重溫每一刻感動。';
27
+
28
+ export default function AlbumListPage(props: Props) {
29
+ const { metadata, posts: directPosts, items } = props;
30
+ const pageTitle = metadata.blogTitle || DEFAULT_TITLE;
31
+ const pageDescription = metadata.blogDescription || DEFAULT_DESCRIPTION;
32
+
33
+ const posts: AlbumPostMeta[] = useMemo(() => {
34
+ if (directPosts) {
35
+ return [...directPosts];
36
+ }
37
+ return (items ?? []).map(({ content }) => content.metadata);
38
+ }, [directPosts, items]);
39
+
40
+ // Extract unique years for TimelineScrubber in descending order
41
+ const years = useMemo(() => {
42
+ const yearSet = new Set<string>();
43
+ for (const post of posts) {
44
+ if (post.frontMatter?.cover) {
45
+ const year = post.year || parseDate(post).year;
46
+ if (year) {
47
+ yearSet.add(year);
48
+ }
49
+ }
50
+ }
51
+ return Array.from(yearSet).sort((a, b) => Number(b) - Number(a));
52
+ }, [posts]);
53
+
54
+ const [activeYear, setActiveYear] = useState<string>(() => years[0] || '2026');
55
+ const [activeMonth, setActiveMonth] = useState<string>('');
56
+ const [scrubberVisible, setScrubberVisible] = useState(false);
57
+ const scrollToYearRef = useRef<((year: string) => void) | null>(null);
58
+
59
+ const handleCurrentDateChange = useCallback((year: string, month: string) => {
60
+ setActiveYear(year);
61
+ setActiveMonth(month);
62
+ }, []);
63
+
64
+ const handleScrubberVisibleChange = useCallback((visible: boolean) => {
65
+ setScrubberVisible(visible);
66
+ }, []);
67
+
68
+ const handleSelectYear = useCallback((year: string) => {
69
+ if (scrollToYearRef.current) {
70
+ scrollToYearRef.current(year);
71
+ return;
72
+ }
73
+ const targetCard = document.querySelector<HTMLElement>(`[data-year="${year}"]`);
74
+ if (targetCard) {
75
+ targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
76
+ }
77
+ }, []);
78
+
79
+ return (
80
+ <Layout title={pageTitle} description={pageDescription}>
81
+ <PageMetadata title={pageTitle} description={pageDescription} />
82
+ <SearchMetadata tag="blog_posts_list" />
83
+
84
+ <main className={styles.albumContainer}>
85
+ <header className={styles.albumHeader}>
86
+ <h1 className={styles.albumTitle}>{pageTitle}</h1>
87
+ <p className={styles.albumDescription}>{pageDescription}</p>
88
+ </header>
89
+
90
+ <AlbumMasonryGrid
91
+ posts={posts}
92
+ onCurrentDateChange={handleCurrentDateChange}
93
+ onScrubberVisibleChange={handleScrubberVisibleChange}
94
+ scrollToYearRef={scrollToYearRef}
95
+ />
96
+
97
+ <TimelineScrubber
98
+ years={years}
99
+ activeYear={activeYear}
100
+ activeMonth={activeMonth}
101
+ visible={scrubberVisible}
102
+ onSelectYear={handleSelectYear}
103
+ />
104
+ </main>
105
+ </Layout>
106
+ );
107
+ }
@@ -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);