feat: use new comment interface, the artalk now serve as the backend. (#42)
This commit is contained in:
parent
89ddfb2dd4
commit
e8c3937626
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -28,6 +28,7 @@
|
|||||||
"cimage",
|
"cimage",
|
||||||
"ckenh",
|
"ckenh",
|
||||||
"cnip",
|
"cnip",
|
||||||
|
"commentform",
|
||||||
"csvg",
|
"csvg",
|
||||||
"ctan",
|
"ctan",
|
||||||
"cynosura",
|
"cynosura",
|
||||||
|
@ -181,7 +181,6 @@ For instance, the [giscus](https://giscus.app) is an opinionated choice.
|
|||||||
- [ ] Add music to the articles. Remain **54** posts.
|
- [ ] Add music to the articles. Remain **54** posts.
|
||||||
- [ ] Clean up the legacy links in the weblog comments.
|
- [ ] Clean up the legacy links in the weblog comments.
|
||||||
- [ ] External the article inner links with different target.
|
- [ ] External the article inner links with different target.
|
||||||
- [ ] Artalk integration with custom stylesheet.
|
|
||||||
- [ ] Slide share components integration.
|
- [ ] Slide share components integration.
|
||||||
|
|
||||||
### Long Term Goals
|
### Long Term Goals
|
||||||
|
@ -23,6 +23,7 @@ export default defineConfig({
|
|||||||
POSTGRES_USERNAME: envField.string({ context: 'server', access: 'secret' }),
|
POSTGRES_USERNAME: envField.string({ context: 'server', access: 'secret' }),
|
||||||
POSTGRES_PASSWORD: envField.string({ context: 'server', access: 'secret' }),
|
POSTGRES_PASSWORD: envField.string({ context: 'server', access: 'secret' }),
|
||||||
POSTGRES_DATABASE: envField.string({ context: 'server', access: 'secret' }),
|
POSTGRES_DATABASE: envField.string({ context: 'server', access: 'secret' }),
|
||||||
|
ARTALK_HOST: envField.string({ context: 'server', access: 'secret' }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
76
package-lock.json
generated
76
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
|
"marked": "^13.0.0",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"ultrahtml": "^1.5.3"
|
"ultrahtml": "^1.5.3"
|
||||||
@ -32,7 +33,6 @@
|
|||||||
"@types/qrcode-svg": "^1.1.4",
|
"@types/qrcode-svg": "^1.1.4",
|
||||||
"@types/unist": "^3.0.2",
|
"@types/unist": "^3.0.2",
|
||||||
"aplayer": "^1.10.1",
|
"aplayer": "^1.10.1",
|
||||||
"artalk": "^2.8.7",
|
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
"prettier-plugin-astro": "^0.14.0",
|
"prettier-plugin-astro": "^0.14.0",
|
||||||
@ -2901,13 +2901,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/abortcontroller-polyfill": {
|
|
||||||
"version": "1.7.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz",
|
|
||||||
"integrity": "sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
||||||
@ -3053,25 +3046,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/artalk": {
|
|
||||||
"version": "2.8.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/artalk/-/artalk-2.8.7.tgz",
|
|
||||||
"integrity": "sha512-Pl6oKnG0mLf6/c0X93SEmUm7RqqO1zsfvf4j8EzO25pvcDn9pJdYQqLrWKupaHwO7JLvEcgq5pBBrJCLmBKGpw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"abortcontroller-polyfill": "^1.7.5",
|
|
||||||
"hanabi": "^0.4.0",
|
|
||||||
"insane": "^2.6.2",
|
|
||||||
"marked": "^12.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assignment": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/assignment/-/assignment-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-naMULXjtgCs9SVUEtyvJNt68aF18em7/W+dhbR59kbz9cXWPEvUkCun2tqlgqRPSqZaKPpqLc5ZnwL8jVmJRvw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/astring": {
|
"node_modules/astring": {
|
||||||
"version": "1.8.6",
|
"version": "1.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz",
|
||||||
@ -3722,16 +3696,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/comment-regex": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/comment-regex/-/comment-regex-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-IWlN//Yfby92tOIje7J18HkNmWRR7JESA/BK8W7wqY/akITpU5B0JQWnbTjCfdChSrDNb0DrdA9jfAxiiBXyiQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/common-ancestor-path": {
|
"node_modules/common-ancestor-path": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz",
|
||||||
@ -4580,16 +4544,6 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hanabi": {
|
|
||||||
"version": "0.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/hanabi/-/hanabi-0.4.0.tgz",
|
|
||||||
"integrity": "sha512-ixJH94fwmmVzUSdxl7TMkVZJmsq4d2JKrxedpM5V1V+91iVHL0q6NnJi4xiDahK6Vo00xT17H8H6b4F6RVbsOg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"comment-regex": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
@ -4859,16 +4813,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/he": {
|
|
||||||
"version": "0.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/he/-/he-0.5.0.tgz",
|
|
||||||
"integrity": "sha512-DoufbNNOFzwRPy8uecq+j+VCPQ+JyDelHTmSgygrA5TsR8Cbw4Qcir5sGtWiusB4BdT89nmlaVDhSJOqC/33vw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"he": "bin/he"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||||
@ -4938,17 +4882,6 @@
|
|||||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
|
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/insane": {
|
|
||||||
"version": "2.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/insane/-/insane-2.6.2.tgz",
|
|
||||||
"integrity": "sha512-BqEL1CJsjJi+/C/zKZxv31zs3r6zkLH5Nz1WMFb7UBX2KHY2yXDpbFTSEmNHzomBbGDysIfkTX55A0mQZ2CQiw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"assignment": "2.0.0",
|
|
||||||
"he": "0.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@ -5450,10 +5383,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "12.0.2",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-13.0.0.tgz",
|
||||||
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
|
"integrity": "sha512-VTeDCd9txf4KLLljUZ0nljE/Incb9SrWuueE44QVuU0pkOdh4sfCeW1Z6lPcxyDRSVY6rm8db/0OPaN75RNUmw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
|
@ -49,6 +49,7 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
|
"marked": "^13.0.0",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"ultrahtml": "^1.5.3"
|
"ultrahtml": "^1.5.3"
|
||||||
@ -64,7 +65,6 @@
|
|||||||
"@types/qrcode-svg": "^1.1.4",
|
"@types/qrcode-svg": "^1.1.4",
|
||||||
"@types/unist": "^3.0.2",
|
"@types/unist": "^3.0.2",
|
||||||
"aplayer": "^1.10.1",
|
"aplayer": "^1.10.1",
|
||||||
"artalk": "^2.8.7",
|
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
"prettier-plugin-astro": "^0.14.0",
|
"prettier-plugin-astro": "^0.14.0",
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 566 KiB After Width: | Height: | Size: 348 KiB |
BIN
public/images/default-avatar.png
Normal file
BIN
public/images/default-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
@ -1,5 +1,4 @@
|
|||||||
import Aplayer from 'aplayer/dist/APlayer.min.js';
|
import Aplayer from 'aplayer/dist/APlayer.min.js';
|
||||||
import Artalk from 'artalk/dist/ArtalkLite';
|
|
||||||
import stickySidebar from './sticky-sidebar.js';
|
import stickySidebar from './sticky-sidebar.js';
|
||||||
|
|
||||||
// Menu toggle.
|
// Menu toggle.
|
||||||
@ -87,16 +86,112 @@ document.querySelector('.global-search-close').addEventListener('click', (event)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Loading the comments.
|
// Loading the comments.
|
||||||
const comment = document.querySelector('#comments');
|
const comments = document.querySelector('#comments');
|
||||||
if (typeof comment !== 'undefined' && comment !== null) {
|
if (typeof comments !== 'undefined' && comments !== null) {
|
||||||
const { key, title, server, site } = comment.dataset;
|
const cancel = comments.querySelector('#cancel-comment-reply-link');
|
||||||
Artalk.init({
|
const replyForm = comments.querySelector('#respond');
|
||||||
el: '#comments',
|
const cancelReply = () => {
|
||||||
pageKey: key,
|
cancel.hidden = true;
|
||||||
pageTitle: title,
|
replyForm.querySelector('input[name="rid"]').value = '0';
|
||||||
server: server,
|
replyForm.querySelector('textarea[name="content"]').value = '';
|
||||||
site: site,
|
|
||||||
|
// Move the form back to top.
|
||||||
|
const commentCount = comments.querySelector('.comment-total-count');
|
||||||
|
commentCount.after(replyForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Load the commenter information from the cookie.
|
||||||
|
|
||||||
|
comments.addEventListener('focusout', (event) => {
|
||||||
|
if (event.target === document.querySelector('input[name="email"]')) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const email = event.target.value;
|
||||||
|
if (email !== '' && email.includes('@')) {
|
||||||
|
// Replace the avatar after typing the email.
|
||||||
|
fetch(`/comments/avatar?email=${email}`)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((link) => {
|
||||||
|
document.querySelector('#commentForm img.avatar').src = link;
|
||||||
|
})
|
||||||
|
.catch((e) => console.log(e));
|
||||||
|
} else {
|
||||||
|
document.querySelector('#commentForm img.avatar').src = '/images/default-avatar.png';
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
comments.addEventListener('click', async (event) => {
|
||||||
|
// Loading more comments from server.
|
||||||
|
if (event.target === comments.querySelector('#comments-next-button')) {
|
||||||
|
const { size, offset, key } = event.target.dataset;
|
||||||
|
const html = await fetch(`/comments/list?key=${key}&offset=${offset}`)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
if (html === '') {
|
||||||
|
// Remove the load more button.
|
||||||
|
event.target.remove();
|
||||||
|
} else {
|
||||||
|
// Append the comments into the list.
|
||||||
|
event.target.dataset.offset = Number(offset) + Number(size);
|
||||||
|
comments.querySelector('.comment-list').insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply a comment.
|
||||||
|
if (event.target.matches('.comment-reply-link')) {
|
||||||
|
cancel.hidden = false;
|
||||||
|
replyForm.querySelector('input[name="rid"]').value = event.target.dataset.rid;
|
||||||
|
|
||||||
|
// Move form to the reply.
|
||||||
|
const commentItem = event.target.closest('li');
|
||||||
|
commentItem.after(replyForm);
|
||||||
|
replyForm.querySelector('#content').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel reply comment.
|
||||||
|
if (event.target === cancel) {
|
||||||
|
cancelReply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reply a comment.
|
||||||
|
comments.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const formData = new FormData(event.target);
|
||||||
|
const data = {};
|
||||||
|
for (const [key, value] of formData) {
|
||||||
|
data[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch('/comments/new', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then((res) => res.text())
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return '<li>评论失败<li>';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.rid !== '0') {
|
||||||
|
replyForm.insertAdjacentHTML('beforebegin', resp);
|
||||||
|
} else {
|
||||||
|
const list = comments.querySelector('.comment-list');
|
||||||
|
list.insertAdjacentHTML('afterbegin', resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReply();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Highlighting the selected comment.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add like button for updating likes.
|
// Add like button for updating likes.
|
||||||
@ -104,7 +199,7 @@ const likeButton = document.querySelector('button.post-like');
|
|||||||
|
|
||||||
const increaseLikes = (count) => {
|
const increaseLikes = (count) => {
|
||||||
count.textContent = Number.parseInt(count.textContent) + 1;
|
count.textContent = Number.parseInt(count.textContent) + 1;
|
||||||
fetch(`${window.location.href}/likes`, {
|
fetch('/likes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-type': 'application/json; charset=UTF-8',
|
'Content-type': 'application/json; charset=UTF-8',
|
||||||
@ -125,7 +220,7 @@ const decreaseLikes = (count) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
count.textContent = Number.parseInt(count.textContent) - 1;
|
count.textContent = Number.parseInt(count.textContent) - 1;
|
||||||
fetch(`${window.location.href}/likes`, {
|
fetch('/likes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-type': 'application/json; charset=UTF-8',
|
'Content-type': 'application/json; charset=UTF-8',
|
||||||
|
@ -1059,7 +1059,7 @@ textarea.form-control {
|
|||||||
position: relative;
|
position: relative;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
display: -ms-flexbox;
|
display: -ms-flexbox;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -2610,7 +2610,7 @@ a:hover .overlay {
|
|||||||
|
|
||||||
.post-content > table th,
|
.post-content > table th,
|
||||||
.post-content div > table th {
|
.post-content div > table th {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content > table th,
|
.post-content > table th,
|
||||||
@ -2643,7 +2643,7 @@ a:hover .overlay {
|
|||||||
|
|
||||||
.post-content caption {
|
.post-content caption {
|
||||||
background: var(--bg-light);
|
background: var(--bg-light);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -2847,7 +2847,7 @@ a:hover .overlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widget_recent_comments ul li span {
|
.widget_recent_comments ul li span {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--color-dark);
|
color: var(--color-dark);
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
@ -3092,7 +3092,7 @@ a:hover .overlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widget_calendar td#today {
|
.widget_calendar td#today {
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3550,6 +3550,197 @@ a:hover .overlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*--------------------------------------------------------------
|
||||||
|
comment style
|
||||||
|
--------------------------------------------------------------*/
|
||||||
|
|
||||||
|
.comment-list .comment-respond {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
padding: 0 0 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment:last-child {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
display: -webkit-box;
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
-ms-flex: 1 1 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
position: relative;
|
||||||
|
display: -webkit-box;
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
-ms-flex: 1 1 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment_at {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author a {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-from-avatar,
|
||||||
|
.comment-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 0.9375rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment .comment-inner {
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
-ms-flex: 1 1 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment .comment-content {
|
||||||
|
margin: 0.5rem 0 0.5rem;
|
||||||
|
line-height: 1.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment .comment-footer {
|
||||||
|
display: -webkit-box;
|
||||||
|
display: -ms-flexbox;
|
||||||
|
display: flex;
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
-ms-flex: 1 1 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
-webkit-box-align: center;
|
||||||
|
-ms-flex-align: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment .comment-footer .comment-date {
|
||||||
|
-webkit-box-flex: 1;
|
||||||
|
-ms-flex: 1 1 auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 1.25rem 0 0 3.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .form-control {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment-from-avatar,
|
||||||
|
.children .comment .comment-avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment .comment-content {
|
||||||
|
margin: 0.375rem 0 0.375rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment .comment-inner {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children .comment .comment-footer .comment-date {
|
||||||
|
-webkit-box-flex: 0;
|
||||||
|
-ms-flex: none;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-link:hover {
|
||||||
|
transition: all 500ms;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 767px) {
|
||||||
|
.comment-list .comment-respond {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
.comment {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.comment-from-avatar,
|
||||||
|
.comment-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 0.625rem 0 0;
|
||||||
|
}
|
||||||
|
.comment .comment-inner {
|
||||||
|
margin: 0.125rem 0 0 0;
|
||||||
|
}
|
||||||
|
.comment .comment-content {
|
||||||
|
margin: 0.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.children {
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0 0 2.375rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
}
|
||||||
|
.children .comment {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.children .comment:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.children .form-control {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
}
|
||||||
|
.children .comment-from-avatar,
|
||||||
|
.children .comment .comment-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.children .comment .comment-content {
|
||||||
|
margin: 0.3125rem 0 0.3125rem;
|
||||||
|
}
|
||||||
|
.children .comment .comment-inner {
|
||||||
|
margin: 0.125rem 0 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Quotes ------------------------------------ */
|
/* Quotes ------------------------------------ */
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
|
@ -292,7 +292,7 @@ h3,
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -659,7 +659,7 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
dt + dd {
|
dt + dd {
|
||||||
@ -688,7 +688,7 @@ cite {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
import { options } from '@/helpers/schema';
|
|
||||||
import 'artalk/dist/ArtalkLite.css';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
commentKey: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { commentKey, title } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="comments"
|
|
||||||
class="comments py-5"
|
|
||||||
data-key={commentKey}
|
|
||||||
data-title={title}
|
|
||||||
data-server={options.settings.comments.server}
|
|
||||||
data-site={options.title}
|
|
||||||
>
|
|
||||||
评论正在加载中 ...
|
|
||||||
</div>
|
|
18
src/components/comment/Comment.astro
Normal file
18
src/components/comment/Comment.astro
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* This module is a reuse component which can be used in ajax loading.
|
||||||
|
*/
|
||||||
|
import CommentItem from '@/components/comment/CommentItem.astro';
|
||||||
|
import { parseComments } from '@/components/comment/artalk';
|
||||||
|
import type { CommentConfig, Comments } from '@/components/comment/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comments: Comments;
|
||||||
|
config: CommentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { comments, config } = Astro.props;
|
||||||
|
const parsedComments = await parseComments(comments.comments);
|
||||||
|
---
|
||||||
|
|
||||||
|
{parsedComments.map((item) => <CommentItem comment={item} config={config} depth={1} />)}
|
67
src/components/comment/CommentItem.astro
Normal file
67
src/components/comment/CommentItem.astro
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
import type { CommentConfig, CommentItem } from '@/components/comment/types';
|
||||||
|
import { formatLocalDate } from '@/helpers/formatter';
|
||||||
|
import { urlJoin } from '@/helpers/tools';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
depth: number;
|
||||||
|
comment: CommentItem;
|
||||||
|
config: CommentConfig;
|
||||||
|
pending?: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { comment, config, depth, pending } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li id={`atk-comment-${comment.id}`} class={`comment odd alt thread-odd thread-alt depth-${depth}`}>
|
||||||
|
<article id={`div-comment-${comment.id}`} class="comment-body">
|
||||||
|
<div class="comment-avatar flex-avatar">
|
||||||
|
<img
|
||||||
|
alt={comment.nick}
|
||||||
|
src={urlJoin(config.frontend_conf.gravatar.mirror, `${comment.email_encrypted}?s=80&d=mm&r=g`)}
|
||||||
|
class="avatar avatar-40 photo"
|
||||||
|
height="40"
|
||||||
|
width="40"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="comment-inner">
|
||||||
|
<div class="comment-author fw-bold">
|
||||||
|
{
|
||||||
|
comment.link === '' ? (
|
||||||
|
comment.nick
|
||||||
|
) : (
|
||||||
|
<a href={comment.link ?? '#'} rel="nofollow" target="_blank">
|
||||||
|
{comment.nick}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
<Fragment set:html={comment.content} />
|
||||||
|
{pending && <p class="text-xs text-danger tip-comment-check">您的评论正在等待审核中...</p>}
|
||||||
|
</div>
|
||||||
|
<div class="comment-footer text-xs text-muted">
|
||||||
|
<time class="me-2">{formatLocalDate(comment.date)}</time>
|
||||||
|
<span class="text-secondary comment-reply-link" data-rid={comment.id}>回复</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{
|
||||||
|
comment.children &&
|
||||||
|
(depth === 1 ? (
|
||||||
|
<ul class="children">
|
||||||
|
{comment.children.map((childComment) => (
|
||||||
|
<Astro.self comment={childComment} config={config} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{comment.children.map((childComment) => (
|
||||||
|
<Astro.self comment={childComment} config={config} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</li>
|
80
src/components/comment/Comments.astro
Normal file
80
src/components/comment/Comments.astro
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
import { getConfig, loadComments } from '@/components/comment/artalk';
|
||||||
|
import Comment from '@/components/comment/Comment.astro';
|
||||||
|
|
||||||
|
// This is a component which loads comments and renders it on server-side.
|
||||||
|
interface Props {
|
||||||
|
commentKey: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { commentKey, title } = Astro.props;
|
||||||
|
const config = await getConfig();
|
||||||
|
const comments = config != null ? await loadComments(commentKey, 0, config) : null;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div id="comments" class="comments py-5">
|
||||||
|
{
|
||||||
|
comments != null && config != null ? (
|
||||||
|
<>
|
||||||
|
<div class="h5 mb-4 comment-total-count">
|
||||||
|
评论 <small class="font-theme text-sm">({comments.count})</small>
|
||||||
|
</div>
|
||||||
|
<div id="respond" class="comment-respond mb-3 mb-md-4">
|
||||||
|
<form method="post" action="/comments/new" id="commentForm" class="comment-form">
|
||||||
|
<div class="comment-from-avatar flex-avatar">
|
||||||
|
<img
|
||||||
|
alt="头像"
|
||||||
|
src="/images/default-avatar.png"
|
||||||
|
class="avatar avatar-40 photo avatar-default"
|
||||||
|
height="40"
|
||||||
|
width="40"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="comment-from-input flex-fill">
|
||||||
|
<div class="comment-form-text mb-3">
|
||||||
|
<textarea id="content" name="content" class="form-control" rows="3" required />
|
||||||
|
</div>
|
||||||
|
<div class="comment-form-info row g-2 g-md-3 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<input class="form-control" placeholder="昵称" name="name" type="text" required="required" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<input class="form-control" name="email" placeholder="邮箱" type="email" required="required" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<input hidden name="page_key" type="text" value={commentKey} />
|
||||||
|
<input hidden name="rid" type="text" value="0" />
|
||||||
|
<input class="form-control" placeholder="网址" name="link" type="url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-submit text-end">
|
||||||
|
<input type="button" id="cancel-comment-reply-link" class="btn btn-light me-1" value="再想想" hidden />
|
||||||
|
<input name="submit" type="submit" id="submit" class="btn btn-primary" value="发表评论" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<ul class="comment-list">
|
||||||
|
<Comment comments={comments} config={config} />
|
||||||
|
</ul>
|
||||||
|
{comments.roots_count > config.frontend_conf.pagination.pageSize && (
|
||||||
|
<div class="text-center mt-3 mt-md-4">
|
||||||
|
<button
|
||||||
|
id="comments-next-button"
|
||||||
|
data-key={commentKey}
|
||||||
|
data-size={config.frontend_conf.pagination.pageSize}
|
||||||
|
data-offset={config.frontend_conf.pagination.pageSize}
|
||||||
|
class="btn btn-light"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'评论加载失败 ❌'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
140
src/components/comment/artalk.ts
Normal file
140
src/components/comment/artalk.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import type {
|
||||||
|
Comment,
|
||||||
|
CommentConfig,
|
||||||
|
CommentItem,
|
||||||
|
CommentReq,
|
||||||
|
CommentResp,
|
||||||
|
Comments,
|
||||||
|
ErrorResp,
|
||||||
|
} from '@/components/comment/types';
|
||||||
|
import { increaseViews } from '@/helpers/db/query';
|
||||||
|
import { options } from '@/helpers/schema';
|
||||||
|
import { urlJoin } from '@/helpers/tools';
|
||||||
|
import { getSecret } from 'astro:env/server';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import * as querystring from 'node:querystring';
|
||||||
|
import { transform } from 'ultrahtml';
|
||||||
|
import sanitize from 'ultrahtml/transformers/sanitize';
|
||||||
|
|
||||||
|
// Access the artalk in internal docker host when it was deployed on zeabur.
|
||||||
|
const server = import.meta.env.PROD ? `http://${getSecret('ARTALK_HOST')}:23366` : options.settings.comments.server;
|
||||||
|
|
||||||
|
export const getConfig = async (): Promise<CommentConfig | null> => {
|
||||||
|
const data = await fetch(urlJoin(server, '/api/v2/conf'))
|
||||||
|
.then((response) => response.json())
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return data != null ? (data as CommentConfig) : data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadComments = async (key: string, offset: number, config: CommentConfig): Promise<Comments | null> => {
|
||||||
|
const query = querystring.stringify({
|
||||||
|
limit: config.frontend_conf.pagination.pageSize,
|
||||||
|
offset: offset,
|
||||||
|
flat_mode: false,
|
||||||
|
page_key: key,
|
||||||
|
site_name: options.title,
|
||||||
|
});
|
||||||
|
const data = await fetch(urlJoin(server, `/api/v2/comments?${query}`))
|
||||||
|
.then((response) => response.json())
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increase the PV.
|
||||||
|
await increaseViews(key);
|
||||||
|
|
||||||
|
return data != null ? (data as Comments) : data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createComment = async (req: CommentReq): Promise<ErrorResp | CommentResp> => {
|
||||||
|
const response = await fetch(urlJoin(server, '/api/v2/comments'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...req, site_name: options.title, rid: req.rid ? Number(req.rid) : 0 }),
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response === null) {
|
||||||
|
return { msg: 'failed to create comment' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return (await response.json()) as ErrorResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as CommentResp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseComments = async (comments: Comment[]): Promise<CommentItem[]> => {
|
||||||
|
const parsedComments = await Promise.all(
|
||||||
|
comments.map(async (comment) => ({ ...comment, content: await parseContent(comment.content) })),
|
||||||
|
);
|
||||||
|
const childComments = _.groupBy(
|
||||||
|
parsedComments.filter((comment) => !rootCommentFilter(comment)),
|
||||||
|
(c) => c.rid,
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedComments.filter(rootCommentFilter).map((comment) => commentItems(comment, childComments));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootCommentFilter = (comment: Comment): boolean =>
|
||||||
|
comment.rid === 0 || comment.rid === null || typeof comment.rid === 'undefined';
|
||||||
|
|
||||||
|
const commentItems = (comment: Comment, childComments: _.Dictionary<Comment[]>): CommentItem => {
|
||||||
|
const children = childComments[`${comment.id}`];
|
||||||
|
if (typeof children === 'undefined') {
|
||||||
|
return 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, [
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
allowComments: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
63
src/components/comment/types.ts
Normal file
63
src/components/comment/types.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
export interface CommentConfig {
|
||||||
|
frontend_conf: FrontendConf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontendConf {
|
||||||
|
flatMode: boolean;
|
||||||
|
gravatar: Gravatar;
|
||||||
|
pagination: Pagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Gravatar {
|
||||||
|
mirror: string;
|
||||||
|
params: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pagination {
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comments {
|
||||||
|
comments: Comment[];
|
||||||
|
count: number;
|
||||||
|
roots_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
nick: string;
|
||||||
|
email_encrypted: string;
|
||||||
|
link: string;
|
||||||
|
date: string;
|
||||||
|
rid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PV {
|
||||||
|
page_key: string;
|
||||||
|
page_title: string;
|
||||||
|
site_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentItem extends Comment {
|
||||||
|
children?: CommentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment request
|
||||||
|
export interface CommentReq {
|
||||||
|
page_key: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
link?: string;
|
||||||
|
content: string;
|
||||||
|
rid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment response
|
||||||
|
export interface CommentResp extends Comment {
|
||||||
|
is_pending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResp {
|
||||||
|
msg: string;
|
||||||
|
}
|
@ -11,10 +11,17 @@ interface Props {
|
|||||||
const { post } = Astro.props;
|
const { post } = Astro.props;
|
||||||
const postURL = urlJoin(options.website, post.permalink);
|
const postURL = urlJoin(options.website, post.permalink);
|
||||||
const qq = querystring.stringify({
|
const qq = querystring.stringify({
|
||||||
query: `url=${postURL}&pics=${urlJoin(options.website, post.cover.src)}&summary=${post.summary}`,
|
url: postURL,
|
||||||
|
pics: urlJoin(options.website, post.cover.src),
|
||||||
|
summary: post.summary,
|
||||||
});
|
});
|
||||||
const weibo = querystring.stringify({
|
const weibo = querystring.stringify({
|
||||||
query: `url=${postURL}&type=button&language=zh_cn&pic=${urlJoin(options.website, post.cover.src)}&searchPic=true&title=【${post.title}】${post.summary}`,
|
url: postURL,
|
||||||
|
type: 'button',
|
||||||
|
language: 'zh_cn',
|
||||||
|
pic: urlJoin(options.website, post.cover.src),
|
||||||
|
searchPic: true,
|
||||||
|
title: `【${post.title}】${post.summary}`,
|
||||||
});
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -3,7 +3,11 @@ import Image from '@/components/image/Image.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';
|
||||||
|
|
||||||
const { slug } = Astro.params;
|
interface Props {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = Astro.props;
|
||||||
const post = posts.find((post) => post.slug === slug);
|
const post = posts.find((post) => post.slug === slug);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return Astro.redirect('/404');
|
return Astro.redirect('/404');
|
||||||
|
@ -42,7 +42,7 @@ settings:
|
|||||||
icpNo: 皖ICP备2021002315号-2
|
icpNo: 皖ICP备2021002315号-2
|
||||||
locale: zh-CN
|
locale: zh-CN
|
||||||
timeZone: Asia/Shanghai
|
timeZone: Asia/Shanghai
|
||||||
timeFormat: yyyy年MM月dd日
|
timeFormat: yyyy-MM-dd
|
||||||
twitter: amehochan
|
twitter: amehochan
|
||||||
post:
|
post:
|
||||||
# asc or desc
|
# asc or desc
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
title: 使用 Java 进行面向对象编程
|
|
||||||
slug: data-oriented-programming-in-java
|
|
||||||
date: 2024-05-29 22:42:12
|
|
||||||
updated: 2024-05-29 23:15:12
|
|
||||||
tags:
|
|
||||||
- 读书
|
|
||||||
category: 杂谈
|
|
||||||
summary: 藏书如山积,读书如水流。山形有限度,水流无时休。
|
|
||||||
cover: /images/2024/05/2024052912190600.jpg
|
|
||||||
published: false
|
|
||||||
---
|
|
35
src/helpers/container.ts
Normal file
35
src/helpers/container.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { getContainerRenderer } from '@astrojs/mdx';
|
||||||
|
import type { AstroRenderer, SSRLoadedRenderer } from 'astro';
|
||||||
|
import { experimental_AstroContainer as AstroContainer, type ContainerRenderOptions } from 'astro/container';
|
||||||
|
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
|
||||||
|
|
||||||
|
// FIXME: This is a monkey patch which should be removed after bumping the astro to 4.10.3
|
||||||
|
export async function loadRenderers(renderers: AstroRenderer[]) {
|
||||||
|
const loadedRenderers = await Promise.all(
|
||||||
|
renderers.map(async (renderer) => {
|
||||||
|
const mod = await import(/* @vite-ignore */ renderer.serverEntrypoint);
|
||||||
|
if (typeof mod.default !== 'undefined') {
|
||||||
|
return {
|
||||||
|
...renderer,
|
||||||
|
ssr: mod.default,
|
||||||
|
} as SSRLoadedRenderer;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderers = await loadRenderers([getContainerRenderer()]);
|
||||||
|
const container = await AstroContainer.create({ renderers: renderers });
|
||||||
|
|
||||||
|
// We only want to make sure the container instance is singleton.
|
||||||
|
export const partialRender = async (
|
||||||
|
component: AstroComponentFactory,
|
||||||
|
options?: ContainerRenderOptions,
|
||||||
|
): Promise<string> => {
|
||||||
|
const html = await container.renderToString(component, options);
|
||||||
|
// Remove this doctype by default.
|
||||||
|
return html.startsWith('<!DOCTYPE html>') ? html.slice(15) : html;
|
||||||
|
};
|
@ -43,6 +43,15 @@ export const latestComments = async (): Promise<Comment[]> => {
|
|||||||
|
|
||||||
const generateKey = (slug: string): string => urlJoin(options.website, '/posts', slug, '/');
|
const generateKey = (slug: string): string => urlJoin(options.website, '/posts', slug, '/');
|
||||||
|
|
||||||
|
export const increaseViews = async (pageKey: string) => {
|
||||||
|
await db
|
||||||
|
.update(atk_pages)
|
||||||
|
.set({
|
||||||
|
pv: sql`${atk_pages.pv} + 1`,
|
||||||
|
})
|
||||||
|
.where(eq(atk_pages.key, sql`${pageKey}`));
|
||||||
|
};
|
||||||
|
|
||||||
export const increaseLikes = async (slug: string): Promise<{ likes: number; token: string }> => {
|
export const increaseLikes = async (slug: string): Promise<{ likes: number; token: string }> => {
|
||||||
const pageKey = generateKey(slug);
|
const pageKey = generateKey(slug);
|
||||||
const token = makeToken(250);
|
const token = makeToken(250);
|
||||||
|
@ -58,3 +58,11 @@ export const formatShowDate = (date: Date) => {
|
|||||||
.setLocale(options.settings.locale)
|
.setLocale(options.settings.locale)
|
||||||
.toFormat(options.settings.timeFormat);
|
.toFormat(options.settings.timeFormat);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatLocalDate = (source: string) => {
|
||||||
|
const date = new Date(source);
|
||||||
|
return DateTime.fromJSDate(date)
|
||||||
|
.setZone(options.settings.timeZone)
|
||||||
|
.setLocale(options.settings.locale)
|
||||||
|
.toFormat(options.settings.timeFormat);
|
||||||
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import ArtalkComment from '@/components/comment/Artalk.astro';
|
import Comments from '@/components/comment/Comments.astro';
|
||||||
import Image from '@/components/image/Image.astro';
|
import Image from '@/components/image/Image.astro';
|
||||||
import PageMeta from '@/components/meta/PageMeta.astro';
|
import PageMeta from '@/components/meta/PageMeta.astro';
|
||||||
import FriendLinks from '@/components/page/friend/FriendLinks.astro';
|
import FriendLinks from '@/components/page/friend/FriendLinks.astro';
|
||||||
@ -29,11 +29,7 @@ const { Content } = await page.render();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{page.friend && <FriendLinks />}
|
{page.friend && <FriendLinks />}
|
||||||
{
|
{page.comments && <Comments commentKey={urlJoin(options.website, page.permalink, '/')} title={page.title} />}
|
||||||
page.comments && (
|
|
||||||
<ArtalkComment commentKey={urlJoin(options.website, page.permalink, '/')} title={page.title} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import ArtalkComment from '@/components/comment/Artalk.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';
|
||||||
import Share from '@/components/like/Share.astro';
|
import Share from '@/components/like/Share.astro';
|
||||||
@ -51,7 +51,7 @@ const { Content } = await post.render();
|
|||||||
<Share {post} />
|
<Share {post} />
|
||||||
{
|
{
|
||||||
post.comments && (
|
post.comments && (
|
||||||
<ArtalkComment commentKey={urlJoin(options.website, post.permalink, '/')} title={post.title} />
|
<Comments commentKey={urlJoin(options.website, post.permalink, '/')} title={post.title} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
20
src/pages/comments/avatar.ts
Normal file
20
src/pages/comments/avatar.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getConfig } from '@/components/comment/artalk';
|
||||||
|
import { urlJoin } from '@/helpers/tools';
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const email = url.searchParams.get('email');
|
||||||
|
if (email == null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
if (config === null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the email into Gravatar hash.
|
||||||
|
const hash = crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
|
||||||
|
return new Response(urlJoin(config.frontend_conf.gravatar.mirror, `${hash}?d=mm&s=80`));
|
||||||
|
};
|
29
src/pages/comments/list.ts
Normal file
29
src/pages/comments/list.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { getConfig, loadComments } from '@/components/comment/artalk';
|
||||||
|
import Comment from '@/components/comment/Comment.astro';
|
||||||
|
import { partialRender } from '@/helpers/container';
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const key = url.searchParams.get('key');
|
||||||
|
if (key == null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = url.searchParams.get('offset');
|
||||||
|
if (offset == null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
if (config === null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await loadComments(key, Number(offset), config);
|
||||||
|
if (comments === null) {
|
||||||
|
return new Response('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await partialRender(Comment, { props: { comments: comments, config: config } });
|
||||||
|
return new Response(html);
|
||||||
|
};
|
21
src/pages/comments/new.ts
Normal file
21
src/pages/comments/new.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import CommentItem from '@/components/comment/CommentItem.astro';
|
||||||
|
import { createComment, getConfig } from '@/components/comment/artalk';
|
||||||
|
import type { CommentReq } from '@/components/comment/types';
|
||||||
|
import { partialRender } from '@/helpers/container';
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const body = (await request.json()) as CommentReq;
|
||||||
|
const resp = await createComment(body);
|
||||||
|
|
||||||
|
if ('msg' in resp) {
|
||||||
|
return new Response(`<li>${resp.msg}</li>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const content = await partialRender(CommentItem, {
|
||||||
|
props: { depth: 2, comment: resp, pending: resp.is_pending, config: config },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(content);
|
||||||
|
};
|
@ -1,17 +1,13 @@
|
|||||||
import PostContent from '@/components/page/post/PostContent.astro';
|
import PostContent from '@/components/page/post/PostContent.astro';
|
||||||
|
import { partialRender } from '@/helpers/container';
|
||||||
import { options, posts, type Post } from '@/helpers/schema';
|
import { options, posts, type Post } from '@/helpers/schema';
|
||||||
import { urlJoin } from '@/helpers/tools';
|
import { urlJoin } from '@/helpers/tools';
|
||||||
import { getContainerRenderer } from '@astrojs/mdx';
|
|
||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import type { AstroRenderer, SSRLoadedRenderer } from 'astro';
|
|
||||||
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
|
||||||
import { ELEMENT_NODE, TEXT_NODE, transform, walk, type TextNode } from 'ultrahtml';
|
import { ELEMENT_NODE, TEXT_NODE, transform, walk, type TextNode } from 'ultrahtml';
|
||||||
import sanitize from 'ultrahtml/transformers/sanitize';
|
import sanitize from 'ultrahtml/transformers/sanitize';
|
||||||
|
|
||||||
const cleanupContent = async (html: string) => {
|
const cleanupContent = async (html: string) => {
|
||||||
const content = html.startsWith('<!DOCTYPE html>') ? html.slice(15) : html;
|
return await transform(html, [
|
||||||
|
|
||||||
return await transform(content, [
|
|
||||||
async (node) => {
|
async (node) => {
|
||||||
await walk(node, (node) => {
|
await walk(node, (node) => {
|
||||||
if (node.type === ELEMENT_NODE) {
|
if (node.type === ELEMENT_NODE) {
|
||||||
@ -56,34 +52,14 @@ const cleanupContent = async (html: string) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy from astro source code. I don't know why this virtual vite module didn't works.
|
|
||||||
export async function loadRenderers(renderers: AstroRenderer[]) {
|
|
||||||
const loadedRenderers = await Promise.all(
|
|
||||||
renderers.map(async (renderer) => {
|
|
||||||
const mod = await import(/* @vite-ignore */ renderer.serverEntrypoint);
|
|
||||||
if (typeof mod.default !== 'undefined') {
|
|
||||||
return {
|
|
||||||
...renderer,
|
|
||||||
ssr: mod.default,
|
|
||||||
} as SSRLoadedRenderer;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderPostsContent = async (feedPosts: Post[]): Promise<Map<string, string>> => {
|
const renderPostsContent = async (feedPosts: Post[]): Promise<Map<string, string>> => {
|
||||||
const contents = new Map<string, string>();
|
const contents = new Map<string, string>();
|
||||||
|
|
||||||
if (options.settings.feed.full) {
|
if (options.settings.feed.full) {
|
||||||
const renderers = await loadRenderers([getContainerRenderer()]);
|
|
||||||
const container = await AstroContainer.create({ renderers: renderers });
|
|
||||||
const promises = feedPosts.map(async (post) => ({
|
const promises = feedPosts.map(async (post) => ({
|
||||||
key: post.slug,
|
key: post.slug,
|
||||||
value: await container.renderToString(PostContent, {
|
value: await partialRender(PostContent, {
|
||||||
params: {
|
props: {
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -8,23 +8,23 @@ export const POST: APIRoute = async ({ params, request }) => {
|
|||||||
// Increase.
|
// Increase.
|
||||||
if (resp.action === 'increase') {
|
if (resp.action === 'increase') {
|
||||||
if (typeof slug === 'undefined') {
|
if (typeof slug === 'undefined') {
|
||||||
return new Response(JSON.stringify({ likes: 0, token: '' }));
|
return Response.json({ likes: 0, token: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { likes, token } = await increaseLikes(slug);
|
const { likes, token } = await increaseLikes(slug);
|
||||||
return new Response(JSON.stringify({ likes: likes, token: token }));
|
return Response.json({ likes: likes, token: token });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrease.
|
// Decrease.
|
||||||
if (resp.action === 'decrease' && resp.token !== '') {
|
if (resp.action === 'decrease' && resp.token !== '') {
|
||||||
if (typeof slug === 'undefined') {
|
if (typeof slug === 'undefined') {
|
||||||
return new Response(JSON.stringify({ likes: 0 }));
|
return Response.json({ likes: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await decreaseLikes(slug, resp.token);
|
await decreaseLikes(slug, resp.token);
|
||||||
const likes = await queryLikes(slug);
|
const likes = await queryLikes(slug);
|
||||||
return new Response(JSON.stringify({ likes: likes }));
|
return Response.json({ likes: likes });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ likes: 0 }));
|
return Response.json({ likes: 0 });
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user