feat: add the gallery support. (#66)
3
.vscode/settings.json
vendored
@ -20,6 +20,7 @@
|
|||||||
"ameho",
|
"ameho",
|
||||||
"amehochan",
|
"amehochan",
|
||||||
"aplayer",
|
"aplayer",
|
||||||
|
"arnowelzel",
|
||||||
"arrowup",
|
"arrowup",
|
||||||
"artalk",
|
"artalk",
|
||||||
"astro",
|
"astro",
|
||||||
@ -49,6 +50,7 @@
|
|||||||
"eparams",
|
"eparams",
|
||||||
"firis",
|
"firis",
|
||||||
"flexdinesh",
|
"flexdinesh",
|
||||||
|
"flink",
|
||||||
"fmoran",
|
"fmoran",
|
||||||
"fong",
|
"fong",
|
||||||
"forencrypt",
|
"forencrypt",
|
||||||
@ -72,6 +74,7 @@
|
|||||||
"jing",
|
"jing",
|
||||||
"jmoiron",
|
"jmoiron",
|
||||||
"jungshik",
|
"jungshik",
|
||||||
|
"junkfix",
|
||||||
"khalil",
|
"khalil",
|
||||||
"khtml",
|
"khtml",
|
||||||
"koanughi",
|
"koanughi",
|
||||||
|
@ -204,7 +204,7 @@ You should host it on your own machine.
|
|||||||
## Short-Term TODO Checklist
|
## Short-Term TODO Checklist
|
||||||
|
|
||||||
- [ ] Add last modified time component for post.
|
- [ ] 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.
|
- [ ] Check article grammar errors by using ChatGPT. Remain **42** posts.
|
||||||
- [ ] Add music to the articles. 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.
|
- [ ] Use self-developed comment solution.
|
||||||
- [ ] Support modification after commenting in 60 minutes even if you have refreshed the page.
|
- [ ] Support modification after commenting in 60 minutes even if you have refreshed the page.
|
||||||
- [ ] Support login into the blog for managing the comments.
|
- [ ] Support login into the blog for managing the comments.
|
||||||
- [ ] Slide share components integration.
|
|
||||||
- [ ] Add han.js support for better typography.
|
- [ ] Add han.js support for better typography.
|
||||||
- [ ] Drop bootstrap, in favor of tailwind css.
|
- [ ] 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)
|
- [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)
|
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)
|
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)
|
||||||
|
21
licenses/LICENSE.arnowelzel.txt
Normal 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.
|
21
licenses/LICENSE.junkfix.txt
Normal 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
@ -40,7 +40,8 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
"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": "^0.14.1",
|
||||||
"prettier-plugin-astro-organize-imports": "^0.4.11",
|
"prettier-plugin-astro-organize-imports": "^0.4.11",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
@ -6032,6 +6033,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -6170,9 +6178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
|
||||||
"integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==",
|
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
@ -73,7 +73,8 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
"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": "^0.14.1",
|
||||||
"prettier-plugin-astro-organize-imports": "^0.4.11",
|
"prettier-plugin-astro-organize-imports": "^0.4.11",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
|
BIN
public/images/albums/flink-forward-asia-2021/IMG_4321.jpg
Normal file
After Width: | Height: | Size: 567 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4322.jpg
Normal file
After Width: | Height: | Size: 522 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4325.jpg
Normal file
After Width: | Height: | Size: 315 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4363.jpg
Normal file
After Width: | Height: | Size: 784 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4364.jpg
Normal file
After Width: | Height: | Size: 812 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4365.jpg
Normal file
After Width: | Height: | Size: 843 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4366.jpg
Normal file
After Width: | Height: | Size: 781 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4367.jpg
Normal file
After Width: | Height: | Size: 672 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4368.jpg
Normal file
After Width: | Height: | Size: 895 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4369.jpg
Normal file
After Width: | Height: | Size: 949 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4370.jpg
Normal file
After Width: | Height: | Size: 546 KiB |
BIN
public/images/albums/flink-forward-asia-2021/IMG_4372.jpg
Normal file
After Width: | Height: | Size: 666 KiB |
92
src/assets/scripts/photoswipe/photoswipe-auto-hide-ui.js
Normal 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;
|
321
src/assets/scripts/photoswipe/photoswipe-slideshow.js
Normal 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;
|
@ -2,11 +2,49 @@ import Aplayer from 'aplayer/dist/APlayer.min.js';
|
|||||||
import { actions, isInputError } from 'astro:actions';
|
import { actions, isInputError } from 'astro:actions';
|
||||||
import PhotoSwipe from 'photoswipe';
|
import PhotoSwipe from 'photoswipe';
|
||||||
import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin';
|
import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin';
|
||||||
|
import PhotoSwipeVideo from 'photoswipe-video-plugin';
|
||||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
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';
|
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.
|
// Lightbox support for post images.
|
||||||
const imageLinks = Array.from(document.querySelectorAll('.post-content a')).filter((link) => {
|
const imageLinks = Array.from(document.querySelectorAll('.post-content a')).filter((link) => {
|
||||||
|
if (link.classList.contains('album-picture')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const img = link.querySelector('img');
|
const img = link.querySelector('img');
|
||||||
return typeof img !== 'undefined' && img !== null;
|
return typeof img !== 'undefined' && img !== null;
|
||||||
});
|
});
|
||||||
|
@ -3513,3 +3513,17 @@ a.index-menu-link:hover {
|
|||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
z-index: 500;
|
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;
|
||||||
|
}
|
||||||
|
44
src/components/album/Album.astro
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
27
src/components/album/UnstyledAlbum.astro
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +1,11 @@
|
|||||||
import type { Comment, CommentItem, CommentReq, CommentResp, Comments, ErrorResp } from '@/components/comment/types';
|
import type { Comment, CommentItem, CommentReq, CommentResp, Comments, ErrorResp } from '@/components/comment/types';
|
||||||
import { queryUser } from '@/helpers/db/query';
|
import { queryUser } from '@/helpers/db/query';
|
||||||
|
import { parseContent } from '@/helpers/markdown';
|
||||||
import { urlJoin } from '@/helpers/tools';
|
import { urlJoin } from '@/helpers/tools';
|
||||||
import options from '@/options';
|
import options from '@/options';
|
||||||
import { ARTALK_HOST, ARTALK_PORT, ARTALK_SCHEME } from 'astro:env/server';
|
import { ARTALK_HOST, ARTALK_PORT, ARTALK_SCHEME } from 'astro:env/server';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { marked } from 'marked';
|
|
||||||
import querystring from 'node:querystring';
|
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.
|
// Access the artalk in internal docker host when it was deployed on zeabur.
|
||||||
const server = `${ARTALK_SCHEME}://${ARTALK_HOST}:${ARTALK_PORT}`;
|
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)) };
|
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,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import UnstyledAlbum from '@/components/album/UnstyledAlbum.astro';
|
||||||
import UnstyledImage from '@/components/image/UnstyledImage.astro';
|
import UnstyledImage from '@/components/image/UnstyledImage.astro';
|
||||||
import UnstyledMusicPlayer from '@/components/player/UnstyledMusicPlayer.astro';
|
import UnstyledMusicPlayer from '@/components/player/UnstyledMusicPlayer.astro';
|
||||||
import { posts } from '@/helpers/schema';
|
import { posts } from '@/helpers/schema';
|
||||||
@ -16,4 +17,4 @@ if (!post) {
|
|||||||
const { Content } = await post.render();
|
const { Content } = await post.render();
|
||||||
---
|
---
|
||||||
|
|
||||||
<Content components={{ MusicPlayer: UnstyledMusicPlayer, Image: UnstyledImage }} />
|
<Content components={{ MusicPlayer: UnstyledMusicPlayer, Image: UnstyledImage, Album: UnstyledAlbum }} />
|
||||||
|
@ -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
|
// Categories Collection
|
||||||
const categoriesCollection = defineCollection({
|
const categoriesCollection = defineCollection({
|
||||||
loader: file('./src/content/metas/categories.yml'),
|
loader: file('./src/content/metas/categories.yml'),
|
||||||
@ -72,7 +92,7 @@ const categoriesCollection = defineCollection({
|
|||||||
name: z.string().max(20),
|
name: z.string().max(20),
|
||||||
slug: slug(),
|
slug: slug(),
|
||||||
cover: image(defaultCover),
|
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'),
|
loader: file('./src/content/metas/friends.yml'),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
website: z.string().max(40),
|
website: z.string().max(40),
|
||||||
description: z.string().optional(),
|
description: z.string().optional().describe('One line string'),
|
||||||
homepage: z.string().url(),
|
homepage: z.string().url(),
|
||||||
poster: z
|
poster: z
|
||||||
.string()
|
.string()
|
||||||
@ -106,8 +126,8 @@ const postsCollection = defineCollection({
|
|||||||
date: z.date(),
|
date: z.date(),
|
||||||
updated: z.date().optional(),
|
updated: z.date().optional(),
|
||||||
comments: z.boolean().optional().default(true),
|
comments: z.boolean().optional().default(true),
|
||||||
alias: z.array(z.string()).optional().default([]),
|
alias: z.string().array().optional().default([]),
|
||||||
tags: z.array(z.string()).optional().default([]),
|
tags: z.string().array().optional().default([]),
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
summary: z.string().optional().default(''),
|
summary: z.string().optional().default(''),
|
||||||
cover: image(defaultCover),
|
cover: image(defaultCover),
|
||||||
@ -134,6 +154,7 @@ const pagesCollection = defineCollection({
|
|||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
images: imagesCollection,
|
images: imagesCollection,
|
||||||
|
albums: albumsCollection,
|
||||||
categories: categoriesCollection,
|
categories: categoriesCollection,
|
||||||
friends: friendsCollection,
|
friends: friendsCollection,
|
||||||
tags: tagsCollection,
|
tags: tagsCollection,
|
||||||
|
58
src/content/albums/2021/2021-12-flink-forward-asia.yml
Normal 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
|
@ -566,3 +566,7 @@
|
|||||||
slug: off-plan
|
slug: off-plan
|
||||||
- name: 性
|
- name: 性
|
||||||
slug: sex
|
slug: sex
|
||||||
|
- name: Flink
|
||||||
|
slug: flink
|
||||||
|
- name: 北京
|
||||||
|
slug: beijing
|
||||||
|
@ -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
@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
@ -3,20 +3,22 @@ import type { Image } from '@/helpers/images';
|
|||||||
import options from '@/options';
|
import options from '@/options';
|
||||||
import { getCollection, render, type RenderResult } from 'astro:content';
|
import { getCollection, render, type RenderResult } from 'astro:content';
|
||||||
import { pinyin } from 'pinyin-pro';
|
import { pinyin } from 'pinyin-pro';
|
||||||
|
import { parseContent } from './markdown';
|
||||||
|
|
||||||
// Import the collections from the astro content.
|
// Import the collections from the astro content.
|
||||||
const imagesCollection = await getCollection('images');
|
const imagesCollection = await getCollection('images');
|
||||||
const categoriesCollection = await getCollection('categories');
|
const albumsCollection = await getCollection('albums');
|
||||||
const friendsCollection = await getCollection('friends');
|
const friendsCollection = await getCollection('friends');
|
||||||
const pagesCollection = await getCollection('pages');
|
const pagesCollection = await getCollection('pages');
|
||||||
const postsCollection = await getCollection('posts');
|
const postsCollection = await getCollection('posts');
|
||||||
|
const categoriesCollection = await getCollection('categories');
|
||||||
const tagsCollection = await getCollection('tags');
|
const tagsCollection = await getCollection('tags');
|
||||||
|
|
||||||
// Redefine the types from the astro content.
|
// 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;
|
cover: Image;
|
||||||
counts: number;
|
pictures: Picture[];
|
||||||
permalink: string;
|
|
||||||
};
|
};
|
||||||
export type Friend = (typeof friendsCollection)[number]['data'];
|
export type Friend = (typeof friendsCollection)[number]['data'];
|
||||||
export type Page = Omit<(typeof pagesCollection)[number]['data'], 'cover'> & {
|
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>;
|
render: () => Promise<RenderResult>;
|
||||||
raw: () => Promise<string | undefined>;
|
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 };
|
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 }));
|
const images: Array<Image & { id: string }> = imagesCollection.map((image) => ({ id: image.id, ...image.data }));
|
||||||
export const getImage = (src: string): Image => {
|
export const getImage = (src: string): Image => {
|
||||||
const image = images.find((image) => image.id === src);
|
const image = images.find((image) => image.id === src);
|
||||||
@ -43,50 +50,60 @@ export const getImage = (src: string): Image => {
|
|||||||
}
|
}
|
||||||
return image;
|
return image;
|
||||||
};
|
};
|
||||||
|
export const albums: Album[] = albumsCollection.map((album) => ({
|
||||||
// Translate the Astro content into the original content for dealing with different configuration types.
|
...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);
|
export const friends: Friend[] = friendsCollection.map((friends) => friends.data);
|
||||||
|
export const pages: Page[] = pagesCollection
|
||||||
// Override the website for local debugging
|
.filter((page) => page.data.published || !options.isProd())
|
||||||
export const pages: Page[] = await Promise.all(
|
.map((page) => ({
|
||||||
pagesCollection
|
...page.data,
|
||||||
.filter((page) => page.data.published || !options.isProd())
|
cover: getImage(page.data.cover),
|
||||||
.map(async (page) => ({
|
slug: page.id,
|
||||||
...page.data,
|
permalink: `/${page.id}`,
|
||||||
cover: getImage(page.data.cover),
|
render: async () => await render(page),
|
||||||
slug: page.id,
|
}));
|
||||||
permalink: `/${page.id}`,
|
export const posts: Post[] = postsCollection
|
||||||
render: async () => await render(page),
|
.filter((post) => post.data.published || !options.isProd())
|
||||||
})),
|
.map((post) => ({
|
||||||
);
|
...post.data,
|
||||||
export const posts: Post[] = (
|
cover: getImage(post.data.cover),
|
||||||
await Promise.all(
|
slug: post.id,
|
||||||
postsCollection
|
permalink: `/posts/${post.id}`,
|
||||||
.filter((post) => post.data.published || !options.isProd())
|
render: async () => await render(post),
|
||||||
.map(async (post) => ({
|
raw: async () => {
|
||||||
...post.data,
|
return post.body;
|
||||||
cover: getImage(post.data.cover),
|
},
|
||||||
slug: post.id,
|
}))
|
||||||
permalink: `/posts/${post.id}`,
|
.sort((left: Post, right: Post) => {
|
||||||
render: async () => await render(post),
|
const a = left.date.getTime();
|
||||||
raw: async () => {
|
const b = right.date.getTime();
|
||||||
return post.body;
|
return options.settings.post.sort === 'asc' ? a - b : b - a;
|
||||||
},
|
});
|
||||||
})),
|
export const categories: Category[] = categoriesCollection.map((cat) => ({
|
||||||
)
|
...cat.data,
|
||||||
).sort((left: Post, right: Post) => {
|
cover: getImage(cat.data.cover),
|
||||||
const a = left.date.getTime();
|
counts: posts.filter((post) => post.category === cat.data.name).length,
|
||||||
const b = right.date.getTime();
|
permalink: `/cats/${cat.data.slug}`,
|
||||||
return options.settings.post.sort === 'asc' ? a - b : b - a;
|
}));
|
||||||
});
|
for (const category of categories) {
|
||||||
export const categories: Category[] = await Promise.all(
|
if (category.description !== '') {
|
||||||
categoriesCollection.map(async (cat) => ({
|
category.description = await parseContent(category.description);
|
||||||
...cat.data,
|
}
|
||||||
cover: getImage(cat.data.cover),
|
}
|
||||||
counts: posts.filter((post) => post.category === cat.data.name).length,
|
|
||||||
permalink: `/cats/${cat.data.slug}`,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
export const tags: Tag[] = tagsCollection.map((tag) => ({
|
export const tags: Tag[] = tagsCollection.map((tag) => ({
|
||||||
...tag.data,
|
...tag.data,
|
||||||
counts: posts.filter((post) => post.tags.includes(tag.data.name)).length,
|
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')}`);
|
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 => {
|
export const getPost = (slug: string): Post | undefined => {
|
||||||
return posts.find((post) => post.slug === slug);
|
return posts.find((post) => post.slug === slug);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import Album from '@/components/album/Album.astro';
|
||||||
import Comments from '@/components/comment/Comments.astro';
|
import Comments from '@/components/comment/Comments.astro';
|
||||||
import Footer from '@/components/footer/Footer.astro';
|
import Footer from '@/components/footer/Footer.astro';
|
||||||
import Image from '@/components/image/Image.astro';
|
import Image from '@/components/image/Image.astro';
|
||||||
@ -30,7 +31,7 @@ const { Content, headings } = await page.render();
|
|||||||
<TableOfContents {headings} toc={page.toc} />
|
<TableOfContents {headings} toc={page.toc} />
|
||||||
<div class="post-content">
|
<div class="post-content">
|
||||||
<div class="nc-light-gallery">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{page.friend && <Friends />}
|
{page.friend && <Friends />}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import Album from '@/components/album/Album.astro';
|
||||||
import Comments from '@/components/comment/Comments.astro';
|
import Comments from '@/components/comment/Comments.astro';
|
||||||
import Image from '@/components/image/Image.astro';
|
import Image from '@/components/image/Image.astro';
|
||||||
import LikeButton from '@/components/like/LikeButton.astro';
|
import LikeButton from '@/components/like/LikeButton.astro';
|
||||||
@ -57,7 +58,7 @@ const { Content, headings } = await post.render();
|
|||||||
<TableOfContents {headings} toc={post.toc} />
|
<TableOfContents {headings} toc={post.toc} />
|
||||||
<div class="post-content">
|
<div class="post-content">
|
||||||
<div class="nc-light-gallery">
|
<div class="nc-light-gallery">
|
||||||
<Content components={{ MusicPlayer: MusicPlayer, Image: Image }} />
|
<Content components={{ MusicPlayer: MusicPlayer, Image: Image, Album: Album }} />
|
||||||
</div>
|
</div>
|
||||||
<nav class="post-in-navigation navigation pagination font-number" role="navigation">
|
<nav class="post-in-navigation navigation pagination font-number" role="navigation">
|
||||||
<div class="nav-links"></div>
|
<div class="nav-links"></div>
|
||||||
|
@ -32,7 +32,9 @@ const { title } = Astro.props;
|
|||||||
{category.name}
|
{category.name}
|
||||||
</a>
|
</a>
|
||||||
<div class="list-subtitle d-none d-md-block text-md text-secondary mt-2">
|
<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>
|
</div>
|
||||||
<div class="list-footer mt-2">
|
<div class="list-footer mt-2">
|
||||||
|
@ -22,7 +22,7 @@ const { currentPosts, totalPage } = slicePosts(posts, pageNum, options.settings.
|
|||||||
<div class="mb-3 mb-lg-4">
|
<div class="mb-3 mb-lg-4">
|
||||||
<h1>{category.name}</h1>
|
<h1>{category.name}</h1>
|
||||||
<div class="text-muted mt-1">
|
<div class="text-muted mt-1">
|
||||||
{category.description.split('\n').map((line) => (line !== '' ? <p>{line}</p> : ''))}
|
<Fragment set:html={category.description} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
|
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
|
||||||
|
@ -20,15 +20,17 @@ if (totalPage === 0) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`标签 “${tag.name}”`}>
|
<BaseLayout title={`标签 “${tag.name}”`}>
|
||||||
<div class="container">
|
<div class="px-lg-2 px-xxl-5 py-3 py-md-4 py-xxl-5">
|
||||||
<div class="mb-3 mb-lg-4">
|
<div class="container">
|
||||||
<h1>{tag.name}</h1>
|
<div class="mb-3 mb-lg-4">
|
||||||
</div>
|
<h1>{tag.name}</h1>
|
||||||
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
|
</div>
|
||||||
{currentPosts.map((post, index) => <PostSquare post={post} first={index === 0} />)}
|
<div class="row g-2 g-md-3 g-xxl-4 list-grouped">
|
||||||
</div>
|
{currentPosts.map((post, index) => <PostSquare post={post} first={index === 0} />)}
|
||||||
<div class="mt-4 mt-lg-5">
|
</div>
|
||||||
<Pagination current={pageNum} total={totalPage} rootPath={tag.permalink} />
|
<div class="mt-4 mt-lg-5">
|
||||||
|
<Pagination current={pageNum} total={totalPage} rootPath={tag.permalink} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|