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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kywk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# docusaurus-plugin-album
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pinterest-style masonry photo album plugin for [Docusaurus](https://docusaurus.io) with a Google Photos timeline scrubber, immersive hero header, and zero-dependency full-screen lightbox.
|
|
4
|
+
|
|
5
|
+
- **Pinterest Masonry Grid**: Responsive multi-column layout with hover overlays for titles, locations, and series badges.
|
|
6
|
+
- **Google Photos Timeline Scrubber**: Floating year/month indicator on scroll with one-click smooth jumping across years.
|
|
7
|
+
- **Immersive Post Hero Header**: Full-width cover image, caption, location badge (`π`), and album series badge (`π`).
|
|
8
|
+
- **Zero-Dependency Lightbox**: Keyboard navigation (`β` / `β` / `Escape`), background preload of adjacent photos, scroll locking, and automatic author avatar exclusion.
|
|
9
|
+
- **Drop-In Blog Wrapper**: Wraps `@docusaurus/plugin-content-blog` with build-time lazy image injection and lightweight route props for fast page loads even with hundreds of albums.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
| Dependency | Version |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Docusaurus | 3.8 or newer (tested with 3.10) |
|
|
16
|
+
| React | 18 or 19 (tested with 19.2) |
|
|
17
|
+
| Node.js | 20.19 or newer |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install docusaurus-plugin-album
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Register `docusaurus-plugin-album` in `docusaurus.config.ts`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// docusaurus.config.ts
|
|
31
|
+
import type { Config } from '@docusaurus/types';
|
|
32
|
+
import type { AlbumPluginOptions } from 'docusaurus-plugin-album';
|
|
33
|
+
|
|
34
|
+
const config: Config = {
|
|
35
|
+
// ...
|
|
36
|
+
plugins: [
|
|
37
|
+
[
|
|
38
|
+
'docusaurus-plugin-album',
|
|
39
|
+
{
|
|
40
|
+
id: 'album',
|
|
41
|
+
path: 'blog.album',
|
|
42
|
+
routeBasePath: 'album',
|
|
43
|
+
blogTitle: 'Photo Album',
|
|
44
|
+
blogDescription: 'Travel and photography archive.',
|
|
45
|
+
} satisfies AlbumPluginOptions,
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default config;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Album Post Frontmatter
|
|
54
|
+
|
|
55
|
+
Create Markdown files under your configured `path` (default: `blog.album/`):
|
|
56
|
+
|
|
57
|
+
```markdown
|
|
58
|
+
---
|
|
59
|
+
title: Autumn in Hokkaido Biei
|
|
60
|
+
date: 2025-09-05
|
|
61
|
+
cover: /img/album/biei-cover.jpg
|
|
62
|
+
cover_caption: Morning light over the Blue Pond in Biei
|
|
63
|
+
location: Biei, Hokkaido
|
|
64
|
+
album_series: 2025 Northern Autumn
|
|
65
|
+
tags:
|
|
66
|
+
- Japan
|
|
67
|
+
- Photography
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
Introductory notes for the album...
|
|
71
|
+
|
|
72
|
+
<!--truncate-->
|
|
73
|
+
|
|
74
|
+

|
|
75
|
+
|
|
76
|
+

|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Supported Frontmatter Fields
|
|
80
|
+
|
|
81
|
+
| Field | Type | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `title` | `string` | Album title |
|
|
84
|
+
| `date` | `string \| Date` | Album date (`YYYY-MM-DD`) |
|
|
85
|
+
| `cover` | `string` | Cover image URL. Posts without `cover` are hidden from the masonry index |
|
|
86
|
+
| `cover_caption` | `string` | Optional caption displayed over the hero cover image |
|
|
87
|
+
| `location` | `string` | Optional location badge (`π`) |
|
|
88
|
+
| `album_series` | `string` | Optional series/collection badge (`π`) |
|
|
89
|
+
| `tags` | `string[]` | Standard Docusaurus blog tags |
|
|
90
|
+
|
|
91
|
+
All standard [`@docusaurus/plugin-content-blog` options](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog) are supported.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm install
|
|
97
|
+
pnpm --filter docusaurus-plugin-album build
|
|
98
|
+
pnpm --filter docusaurus-plugin-album test
|
|
99
|
+
pnpm playground
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BlogContent, PluginOptions as BlogPluginOptions } from '@docusaurus/plugin-content-blog';
|
|
2
|
+
import type { LoadContext, OptionValidationContext, Plugin } from '@docusaurus/types';
|
|
3
|
+
import type { AlbumPluginOptions } from './types';
|
|
4
|
+
export type { AlbumFrontMatter, AlbumPluginOptions, AlbumPostMeta, LightboxImage } from './types';
|
|
5
|
+
type HastNode = {
|
|
6
|
+
type?: string;
|
|
7
|
+
tagName?: string;
|
|
8
|
+
properties?: Record<string, unknown>;
|
|
9
|
+
children?: HastNode[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Build-time Rehype plugin that injects `loading="lazy"` and `decoding="async"`
|
|
13
|
+
* into markdown body `<img>` elements so static HTML does not trigger eager
|
|
14
|
+
* preload requests for every photo in an album post.
|
|
15
|
+
*/
|
|
16
|
+
export declare function rehypeAlbumLazyImages(): (tree: HastNode) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Pre-parses year, month, and display label from permalink or post date.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parsePostDate(permalink: string, date: Date | string | undefined): {
|
|
21
|
+
year: string;
|
|
22
|
+
month: string;
|
|
23
|
+
displayDate: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function validateOptions({ validate, options, }: OptionValidationContext<AlbumPluginOptions | undefined, BlogPluginOptions>): BlogPluginOptions;
|
|
26
|
+
export default function pluginAlbum(context: LoadContext, options: BlogPluginOptions): Promise<Plugin<BlogContent>>;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.rehypeAlbumLazyImages = rehypeAlbumLazyImages;
|
|
40
|
+
exports.parsePostDate = parsePostDate;
|
|
41
|
+
exports.validateOptions = validateOptions;
|
|
42
|
+
exports.default = pluginAlbum;
|
|
43
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
44
|
+
const plugin_content_blog_1 = __importStar(require("@docusaurus/plugin-content-blog"));
|
|
45
|
+
const PERMALINK_DATE_REGEX = /\/(\d{4})\/(\d{2})\//;
|
|
46
|
+
/**
|
|
47
|
+
* Build-time Rehype plugin that injects `loading="lazy"` and `decoding="async"`
|
|
48
|
+
* into markdown body `<img>` elements so static HTML does not trigger eager
|
|
49
|
+
* preload requests for every photo in an album post.
|
|
50
|
+
*/
|
|
51
|
+
function rehypeAlbumLazyImages() {
|
|
52
|
+
function visitNode(node) {
|
|
53
|
+
if (!node || typeof node !== 'object') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (node.type === 'element' && node.tagName === 'img') {
|
|
57
|
+
node.properties = node.properties ?? {};
|
|
58
|
+
if (!node.properties.loading) {
|
|
59
|
+
node.properties.loading = 'lazy';
|
|
60
|
+
}
|
|
61
|
+
if (!node.properties.decoding) {
|
|
62
|
+
node.properties.decoding = 'async';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(node.children)) {
|
|
66
|
+
for (const child of node.children) {
|
|
67
|
+
visitNode(child);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (tree) => {
|
|
72
|
+
visitNode(tree);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pre-parses year, month, and display label from permalink or post date.
|
|
77
|
+
*/
|
|
78
|
+
function parsePostDate(permalink, date) {
|
|
79
|
+
if (typeof permalink === 'string') {
|
|
80
|
+
const permalinkMatch = permalink.match(PERMALINK_DATE_REGEX);
|
|
81
|
+
if (permalinkMatch) {
|
|
82
|
+
const year = permalinkMatch[1];
|
|
83
|
+
const month = String(Number(permalinkMatch[2]));
|
|
84
|
+
return { year, month, displayDate: `${year}εΉ΄ ${month}ζ` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (date) {
|
|
88
|
+
const parsed = date instanceof Date ? date : new Date(date);
|
|
89
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
90
|
+
const year = `${parsed.getUTCFullYear()}`;
|
|
91
|
+
const month = `${parsed.getUTCMonth() + 1}`;
|
|
92
|
+
return { year, month, displayDate: `${year}εΉ΄ ${month}ζ` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { year: '2026', month: '1', displayDate: '' };
|
|
96
|
+
}
|
|
97
|
+
function validateOptions({ validate, options = {}, }) {
|
|
98
|
+
const userRehypePlugins = Array.isArray(options.rehypePlugins) ? options.rehypePlugins : [];
|
|
99
|
+
const mergedOptions = {
|
|
100
|
+
id: 'album',
|
|
101
|
+
path: 'blog.album',
|
|
102
|
+
routeBasePath: 'album',
|
|
103
|
+
blogTitle: 'Album ηΈη°Ώ',
|
|
104
|
+
blogDescription: 'η¨εΏ«ιθ¨ιζ
ιι’¨ε
θηζ΄»εͺε½±οΌε¨ε
ε½±θζιθ»ΈδΈιζΊ«ζ―δΈε»ζεγ',
|
|
105
|
+
blogSidebarTitle: 'All albums',
|
|
106
|
+
blogSidebarCount: 'ALL',
|
|
107
|
+
postsPerPage: 'ALL',
|
|
108
|
+
blogListComponent: node_path_1.default.resolve(__dirname, 'theme/AlbumListPage'),
|
|
109
|
+
blogPostComponent: node_path_1.default.resolve(__dirname, 'theme/AlbumPostPage'),
|
|
110
|
+
onUntruncatedBlogPosts: 'ignore',
|
|
111
|
+
showReadingTime: true,
|
|
112
|
+
...options,
|
|
113
|
+
rehypePlugins: [rehypeAlbumLazyImages, ...userRehypePlugins],
|
|
114
|
+
};
|
|
115
|
+
return (0, plugin_content_blog_1.validateOptions)({ validate, options: mergedOptions });
|
|
116
|
+
}
|
|
117
|
+
async function pluginAlbum(context, options) {
|
|
118
|
+
const blogPluginInstance = await (0, plugin_content_blog_1.default)(context, options);
|
|
119
|
+
return {
|
|
120
|
+
...blogPluginInstance,
|
|
121
|
+
// Keep blogPluginInstance.name ('docusaurus-plugin-content-blog') because
|
|
122
|
+
// Docusaurus namespaces createData() by plugin.name and @docusaurus/plugin-content-blog
|
|
123
|
+
// hardcodes 'docusaurus-plugin-content-blog' in its MDX loader rules.
|
|
124
|
+
async contentLoaded({ content, actions }) {
|
|
125
|
+
const albumPosts = (content?.blogPosts ?? [])
|
|
126
|
+
.filter((post) => {
|
|
127
|
+
const fm = post?.metadata?.frontMatter;
|
|
128
|
+
return Boolean(fm?.cover);
|
|
129
|
+
})
|
|
130
|
+
.map((post) => {
|
|
131
|
+
const { permalink, title, date } = post.metadata;
|
|
132
|
+
const frontMatter = post.metadata.frontMatter;
|
|
133
|
+
const dateStr = date instanceof Date ? date.toISOString() : String(date);
|
|
134
|
+
const { year, month, displayDate } = parsePostDate(permalink, date);
|
|
135
|
+
return {
|
|
136
|
+
permalink,
|
|
137
|
+
title,
|
|
138
|
+
date: dateStr,
|
|
139
|
+
year,
|
|
140
|
+
month,
|
|
141
|
+
displayDate,
|
|
142
|
+
frontMatter: {
|
|
143
|
+
cover: frontMatter.cover,
|
|
144
|
+
cover_caption: frontMatter.cover_caption,
|
|
145
|
+
location: frontMatter.location,
|
|
146
|
+
album_series: frontMatter.album_series,
|
|
147
|
+
tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : undefined,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
const wrappedActions = {
|
|
152
|
+
...actions,
|
|
153
|
+
addRoute(routeConfig) {
|
|
154
|
+
const isAlbumListRoute = routeConfig.component === options.blogListComponent ||
|
|
155
|
+
(typeof routeConfig.component === 'string' &&
|
|
156
|
+
routeConfig.component.endsWith('AlbumListPage'));
|
|
157
|
+
if (isAlbumListRoute) {
|
|
158
|
+
actions.addRoute({
|
|
159
|
+
...routeConfig,
|
|
160
|
+
modules: {},
|
|
161
|
+
props: {
|
|
162
|
+
...routeConfig.props,
|
|
163
|
+
posts: albumPosts,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
actions.addRoute(routeConfig);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
if (blogPluginInstance.contentLoaded) {
|
|
172
|
+
await blogPluginInstance.contentLoaded({
|
|
173
|
+
content,
|
|
174
|
+
actions: wrappedActions,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
getThemePath() {
|
|
179
|
+
return node_path_1.default.resolve(__dirname, 'theme');
|
|
180
|
+
},
|
|
181
|
+
getTypeScriptThemePath() {
|
|
182
|
+
return node_path_1.default.resolve(__dirname, '../src/theme');
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
Object.assign(pluginAlbum, { validateOptions });
|
|
@@ -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
|
+
}
|