feat: add the gallery support. (#66)

This commit is contained in:
Yufan Sheng 2024-12-04 21:25:36 +08:00
parent 1a81de76d5
commit 8f1ba7136a
Signed by: syhily
GPG Key ID: DEB186763C308C31
37 changed files with 857 additions and 136 deletions

View File

@ -20,6 +20,7 @@
"ameho",
"amehochan",
"aplayer",
"arnowelzel",
"arrowup",
"artalk",
"astro",
@ -49,6 +50,7 @@
"eparams",
"firis",
"flexdinesh",
"flink",
"fmoran",
"fong",
"forencrypt",
@ -72,6 +74,7 @@
"jing",
"jmoiron",
"jungshik",
"junkfix",
"khalil",
"khtml",
"koanughi",

View File

@ -204,7 +204,7 @@ You should host it on your own machine.
## Short-Term TODO Checklist
- [ ] Add last modified time component for post.
- [ ] Add light box for images and album component.
- [ ] Slide share components integration.
- [ ] Check article grammar errors by using ChatGPT. Remain **42** posts.
- [ ] Add music to the articles. Remain **42** posts.
@ -213,7 +213,6 @@ You should host it on your own machine.
- [ ] Use self-developed comment solution.
- [ ] Support modification after commenting in 60 minutes even if you have refreshed the page.
- [ ] Support login into the blog for managing the comments.
- [ ] Slide share components integration.
- [ ] Add han.js support for better typography.
- [ ] Drop bootstrap, in favor of tailwind css.
@ -260,3 +259,9 @@ The source codes used from third party projects are:
- [images.ts](src/helpers/images.ts)
from [vercel/next.js](https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/image-blur-svg.ts)
with [license](licenses/LICENSE.vercel.txt)
- [photoswipe-auto-hide-ui.js](src/assets/scripts/photoswipe/photoswipe-auto-hide-ui.js)
from [arnowelzel/photoswipe-auto-hide-ui](https://github.com/arnowelzel/photoswipe-auto-hide-ui)
with [license](licenses/LICENSE.arnowelzel.txt)
- [photoswipe-slideshow.js](src/assets/scripts/photoswipe/photoswipe-slideshow.js)
from [junkfix/photoswipe-slideshow](https://github.com/junkfix/photoswipe-slideshow)
with [license](licenses/LICENSE.junkfix.txt)

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Arno Welzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 htmltiger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

16
package-lock.json generated
View File

@ -40,7 +40,8 @@
"bootstrap": "^5.3.3",
"photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"prettier": "^3.4.1",
"photoswipe-video-plugin": "^1.0.2",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-astro-organize-imports": "^0.4.11",
"prettier-plugin-organize-imports": "^4.1.0",
@ -6032,6 +6033,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/photoswipe-video-plugin": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/photoswipe-video-plugin/-/photoswipe-video-plugin-1.0.2.tgz",
"integrity": "sha512-skNHaalLU7rptZ3zq4XfS5hPqSDD65ctvpf2X8buvC8BpOt6XKSIgRkLzTwgQOUm9yQ8kQ4mMget7CIqGcqtDg==",
"dev": true,
"license": "ISC"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -6170,9 +6178,9 @@
}
},
"node_modules/prettier": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz",
"integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true,
"license": "MIT",
"bin": {

View File

@ -73,7 +73,8 @@
"bootstrap": "^5.3.3",
"photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"prettier": "^3.4.1",
"photoswipe-video-plugin": "^1.0.2",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-astro-organize-imports": "^0.4.11",
"prettier-plugin-organize-imports": "^4.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

View File

@ -0,0 +1,92 @@
/**
* PhotoSwipe Auto Hide UI plugin v1.0.1
*
* https://github.com/arnowelzel/photoswipe-auto-hide-ui
*/
const defaultOptions = {
idleTime: 4000,
};
class PhotoSwipeAutoHideUI {
constructor(lightbox, options) {
this.options = {
...defaultOptions,
...options,
};
this.captionTimer = false;
this.lightbox = lightbox;
this.hasTouch = false;
this.lightbox.on('change', () => {
document.addEventListener(
'touchstart',
() => {
this.stopHideTimer();
this.hasTouch = true;
},
{ once: true },
);
document.addEventListener(
'mousemove',
() => {
this.startHideTimer();
},
{ once: true },
);
});
this.lightbox.on('destroy', () => {
this.stopHideTimer();
});
}
showUI() {
if (this.lightbox?.pswp?.element) {
this.lightbox.pswp.element.classList.add('pswp--ui-visible');
}
}
hideUI() {
if (this.lightbox?.pswp?.element) {
this.lightbox.pswp.element.classList.remove('pswp--ui-visible');
}
}
mouseMove() {
this.stopHideTimer();
if (this.lightbox) {
this.showUI();
this.startHideTimer();
}
}
startHideTimer() {
if (this.hasTouch) {
return;
}
this.stopHideTimer();
this.captionTimer = window.setTimeout(() => {
this.hideUI();
}, this.options.idleTime);
document.addEventListener(
'mousemove',
() => {
this.mouseMove();
},
{ once: true },
);
}
stopHideTimer() {
if (this.captionTimer) {
window.clearTimeout(this.captionTimer);
this.captionTimer = false;
}
}
}
export default PhotoSwipeAutoHideUI;

View File

@ -0,0 +1,321 @@
/**
* Slideshow plugin for PhotoSwipe v5.
*
* https://github.com/htmltiger/photoswipe-slideshow
*/
class PhotoSwipeSlideshow {
constructor(lightbox, options) {
this.lightbox = lightbox;
this.options = Object.assign(
{
defaultDelayMs: 4000,
playPauseButtonOrder: 6,
progressBarPosition: 'top',
progressBarTransition: 'ease',
restartOnSlideChange: true,
autoHideProgressBar: true,
},
options,
);
// Use the stored slideshow length, if it's been saved to Local Storage.
// Otherwise, use the length specified by the caller, or fall back to the default value.
this.setSlideshowLength(Number(localStorage.getItem('pswp_delay')) || this.options.defaultDelayMs);
document.head.insertAdjacentHTML(
'beforeend',
`<style>.pswp__progress-bar{position:fixed;${this.options.progressBarPosition}:0;width:0;height:0}.pswp__progress-bar.running{width:100%;height:3px;transition-property:width;background:#c00}</style>`,
);
// Set default parameters.
this.slideshowIsRunning = false;
this.slideshowTimerID = null;
this.wakeLockIsRunning = false;
this.wakeLockSentinel = null;
// Set up lightbox and gallery event binds.
this.lightbox.on('init', () => {
this.init(this.lightbox.pswp);
});
}
// Set up event binds for the PhotoSwipe lightbox and gallery.
init(pswp) {
// Add UI elements to an open gallery.
pswp.on('uiRegister', () => {
// Add a button to the PhotoSwipe UI for toggling the slideshow state.
pswp.ui.registerElement({
name: 'playpause-button',
title: 'Toggle slideshow (Space)\nChange delay with +/- while running',
order: this.options.playPauseButtonOrder,
isButton: true,
html: '<svg aria-hidden="true" class="pswp__icn" viewBox="0 0 32 32"><use class="pswp__icn-shadow" xlink:href="#pswp__icn-play"/><use class="pswp__icn-shadow" xlink:href="#pswp__icn-stop"/><path id="pswp__icn-play" d="M9.5 6.4c-.7-.4-1.6-.4-2.3-0S6 7.5 6 8.2V23.9c0 .8.5 1.5 1.2 1.9s1.6.4 2.3-0l13.8-7.8a2.3 2.1 0 000-3.7z"/><path id="pswp__icn-stop" style="display:none" d="M6 9A3 3 90 019 6H23A3 3 90 0126 9V23a3 3 90 01-3 3H9A3 3 90 016 23z"/></svg>',
onClick: () => {
this.setSlideshowState();
},
});
// Add an element for the slideshow progress bar.
pswp.ui.registerElement({
name: 'playtime',
appendTo: 'wrapper', // add to PhotoSwipe's scroll viewport wrapper
tagName: 'div',
className: 'pswp__progress-bar',
});
// Add custom keyboard bindings, replacing the default bindings.
pswp.events.add(document, 'keydown', (e) => {
switch (e.code) {
case 'Space':
this.setSlideshowState();
e.preventDefault();
break;
case 'ArrowUp':
case 'NumpadAdd':
case 'Equal':
this.changeSlideshowLength(1000);
e.preventDefault();
break;
case 'ArrowDown':
case 'NumpadSubtract':
case 'Minus':
this.changeSlideshowLength(-1000);
e.preventDefault();
break;
}
});
});
// When slide is switched during the slideshow, optionally restart the slideshow.
this.lightbox.on('change', () => {
if (this.slideshowIsRunning && this.options.restartOnSlideChange) {
this.goToNextSlideAfterTimeout();
}
});
// Close the slideshow when closing PhotoSwipe.
this.lightbox.on('close', () => {
if (this.slideshowIsRunning) {
this.setSlideshowState();
}
});
}
// Toggle the slideshow state and switch the button's icon.
setSlideshowState() {
// Invert the slideshow state.
this.slideshowIsRunning = !this.slideshowIsRunning;
if (this.slideshowIsRunning) {
// Starting the slideshow: go to next slide after some wait time.
this.goToNextSlideAfterTimeout();
} else {
// Stopping the slideshow: reset the progress bar and timer.
this.resetSlideshow();
}
// Update button icon to reflect the slideshow state.
document.querySelector('#pswp__icn-stop').style.display = this.slideshowIsRunning ? 'inline' : 'none';
document.querySelector('#pswp__icn-play').style.display = this.slideshowIsRunning ? 'none' : 'inline';
// Optionally ensure the progress bar isn't hidden after some time of inactivity.
document.querySelector('.pswp__progress-bar').style.opacity = this.options.autoHideProgressBar ? null : 1;
// Prevent or allow the screen to turn off.
this.toggleWakeLock();
}
setSlideshowLength(newDelay) {
// The `setTimeout` function requires a 32-bit positive number, in milliseconds.
// But 1ms isn't useful for a slideshow, so use a reasonable minimum.
this.options.defaultDelayMs = Math.min(Math.max(newDelay, 1000), 2147483647); // 1 sec <= delay <= 24.85 days
// Save the slideshow length to Local Storage if one of the bounds has been reached.
// This survives page refreshes.
if (this.options.defaultDelayMs !== newDelay) {
localStorage.setItem('pswp_delay', this.options.defaultDelayMs);
}
}
changeSlideshowLength(delta) {
// Don't do anything if the slideshow isn't running.
if (!this.slideshowIsRunning) {
return;
}
// Update the slideshow length and save it to Local Storage.
this.setSlideshowLength(this.options.defaultDelayMs + delta);
localStorage.setItem('pswp_delay', this.options.defaultDelayMs);
// Show the current slideshow length.
const slideCounterElement = document.querySelector('.pswp__counter');
if (slideCounterElement) {
slideCounterElement.innerHTML = `${this.options.defaultDelayMs / 1000}s`;
}
// Restart the slideshow.
this.goToNextSlideAfterTimeout();
}
isVideoContent(content) {
return content?.data?.type === 'video';
}
// Calculate the time before going to the next slide.
getSlideTimeout() {
const slideContent = pswp.currSlide.content;
// Calculate remaining duration for videos.
if (this.isVideoContent(slideContent)) {
const videoElement = slideContent.element;
if (videoElement.paused) {
// Use default delay if video isn't playing.
return this.options.defaultDelayMs;
}
const durationSec = videoElement.duration;
const currentTimeSec = videoElement.currentTime;
if (Number.isNaN(durationSec) || Number.isNaN(currentTimeSec)) {
// Fall back to default delay if video hasn't been loaded yet.
return this.options.defaultDelayMs;
}
return (durationSec - currentTimeSec) * 1000;
}
// Use the default delay for images.
return this.options.defaultDelayMs;
}
slideContentHasLoaded() {
const slideContent = pswp.currSlide.content;
if (this.isVideoContent(slideContent)) {
// Ensure that video can be played smoothly:
// * Enough data has been downloaded for playback
// (https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState)
// * More data may still need to be downloaded
// (https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/networkState)
// This requires a network with a sufficiently high download rate.
const videoElement = slideContent.element;
return (
videoElement.ended ||
(videoElement.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA &&
[HTMLMediaElement.NETWORK_IDLE, HTMLMediaElement.NETWORK_LOADING].includes(videoElement.networkState))
);
}
// For images (or other media), use PhotoSwipe's LOAD_STATE.
return !slideContent.isLoading();
}
goToNextSlideAfterTimeout() {
// Reset the progress bar and timer.
this.resetSlideshow();
if (this.slideContentHasLoaded()) {
// Get timeout length, accounting for various media types.
const currentSlideTimeout = this.getSlideTimeout();
// Start the slideshow timer.
this.slideshowTimerID = setTimeout(() => {
pswp.next();
if (this.options.restartOnSlideChange) {
// The slideshow timer has been set by the `change` listener.
} else {
this.goToNextSlideAfterTimeout();
}
}, currentSlideTimeout);
// Show the progress bar.
// This needs a small delay so the browser has time to reset the progress bar.
setTimeout(() => {
if (this.slideshowIsRunning) {
this.toggleProgressBar(currentSlideTimeout);
}
}, 100);
} else {
// Wait for the media to load, without blocking the page.
this.slideshowTimerID = setTimeout(() => {
this.goToNextSlideAfterTimeout();
}, 200);
}
}
// https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function
getSlideTransition() {
if (this.isVideoContent(pswp.currSlide.content)) {
// Match the transition of a video player's seek bar.
return 'linear';
}
// Use the default animation.
return this.options.progressBarTransition;
}
toggleProgressBar(currentSlideTimeout) {
const slideshowProgressBarElement = document.querySelector('.pswp__progress-bar');
if (currentSlideTimeout) {
// Start slideshow
slideshowProgressBarElement.style.transitionTimingFunction = this.getSlideTransition();
slideshowProgressBarElement.style.transitionDuration = `${currentSlideTimeout}ms`;
slideshowProgressBarElement.classList.add('running');
} else {
// Stop slideshow
slideshowProgressBarElement.classList.remove('running');
}
}
toggleWakeLock() {
if (this.wakeLockIsRunning === this.slideshowIsRunning) {
return;
}
if ('keepAwake' in screen) {
// Use experimental API for older browsers.
// This is a simple boolean flag.
screen.keepAwake = this.slideshowIsRunning;
} else if ('wakeLock' in navigator) {
// Use the Screen Wake Lock API for newer browsers.
if (this.wakeLockSentinel) {
// Release screen wake lock, if a request was previously successful.
this.wakeLockSentinel.release().then(() => {
this.wakeLockSentinel = null;
});
} else {
// Request screen wake lock.
navigator.wakeLock
.request('screen')
.then((sentinel) => {
// Save the reference for the wake lock.
this.wakeLockSentinel = sentinel;
// Update our state if the wake lock happens to be released by the browser.
this.wakeLockSentinel.addEventListener('release', () => {
this.wakeLockSentinel = null;
this.wakeLockIsRunning = false;
});
})
.catch((e) => console.error(e));
}
}
this.wakeLockIsRunning = this.slideshowIsRunning;
}
// Stop the slideshow by resetting the progress bar and timer.
resetSlideshow() {
this.toggleProgressBar();
if (this.slideshowTimerID) {
clearTimeout(this.slideshowTimerID);
this.slideshowTimerID = null;
}
}
}
export default PhotoSwipeSlideshow;

View File

@ -2,11 +2,49 @@ import Aplayer from 'aplayer/dist/APlayer.min.js';
import { actions, isInputError } from 'astro:actions';
import PhotoSwipe from 'photoswipe';
import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin';
import PhotoSwipeVideo from 'photoswipe-video-plugin';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipeAutoHideUI from './photoswipe/photoswipe-auto-hide-ui.js';
import PhotoSwipeSlideshow from './photoswipe/photoswipe-slideshow.js';
import stickySidebar from './sticky-sidebar.js';
// Slideshow for Album.
for (const album of document.querySelectorAll('.post-content .album')) {
// Set up the main gallery lightbox.
const lightbox = new PhotoSwipeLightbox({
gallery: album,
pswpModule: PhotoSwipe,
children: 'a',
bgOpacity: 1,
});
// Add the dynamic description.
new PhotoSwipeDynamicCaption(lightbox, {
captionContent: '.pswp-caption-content',
});
// Add a slideshow to the PhotoSwipe gallery.
new PhotoSwipeSlideshow(lightbox, {
defaultDelayMs: 7000, // 7 seconds
restartOnSlideChange: true,
progressBarPosition: 'top',
autoHideProgressBar: false,
});
// Plugin to display video.
new PhotoSwipeVideo(lightbox, {});
// Hide the PhotoSwipe UI after some time of inactivity.
new PhotoSwipeAutoHideUI(lightbox, {});
lightbox.init();
}
// Lightbox support for post images.
const imageLinks = Array.from(document.querySelectorAll('.post-content a')).filter((link) => {
if (link.classList.contains('album-picture')) {
return false;
}
const img = link.querySelector('img');
return typeof img !== 'undefined' && img !== null;
});

View File

@ -3513,3 +3513,17 @@ a.index-menu-link:hover {
pointer-events: initial;
z-index: 500;
}
/* PhotoSwipe Slideshow */
.pswp__button--playpause-button {
position: fixed;
bottom: 0;
left: 0;
margin-left: 6px;
}
.pswp__progress-bar {
position: fixed;
bottom: 0;
top: auto !important;
}

View File

@ -0,0 +1,44 @@
---
import Image from '@/components/image/Image.astro';
import { formatLocalDate } from '@/helpers/formatter';
import { getAlbum } from '@/helpers/schema';
interface Props {
id: string;
}
const { id } = Astro.props;
const album = getAlbum(id);
---
{
album !== undefined && (
<Fragment>
{album.description && <Fragment set:html={album.description} />}
<div class="row g-2 g-md-3 g-xxl-4 list-grouped album">
{album.pictures.map((picture, index) => (
<div class={index === 0 ? 'col-12 col-md-8 col-xl-6' : 'col-6 col-md-4 col-xl-3'}>
<div class="list-item list-nice-overlay">
<div class={`media ${index === 0 ? 'media-36x17' : ''}`}>
<a
href={picture.src}
class="media-content album-picture"
data-pswp-width={picture.width}
data-pswp-height={picture.height}
>
<Image {...picture} alt={picture.title || ''} width={index === 0 ? 600 : 300} height={300} />
<div class="overlay" />
<div class="album-picture-meta pswp-caption-content d-none">
{picture.title && <h3>{picture.title}</h3>}
{picture.date && <p>{formatLocalDate(picture.date, 'yyyy-MM-dd HH:mm')}</p>}
{picture.description && <Fragment set:html={picture.description} />}
</div>
</a>
</div>
</div>
</div>
))}
</div>
</Fragment>
)
}

View File

@ -0,0 +1,27 @@
---
import Image from '@/components/image/UnstyledImage.astro';
import { getAlbum } from '@/helpers/schema';
interface Props {
id: string;
}
const { id } = Astro.props;
const album = getAlbum(id);
---
{
album !== undefined && (
<Fragment>
<h2>{album.title}</h2>
{album.description && <Fragment set:html={album.description} />}
{album.pictures.map((picture) => (
<Fragment>
{picture.title && <h3>{picture.title}</h3>}
<Image alt={picture.title || ''} {...picture} />
{picture.description && <Fragment set:html={picture.description} />}
</Fragment>
))}
</Fragment>
)
}

View File

@ -1,13 +1,11 @@
import type { Comment, CommentItem, CommentReq, CommentResp, Comments, ErrorResp } from '@/components/comment/types';
import { queryUser } from '@/helpers/db/query';
import { parseContent } from '@/helpers/markdown';
import { urlJoin } from '@/helpers/tools';
import options from '@/options';
import { ARTALK_HOST, ARTALK_PORT, ARTALK_SCHEME } from 'astro:env/server';
import _ from 'lodash';
import { marked } from 'marked';
import querystring from 'node:querystring';
import { ELEMENT_NODE, transform, walk } from 'ultrahtml';
import sanitize from 'ultrahtml/transformers/sanitize';
// Access the artalk in internal docker host when it was deployed on zeabur.
const server = `${ARTALK_SCHEME}://${ARTALK_HOST}:${ARTALK_PORT}`;
@ -136,63 +134,3 @@ const commentItems = (comment: Comment, childComments: _.Dictionary<Comment[]>):
return { ...comment, children: children.map((child) => commentItems(child, childComments)) };
};
const parseContent = async (content: string): Promise<string> => {
// Support paragraph in blank line.
const escapedContent = content.replace(/\r\n/g, '\n').replace(/(?<!\n)\n(?!\n)/g, '<br />');
const parsed = await marked.parse(escapedContent);
// Avoid the XSS attack.
return transform(parsed, [
async (node) => {
await walk(node, (node) => {
if (node.type === ELEMENT_NODE) {
if (node.name === 'a' && !node.attributes.href?.startsWith('https://yufan.me')) {
node.attributes.target = '_blank';
node.attributes.rel = 'nofollow';
}
}
});
return node;
},
sanitize({
allowElements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'a',
'img',
'span',
'strong',
'code',
'pre',
'blockquote',
'del',
'i',
'u',
'sup',
'sub',
'em',
'b',
'font',
'hr',
'br',
'ul',
'ol',
'li',
],
allowAttributes: {
src: ['img'],
width: ['img'],
height: ['img'],
rel: ['a'],
target: ['a'],
},
allowComments: false,
}),
]);
};

