feat: use new comment interface, the artalk now serve as the backend. (#42)

This commit is contained in:
Yufan Sheng 2024-06-17 02:56:39 +08:00
parent 89ddfb2dd4
commit e8c3937626
Signed by: syhily
GPG Key ID: 9D18A22A7DCD5A9B
30 changed files with 831 additions and 173 deletions

View File

@ -28,6 +28,7 @@
"cimage", "cimage",
"ckenh", "ckenh",
"cnip", "cnip",
"commentform",
"csvg", "csvg",
"ctan", "ctan",
"cynosura", "cynosura",

View File

@ -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

View File

@ -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
View File

@ -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"

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -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',

View File

@ -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 {

View File

@ -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;
} }

View File

@ -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>

View 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} />)}

View 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>

View 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>

View 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,
}),
]);
};

View 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;
}

View File

@ -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}`,
}); });
--- ---

View File

@ -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');

View File

@ -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

View File

@ -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
View 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;
};

View File

@ -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);

View File

@ -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);
};

View File

@ -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>

View File

@ -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>

View 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`));
};

View 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
View 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);
};

View File

@ -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,
}, },
}), }),

View File

@ -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 });
}; };