diff --git a/.vscode/settings.json b/.vscode/settings.json
index 73ee75d..8a66df4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -28,6 +28,7 @@
"cimage",
"ckenh",
"cnip",
+ "commentform",
"csvg",
"ctan",
"cynosura",
diff --git a/README.md b/README.md
index 19bb882..7b5eb16 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,6 @@ For instance, the [giscus](https://giscus.app) is an opinionated choice.
- [ ] Add music to the articles. Remain **54** posts.
- [ ] Clean up the legacy links in the weblog comments.
- [ ] External the article inner links with different target.
-- [ ] Artalk integration with custom stylesheet.
- [ ] Slide share components integration.
### Long Term Goals
diff --git a/astro.config.ts b/astro.config.ts
index daad670..b150148 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -23,6 +23,7 @@ export default defineConfig({
POSTGRES_USERNAME: envField.string({ context: 'server', access: 'secret' }),
POSTGRES_PASSWORD: envField.string({ context: 'server', access: 'secret' }),
POSTGRES_DATABASE: envField.string({ context: 'server', access: 'secret' }),
+ ARTALK_HOST: envField.string({ context: 'server', access: 'secret' }),
},
},
},
diff --git a/package-lock.json b/package-lock.json
index c843cfa..d47d21a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.4",
+ "marked": "^13.0.0",
"pg": "^8.12.0",
"qrcode-svg": "^1.1.0",
"ultrahtml": "^1.5.3"
@@ -32,7 +33,6 @@
"@types/qrcode-svg": "^1.1.4",
"@types/unist": "^3.0.2",
"aplayer": "^1.10.1",
- "artalk": "^2.8.7",
"bootstrap": "^5.3.3",
"prettier": "^3.3.2",
"prettier-plugin-astro": "^0.14.0",
@@ -2901,13 +2901,6 @@
"dev": true,
"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": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
@@ -3053,25 +3046,6 @@
"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": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz",
@@ -3722,16 +3696,6 @@
"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": {
"version": "1.0.1",
"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"
}
},
- "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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@@ -4859,16 +4813,6 @@
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -4938,17 +4882,6 @@
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -5450,10 +5383,9 @@
}
},
"node_modules/marked": {
- "version": "12.0.2",
- "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
- "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
- "dev": true,
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.0.tgz",
+ "integrity": "sha512-VTeDCd9txf4KLLljUZ0nljE/Incb9SrWuueE44QVuU0pkOdh4sfCeW1Z6lPcxyDRSVY6rm8db/0OPaN75RNUmw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
diff --git a/package.json b/package.json
index 3bf4861..93898ff 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.4",
+ "marked": "^13.0.0",
"pg": "^8.12.0",
"qrcode-svg": "^1.1.0",
"ultrahtml": "^1.5.3"
@@ -64,7 +65,6 @@
"@types/qrcode-svg": "^1.1.4",
"@types/unist": "^3.0.2",
"aplayer": "^1.10.1",
- "artalk": "^2.8.7",
"bootstrap": "^5.3.3",
"prettier": "^3.3.2",
"prettier-plugin-astro": "^0.14.0",
diff --git a/public/images/2024/05/2024052911583500.jpg b/public/images/2024/05/2024052911583500.jpg
index 1a65bce..f14a908 100644
Binary files a/public/images/2024/05/2024052911583500.jpg and b/public/images/2024/05/2024052911583500.jpg differ
diff --git a/public/images/default-avatar.png b/public/images/default-avatar.png
new file mode 100644
index 0000000..5e9d814
Binary files /dev/null and b/public/images/default-avatar.png differ
diff --git a/src/asserts/scripts/yufan.me.js b/src/asserts/scripts/yufan.me.js
index 94a3fbb..eb72487 100644
--- a/src/asserts/scripts/yufan.me.js
+++ b/src/asserts/scripts/yufan.me.js
@@ -1,5 +1,4 @@
import Aplayer from 'aplayer/dist/APlayer.min.js';
-import Artalk from 'artalk/dist/ArtalkLite';
import stickySidebar from './sticky-sidebar.js';
// Menu toggle.
@@ -87,16 +86,112 @@ document.querySelector('.global-search-close').addEventListener('click', (event)
});
// Loading the comments.
-const comment = document.querySelector('#comments');
-if (typeof comment !== 'undefined' && comment !== null) {
- const { key, title, server, site } = comment.dataset;
- Artalk.init({
- el: '#comments',
- pageKey: key,
- pageTitle: title,
- server: server,
- site: site,
+const comments = document.querySelector('#comments');
+if (typeof comments !== 'undefined' && comments !== null) {
+ const cancel = comments.querySelector('#cancel-comment-reply-link');
+ const replyForm = comments.querySelector('#respond');
+ const cancelReply = () => {
+ cancel.hidden = true;
+ replyForm.querySelector('input[name="rid"]').value = '0';
+ replyForm.querySelector('textarea[name="content"]').value = '';
+
+ // 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 '
评论失败';
+ });
+
+ 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.
@@ -104,7 +199,7 @@ const likeButton = document.querySelector('button.post-like');
const increaseLikes = (count) => {
count.textContent = Number.parseInt(count.textContent) + 1;
- fetch(`${window.location.href}/likes`, {
+ fetch('/likes', {
method: 'POST',
headers: {
'Content-type': 'application/json; charset=UTF-8',
@@ -125,7 +220,7 @@ const decreaseLikes = (count) => {
}
count.textContent = Number.parseInt(count.textContent) - 1;
- fetch(`${window.location.href}/likes`, {
+ fetch('/likes', {
method: 'POST',
headers: {
'Content-type': 'application/json; charset=UTF-8',
diff --git a/src/asserts/styles/globals.css b/src/asserts/styles/globals.css
index 61978f8..f87f91b 100644
--- a/src/asserts/styles/globals.css
+++ b/src/asserts/styles/globals.css
@@ -1059,7 +1059,7 @@ textarea.form-control {
position: relative;
line-height: 1;
white-space: nowrap;
- font-weight: bold;
+ font-weight: 600;
display: -ms-flexbox;
display: -webkit-box;
display: flex;
@@ -2610,7 +2610,7 @@ a:hover .overlay {
.post-content > table th,
.post-content div > table th {
- font-weight: 500;
+ font-weight: 600;
}
.post-content > table th,
@@ -2643,7 +2643,7 @@ a:hover .overlay {
.post-content caption {
background: var(--bg-light);
- font-weight: 500;
+ font-weight: 600;
padding: 0.5em;
text-align: center;
}
@@ -2847,7 +2847,7 @@ a:hover .overlay {
}
.widget_recent_comments ul li span {
- font-weight: 500;
+ font-weight: 600;
color: var(--color-dark);
margin-right: 5px;
}
@@ -3092,7 +3092,7 @@ a:hover .overlay {
}
.widget_calendar td#today {
- font-weight: bold;
+ font-weight: 600;
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 ------------------------------------ */
blockquote {
diff --git a/src/asserts/styles/reset.css b/src/asserts/styles/reset.css
index 844808f..575912c 100644
--- a/src/asserts/styles/reset.css
+++ b/src/asserts/styles/reset.css
@@ -292,7 +292,7 @@ h3,
h4,
h5,
h6 {
- font-weight: 500;
+ font-weight: 600;
margin: 0;
}
@@ -659,7 +659,7 @@ dd {
}
dt {
- font-weight: 500;
+ font-weight: 600;
}
dt + dd {
@@ -688,7 +688,7 @@ cite {
color: inherit;
font-size: 85%;
font-style: normal;
- font-weight: 500;
+ font-weight: 600;
line-height: 1.25;
}
diff --git a/src/components/comment/Artalk.astro b/src/components/comment/Artalk.astro
deleted file mode 100644
index 18d7b85..0000000
--- a/src/components/comment/Artalk.astro
+++ /dev/null
@@ -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;
----
-
-
diff --git a/src/components/comment/Comment.astro b/src/components/comment/Comment.astro
new file mode 100644
index 0000000..2a95a5e
--- /dev/null
+++ b/src/components/comment/Comment.astro
@@ -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) => )}
diff --git a/src/components/comment/CommentItem.astro b/src/components/comment/CommentItem.astro
new file mode 100644
index 0000000..d9f0ed6
--- /dev/null
+++ b/src/components/comment/CommentItem.astro
@@ -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;
+---
+
+
diff --git a/src/components/comment/Comments.astro b/src/components/comment/Comments.astro
new file mode 100644
index 0000000..df8fbad
--- /dev/null
+++ b/src/components/comment/Comments.astro
@@ -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;
+---
+
+
diff --git a/src/components/comment/artalk.ts b/src/components/comment/artalk.ts
new file mode 100644
index 0000000..d5a4973
--- /dev/null
+++ b/src/components/comment/artalk.ts
@@ -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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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): 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 => {
+ // Support paragraph in blank line.
+ const escapedContent = content.replace(/\r\n/g, '\n').replace(/(?');
+ 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,
+ }),
+ ]);
+};
diff --git a/src/components/comment/types.ts b/src/components/comment/types.ts
new file mode 100644
index 0000000..42d6d2b
--- /dev/null
+++ b/src/components/comment/types.ts
@@ -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;
+}
diff --git a/src/components/like/Share.astro b/src/components/like/Share.astro
index e3ca5e1..9f33dbe 100644
--- a/src/components/like/Share.astro
+++ b/src/components/like/Share.astro
@@ -11,10 +11,17 @@ interface Props {
const { post } = Astro.props;
const postURL = urlJoin(options.website, post.permalink);
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({
- 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}`,
});
---
diff --git a/src/components/page/post/PostContent.astro b/src/components/page/post/PostContent.astro
index fd216e1..4a9a524 100644
--- a/src/components/page/post/PostContent.astro
+++ b/src/components/page/post/PostContent.astro
@@ -3,7 +3,11 @@ import Image from '@/components/image/Image.astro';
import UnstyledMusicPlayer from '@/components/player/UnstyledMusicPlayer.astro';
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);
if (!post) {
return Astro.redirect('/404');
diff --git a/src/content/options/index.yml b/src/content/options/index.yml
index 439657a..1da44e5 100644
--- a/src/content/options/index.yml
+++ b/src/content/options/index.yml
@@ -42,7 +42,7 @@ settings:
icpNo: 皖ICP备2021002315号-2
locale: zh-CN
timeZone: Asia/Shanghai
- timeFormat: yyyy年MM月dd日
+ timeFormat: yyyy-MM-dd
twitter: amehochan
post:
# asc or desc
diff --git a/src/content/posts/2024/2024-06-13-data-oriented-programming-in-java.mdx b/src/content/posts/2024/2024-06-13-data-oriented-programming-in-java.mdx
deleted file mode 100644
index d3ff80c..0000000
--- a/src/content/posts/2024/2024-06-13-data-oriented-programming-in-java.mdx
+++ /dev/null
@@ -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
----
diff --git a/src/helpers/container.ts b/src/helpers/container.ts
new file mode 100644
index 0000000..4016dbf
--- /dev/null
+++ b/src/helpers/container.ts
@@ -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 => {
+ const html = await container.renderToString(component, options);
+ // Remove this doctype by default.
+ return html.startsWith('') ? html.slice(15) : html;
+};
diff --git a/src/helpers/db/query.ts b/src/helpers/db/query.ts
index ab9028b..0bb24e0 100644
--- a/src/helpers/db/query.ts
+++ b/src/helpers/db/query.ts
@@ -43,6 +43,15 @@ export const latestComments = async (): Promise => {
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 }> => {
const pageKey = generateKey(slug);
const token = makeToken(250);
diff --git a/src/helpers/formatter.ts b/src/helpers/formatter.ts
index 53f7d75..00d3bde 100644
--- a/src/helpers/formatter.ts
+++ b/src/helpers/formatter.ts
@@ -58,3 +58,11 @@ export const formatShowDate = (date: Date) => {
.setLocale(options.settings.locale)
.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);
+};
diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro
index 4dde6f9..0581f76 100644
--- a/src/layouts/PageLayout.astro
+++ b/src/layouts/PageLayout.astro
@@ -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 PageMeta from '@/components/meta/PageMeta.astro';
import FriendLinks from '@/components/page/friend/FriendLinks.astro';
@@ -29,11 +29,7 @@ const { Content } = await page.render();
{page.friend && }
- {
- page.comments && (
-
- )
- }
+ {page.comments && }
diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro
index 09cadb2..27c0c65 100644
--- a/src/layouts/PostLayout.astro
+++ b/src/layouts/PostLayout.astro
@@ -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 LikeButton from '@/components/like/LikeButton.astro';
import Share from '@/components/like/Share.astro';
@@ -51,7 +51,7 @@ const { Content } = await post.render();
{
post.comments && (
-
+
)
}
diff --git a/src/pages/comments/avatar.ts b/src/pages/comments/avatar.ts
new file mode 100644
index 0000000..8f2aedb
--- /dev/null
+++ b/src/pages/comments/avatar.ts
@@ -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`));
+};
diff --git a/src/pages/comments/list.ts b/src/pages/comments/list.ts
new file mode 100644
index 0000000..46e6b71
--- /dev/null
+++ b/src/pages/comments/list.ts
@@ -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);
+};
diff --git a/src/pages/comments/new.ts b/src/pages/comments/new.ts
new file mode 100644
index 0000000..ac1d31c
--- /dev/null
+++ b/src/pages/comments/new.ts
@@ -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(`${resp.msg}`);
+ }
+
+ const config = await getConfig();
+ const content = await partialRender(CommentItem, {
+ props: { depth: 2, comment: resp, pending: resp.is_pending, config: config },
+ });
+
+ return new Response(content);
+};
diff --git a/src/pages/feed.ts b/src/pages/feed.ts
index 279b2bf..9687412 100644
--- a/src/pages/feed.ts
+++ b/src/pages/feed.ts
@@ -1,17 +1,13 @@
import PostContent from '@/components/page/post/PostContent.astro';
+import { partialRender } from '@/helpers/container';
import { options, posts, type Post } from '@/helpers/schema';
import { urlJoin } from '@/helpers/tools';
-import { getContainerRenderer } from '@astrojs/mdx';
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 sanitize from 'ultrahtml/transformers/sanitize';
const cleanupContent = async (html: string) => {
- const content = html.startsWith('') ? html.slice(15) : html;
-
- return await transform(content, [
+ return await transform(html, [
async (node) => {
await walk(node, (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