View File

@ -1,4 +1,5 @@
---
import UnstyledAlbum from '@/components/album/UnstyledAlbum.astro';
import UnstyledImage from '@/components/image/UnstyledImage.astro';
import UnstyledMusicPlayer from '@/components/player/UnstyledMusicPlayer.astro';
import { posts } from '@/helpers/schema';
@ -16,4 +17,4 @@ if (!post) {
const { Content } = await post.render();
---
<Content components={{ MusicPlayer: UnstyledMusicPlayer, Image: UnstyledImage }} />
<Content components={{ MusicPlayer: UnstyledMusicPlayer, Image: UnstyledImage, Album: UnstyledAlbum }} />

View File

@ -65,6 +65,26 @@ const imagesCollection = defineCollection({
}),
});
// Albums Collection
const albumsCollection = defineCollection({
loader: glob({ pattern: '**\/[^_]*.yml', base: './src/content/albums' }),
schema: z.object({
slug: slug(),
title: z.string().max(99),
date: z.date(),
description: z.string().optional().describe('In markdown format'),
cover: image(defaultCover),
pictures: z
.object({
src: z.string(),
title: z.string().optional(),
description: z.string().optional().describe('In markdown format'),
date: z.date(),
})
.array(),
}),
});
// Categories Collection
const categoriesCollection = defineCollection({
loader: file('./src/content/metas/categories.yml'),
@ -72,7 +92,7 @@ const categoriesCollection = defineCollection({
name: z.string().max(20),
slug: slug(),
cover: image(defaultCover),
description: z.string().max(999).optional().default(''),
description: z.string().max(999).optional().default('').describe('In markdown format'),
}),
});
@ -81,7 +101,7 @@ const friendsCollection = defineCollection({
loader: file('./src/content/metas/friends.yml'),
schema: z.object({
website: z.string().max(40),
description: z.string().optional(),
description: z.string().optional().describe('One line string'),
homepage: z.string().url(),
poster: z
.string()
@ -106,8 +126,8 @@ const postsCollection = defineCollection({
date: z.date(),
updated: z.date().optional(),
comments: z.boolean().optional().default(true),
alias: z.array(z.string()).optional().default([]),
tags: z.array(z.string()).optional().default([]),
alias: z.string().array().optional().default([]),
tags: z.string().array().optional().default([]),
category: z.string(),
summary: z.string().optional().default(''),
cover: image(defaultCover),
@ -134,6 +154,7 @@ const pagesCollection = defineCollection({
export const collections = {
images: imagesCollection,
albums: albumsCollection,
categories: categoriesCollection,
friends: friendsCollection,
tags: tagsCollection,

View File

@ -0,0 +1,58 @@
slug: flink-forward-asia-2021
title: Flink Forward Asia 2021 录影之行
date: 2021-12-10 15:52:33
description: |-
2021 年对我来说是个极其不寻常的一年12 月更是最为重要的一个月。这个月里,我的女儿桃桃出生,同时有幸前往北京参与 Flink Forward Asia 2021 线上大会的视频录制。
因为疫情的原因,原来计划年底在杭州举办的 Flink Forward Asia 2021 大会只能被迫改为线上举办。当时我原来计划在大会上讲刚刚合并到 Flink 主分支的 `flink-connector-pulsar` 相关内容,可是还没交 PPT 稿件,女儿就早于预产期提前问世。这一拖再拖,最后只能选择去北京赶视频录制的尾巴。
此次北京之行,既是我离开北京 5 年后的第一次回去,我依旧走过了熟悉的呼家楼地铁站。也是我第一次去新的大兴国际机场。新机场离北京很远,很远。到机场后手机直接收到了河北的短信。因为新冠疫情的缘故,机场空荡荡的,没有什么人。这也给了我机会,让我更加细致地观察这项世纪工程。
cover: /images/albums/flink-forward-asia-2021/IMG_4321.jpg
pictures:
- src: /images/albums/flink-forward-asia-2021/IMG_4321.jpg
title: Flink Forward 2021 录制 - 场地
description: |-
录制的地方是在东五环的一个偏僻角落,坐了半个小时的地铁才到。应该是临时拿仓库搭建的录影棚。此时十二月的北京很冷,在屋内哈口气都能看到白雾。为了保证上镜的效果,我们都是穿着 Flink Forward Asia 的大衣,里面贴满了暖宝宝录制的。
录制的时候我们看不到 PPT也看不到观众。所以都提前写好词面无表情地读稿。😆
date: 2021-12-10 13:52:33
- src: /images/albums/flink-forward-asia-2021/IMG_4322.jpg
title: Flink Forward 2021 录制 - 讲师读稿
description: 此时是我前面一位小伙伴在录制,他讲的课题现在想不起来的。只记得他讲了好久好久,久到我的脚都冻得麻木,又出去走了两圈,身子还没暖起来。
date: 2021-12-10 14:13:33
- src: /images/albums/flink-forward-asia-2021/IMG_4325.jpg
title: Flink Forward 2021 录制 - 提词器效果
date: 2021-12-10 14:46:33
- src: /images/albums/flink-forward-asia-2021/IMG_4363.jpg
title: 大兴机场(一)
date: 2021-12-10 17:16:11
- src: /images/albums/flink-forward-asia-2021/IMG_4364.jpg
title: 大兴机场(二)
date: 2021-12-10 17:18:23
- src: /images/albums/flink-forward-asia-2021/IMG_4365.jpg
title: 大兴机场(三)
date: 2021-12-10 17:25:47
- src: /images/albums/flink-forward-asia-2021/IMG_4366.jpg
title: 大兴机场 - 庆丰包子铺
description: 拍摄此张照片的时候,某位应该还在位上。
date: 2021-12-10 17:32:32
- src: /images/albums/flink-forward-asia-2021/IMG_4367.jpg
title: 大兴机场 - 登机口
description: |-
大兴机场的登机口设计和传统的中国机场登机口设计有所不同。它是同层出入的,也就是登机和下飞机都从这同一个门进出到同一层,而非以前的走过廊桥后,会到错开走到楼下那层离港。
拍摄此照片时,一开始我以为我看错了,后面专门去搜索,才发现是一种提高客流量大设计。可惜此时疫情的大兴机场没有人,无法看到流量高峰时期的胜景。
date: 2021-12-10 17:30:47
- src: /images/albums/flink-forward-asia-2021/IMG_4368.jpg
title: 大兴机场(四)
date: 2021-12-10 17:43:47
- src: /images/albums/flink-forward-asia-2021/IMG_4369.jpg
title: 大兴机场 - 壁画
date: 2021-12-10 17:54:15
- src: /images/albums/flink-forward-asia-2021/IMG_4370.jpg
title: 开始登机
description: 此时透过廊桥的玻璃往外看,太阳已经完全落下,点点余晖也即将消散。淡黄色的照明灯已经亮起,让人分不清是最后那点阳光,还是灯光。一天不到的北京之行即将落幕,就像是做了一场梦一般迷幻。
date: 2021-12-10 18:13:32
- src: /images/albums/flink-forward-asia-2021/IMG_4372.jpg
title: 空中俯瞰北京
date: 2021-12-10 18:36:53

View File

@ -566,3 +566,7 @@
slug: off-plan
- name:
slug: sex
- name: Flink
slug: flink
- name: 北京
slug: beijing

View File

@ -0,0 +1,14 @@
---
title: Flink Forward Asia 2021 北京之旅
slug: flink-forward-asia-2021
date: 2021-12-25 15:13:41
comments: true
tags:
- Flink
- 北京
category: 编程
summary: 2021 年对我来说是个极其不寻常的一年12 月更是最为重要的一个月。这个月里,我的女儿桃桃出生,同时有幸前往北京参与 Flink Forward Asia 2021 线上大会的视频录制。
cover: /images/albums/flink-forward-asia-2021/IMG_4321.jpg
---
<Album id='flink-forward-asia-2021' />

63
src/helpers/markdown.ts Normal file
View File

@ -0,0 +1,63 @@
import { marked } from 'marked';
import { ELEMENT_NODE, transform, walk } from 'ultrahtml';
import sanitize from 'ultrahtml/transformers/sanitize';
export const parseContent = async (content: string): Promise<string> => {
// Support paragraph in blank line.
const escapedContent = content.replace(/\r\n/g, '\n').replace(/(?<!\n)\n(?!\n)/g, '<br />');
const parsed = await marked.parse(escapedContent);
// Avoid the XSS attack.
return transform(parsed, [
async (node) => {
await walk(node, (node) => {
if (node.type === ELEMENT_NODE) {
if (node.name === 'a' && !node.attributes.href?.startsWith('https://yufan.me')) {
node.attributes.target = '_blank';
node.attributes.rel = 'nofollow';
}
}
});
return node;
},
sanitize({
allowElements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'a',
'img',
'span',
'strong',
'code',
'pre',
'blockquote',
'del',
'i',
'u',
'sup',
'sub',
'em',
'b',
'font',
'hr',
'br',
'ul',
'ol',
'li',
],
allowAttributes: {
src: ['img'],
width: ['img'],
height: ['img'],
rel: ['a'],
target: ['a'],
},
allowComments: false,
}),
]);
};

View File

@ -3,20 +3,22 @@ import type { Image } from '@/helpers/images';
import options from '@/options';
import { getCollection, render, type RenderResult } from 'astro:content';
import { pinyin } from 'pinyin-pro';
import { parseContent } from './markdown';
// Import the collections from the astro content.
const imagesCollection = await getCollection('images');
const categoriesCollection = await getCollection('categories');
const albumsCollection = await getCollection('albums');
const friendsCollection = await getCollection('friends');
const pagesCollection = await getCollection('pages');
const postsCollection = await getCollection('posts');
const categoriesCollection = await getCollection('categories');
const tagsCollection = await getCollection('tags');
// Redefine the types from the astro content.
export type Category = Omit<(typeof categoriesCollection)[number]['data'], 'cover'> & {
export type Picture = Omit<(typeof albumsCollection)[number]['data']['pictures'][number], 'src'> & Image;
export type Album = Omit<(typeof albumsCollection)[number]['data'], 'cover' | 'pictures'> & {
cover: Image;
counts: number;
permalink: string;
pictures: Picture[];
};
export type Friend = (typeof friendsCollection)[number]['data'];
export type Page = Omit<(typeof pagesCollection)[number]['data'], 'cover'> & {
@ -32,9 +34,14 @@ export type Post = Omit<(typeof postsCollection)[number]['data'], 'cover'> & {
render: () => Promise<RenderResult>;
raw: () => Promise<string | undefined>;
};
export type Category = Omit<(typeof categoriesCollection)[number]['data'], 'cover'> & {
cover: Image;
counts: number;
permalink: string;
};
export type Tag = (typeof tagsCollection)[number]['data'] & { counts: number; permalink: string };
// Expose this help method for finding the images without null check.
// Translate the Astro content into the original content for dealing with different configuration types.
const images: Array<Image & { id: string }> = imagesCollection.map((image) => ({ id: image.id, ...image.data }));
export const getImage = (src: string): Image => {
const image = images.find((image) => image.id === src);
@ -43,50 +50,60 @@ export const getImage = (src: string): Image => {
}
return image;
};
// Translate the Astro content into the original content for dealing with different configuration types.
export const albums: Album[] = albumsCollection.map((album) => ({
...album.data,
description: album.data.description,
cover: getImage(album.data.cover),
pictures: album.data.pictures.map((picture) => ({ ...picture, ...getImage(picture.src) })),
}));
for (const album of albums) {
if (album.description !== undefined && album.description !== '') {
album.description = await parseContent(album.description);
}
for (const picture of album.pictures) {
if (picture.description !== undefined && picture.description !== '') {
picture.description = await parseContent(picture.description);
}
}
}
export const friends: Friend[] = friendsCollection.map((friends) => friends.data);
// Override the website for local debugging
export const pages: Page[] = await Promise.all(
pagesCollection
.filter((page) => page.data.published || !options.isProd())
.map(async (page) => ({
...page.data,
cover: getImage(page.data.cover),
slug: page.id,
permalink: `/${page.id}`,
render: async () => await render(page),
})),
);
export const posts: Post[] = (
await Promise.all(
postsCollection
.filter((post) => post.data.published || !options.isProd())
.map(async (post) => ({
...post.data,
cover: getImage(post.data.cover),
slug: post.id,
permalink: `/posts/${post.id}`,
render: async () => await render(post),
raw: async () => {
return post.body;
},
})),
)
).sort((left: Post, right: Post) => {
const a = left.date.getTime();
const b = right.date.getTime();
return options.settings.post.sort === 'asc' ? a - b : b - a;
});
export const categories: Category[] = await Promise.all(
categoriesCollection.map(async (cat) => ({
...cat.data,
cover: getImage(cat.data.cover),
counts: posts.filter((post) => post.category === cat.data.name).length,
permalink: `/cats/${cat.data.slug}`,
})),
);
export const pages: Page[] = pagesCollection
.filter((page) => page.data.published || !options.isProd())
.map((page) => ({
...page.data,
cover: getImage(page.data.cover),
slug: page.id,
permalink: `/${page.id}`,
render: async () => await render(page),
}));
export const posts: Post[] = postsCollection
.filter((post) => post.data.published || !options.isProd())
.map((post) => ({
...post.data,
cover: getImage(post.data.cover),
slug: post.id,
permalink: `/posts/${post.id}`,
render: async () => await render(post),
raw: async () => {
return post.body;
},
}))
.sort((left: Post, right: Post) => {
const a = left.date.getTime();
const b = right.date.getTime();
return options.settings.post.sort === 'asc' ? a - b : b - a;
});
export const categories: Category[] = categoriesCollection.map((cat) => ({
...cat.data,
cover: getImage(cat.data.cover),
counts: posts.filter((post) => post.category === cat.data.name).length,
permalink: `/cats/${cat.data.slug}`,
}));
for (const category of categories) {
if (category.description !== '') {
category.description = await parseContent(category.description);
}
}
export const tags: Tag[] = tagsCollection.map((tag) => ({
...tag.data,
counts: posts.filter((post) => post.tags.includes(tag.data.name)).length,
@ -157,6 +174,10 @@ if (invalidFeaturePosts.length > 0) {
throw new Error(`The bellowing feature posts are invalid:\n$${invalidFeaturePosts.join('\n')}`);
}
export const getAlbum = (slug: string): Album | undefined => {
return albums.find((album) => album.slug === slug);
};
export const getPost = (slug: string): Post | undefined => {
return posts.find((post) => post.slug === slug);
};

View File

@ -1,4 +1,5 @@
---
import Album from '@/components/album/Album.astro';
import Comments from '@/components/comment/Comments.astro';
import Footer from '@/components/footer/Footer.astro';
import Image from '@/components/image/Image.astro';
@ -30,7 +31,7 @@ const { Content, headings } = await page.render();
<TableOfContents {headings} toc={page.toc} />
<div class="post-content">
<div class="nc-light-gallery">
{page.comments && <Content components={{ MusicPlayer: MusicPlayer, Image: Image }} />}
{page.comments && <Content components={{ MusicPlayer: MusicPlayer, Image: Image, Album: Album }} />}
</div>
</div>
{page.friend && <Friends />}

View File

@ -1,4 +1,5 @@
---
import Album from '@/components/album/Album.astro';
import Comments from '@/components/comment/Comments.astro';
import Image from '@/components/image/Image.astro';
import LikeButton from '@/components/like/LikeButton.astro';
@ -57,7 +58,7 @@ const { Content, headings } = await post.render();
<TableOfContents {headings} toc={post.toc} />
<div class="post-content">
<div class="nc-light-gallery">
<Content components={{ MusicPlayer: MusicPlayer, Image: Image }} />
<Content components={{ MusicPlayer: MusicPlayer, Image: Image, Album: Album }} />
</div>
<nav class="post-in-navigation navigation pagination font-number" role="navigation">
<div class="nav-links"></div>

View File

@ -32,7 +32,9 @@ const { title } = Astro.props;
{category.name}
</a>
<div class="list-subtitle d-none d-md-block text-md text-secondary mt-2">
<div class="h-1x">{category.description.split('\n')[0]}</div>
<div class="h-1x">
<Fragment set:html={category.description} />
</div>
</div>
</div>
<div class="list-footer mt-2">

View File

@ -22,7 +22,7 @@ const { currentPosts, totalPage } = slicePosts(posts, pageNum, options.settings.
<div class="mb-3 mb-lg-4">
<h1>{category.name}</h1>
<div class="text-muted mt-1">
{category.description.split('\n').map((line) => (line !== '' ? <p>{line}</p> : ''))}
<Fragment set:html={category.description} />
</div>
</div>
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">

View File

@ -20,15 +20,17 @@ if (totalPage === 0) {
---
<BaseLayout title={`标签 “${tag.name}”`}>
<div class="container">
<div class="mb-3 mb-lg-4">
<h1>{tag.name}</h1>
</div>
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
{currentPosts.map((post, index) => <PostSquare post={post} first={index === 0} />)}
</div>
<div class="mt-4 mt-lg-5">
<Pagination current={pageNum} total={totalPage} rootPath={tag.permalink} />
<div class="px-lg-2 px-xxl-5 py-3 py-md-4 py-xxl-5">
<div class="container">
<div class="mb-3 mb-lg-4">
<h1>{tag.name}</h1>
</div>
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
{currentPosts.map((post, index) => <PostSquare post={post} first={index === 0} />)}
</div>
<div class="mt-4 mt-lg-5">
<Pagination current={pageNum} total={totalPage} rootPath={tag.permalink} />
</div>
</div>
</div>
</BaseLayout>