feat(aside): recent comments card

This commit is contained in:
HPCesia 2025-03-18 20:30:57 +08:00
parent 5af071b0e7
commit 5e341cdfad
10 changed files with 280 additions and 1 deletions

View File

@ -0,0 +1,104 @@
---
import { commentConfig, siteConfig } from '@/config';
import I18nKey from '@i18n/I18nKey';
import { i18n } from '@i18n/translation';
import TwikooRecentCommentScript from './recent-comments/Twikoo.astro';
import WalineRecentCommentScript from './recent-comments/Waline.astro';
export function getTemplate() {
const recentCommentsCard = document.getElementById('recent-comments-card');
if (!recentCommentsCard) {
console.error('Recent comments card not found');
return;
}
const recentCommentsList = recentCommentsCard.querySelector('ul')!;
const recentCommentTemplate = recentCommentsList.querySelector('template');
return {
container: recentCommentsList,
template: recentCommentTemplate,
};
}
export function cleanCommentHtml(htmlString: string) {
return htmlString
.replaceAll(/<img.*?src="(.*?)"?[^>]+>/gi, i18n(I18nKey.commentReplaceImage)!)
.replaceAll(
/<a[^>]+?href=["']?([^"']+)["']?[^>]*>([^<]+)<\/a>/gi,
i18n(I18nKey.commentReplaceLink)!
)
.replaceAll(/<pre><code[^>]+?>.*?<\/pre>/gis, i18n(I18nKey.commentReplaceCode)!)
.replaceAll(/<[^>]+>/g, '');
}
export function createCommentItem(
container: HTMLUListElement,
template: HTMLTemplateElement,
data: {
avatarUrl: string;
commentContent: string;
commentUrl: string;
author: string;
time: Date;
}
) {
const item = template.content.firstElementChild!.cloneNode(true) as HTMLLIElement;
const avatarLinkEl = item.querySelector('a.avatar')!;
const avatarWrapper = avatarLinkEl.querySelector('div')!;
const avatarImg = new Image();
const comment = item.querySelector('a.line-clamp-2')!;
const time = item.querySelector('time')!;
avatarLinkEl.setAttribute('href', data.commentUrl);
comment.setAttribute('href', data.commentUrl);
avatarImg.src = data.avatarUrl;
avatarImg.alt = data.author;
avatarWrapper.appendChild(avatarImg);
comment.innerHTML = data.commentContent;
time.setAttribute('datetime', data.time.toISOString());
time.innerText = data.time.toLocaleDateString(siteConfig.lang.replace('_', '-'), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
container.appendChild(item);
}
---
<div id="recent-comments-card" class="card border-base-300 bg-base-200/25 border">
<div class="card-body px-4 py-2">
<div class="card-title">
{i18n(I18nKey.recentComments)}
</div>
<ul class="list">
<template>
<li class="list-row px-0">
<a class="avatar">
<div class="w-16 min-w-16 rounded-md"></div>
</a>
<div class="flex w-full flex-col justify-between">
<a class="hover:text-primary line-clamp-2 w-full overflow-clip"></a>
<time class="text-base-content/60 text-xs"></time>
</div>
</li>
</template>
</ul>
</div>
</div>
{
(() => {
switch (commentConfig.provider) {
case 'twikoo':
return <TwikooRecentCommentScript />;
case 'waline':
return <WalineRecentCommentScript />;
default:
throw new Error(
`Unsupported comment provider: '${commentConfig.provider}' for recent comments`
);
}
})()
}

View File

@ -0,0 +1,54 @@
<script>
import { asideConfig, commentConfig } from '@/config';
import { loadScript } from '@/scripts/utils';
import { CDN } from '@constants/cdn.mjs';
import {
cleanCommentHtml,
createCommentItem,
getTemplate,
} from '../ResentCommentsCard.astro';
const twikooConfig = commentConfig.twikoo!;
async function setup() {
const waitTwikoo = () => {
if (typeof twikoo === 'undefined') {
setTimeout(waitTwikoo, 100);
}
};
// 判断当前页面是否已经加载了 Twikoo 的脚本
// 如果没有加载,则动态加载
const twikooWrapper = document.getElementById('twikoo-wrap');
if (!twikooWrapper) {
await loadScript(CDN.twikoo);
}
waitTwikoo();
const recentComments = await twikoo.getRecentComments({
envId: twikooConfig.envId,
pageSize: asideConfig.recentComment.count,
});
const { container, template } = getTemplate()!;
if (container && !template) {
// 说明已经加载完毕, 模板被删除了
return;
}
recentComments.forEach((comment) =>
createCommentItem(container, template!, {
avatarUrl: comment.avatar,
commentContent: cleanCommentHtml(comment.comment),
commentUrl: `${comment.url}#${comment.id}`,
author: comment.nick,
time: new Date(comment.created),
})
);
container.removeChild(template!);
}
document.addEventListener('astro:page-load', setup);
await setup();
</script>

View File

@ -0,0 +1,66 @@
<script>
import { asideConfig, commentConfig } from '@/config';
import { joinUrl } from '@utils/url-utils';
import {
cleanCommentHtml,
createCommentItem,
getTemplate,
} from '../ResentCommentsCard.astro';
async function setup() {
const walineConfig = commentConfig.waline!;
const commentCount = asideConfig.recentComment.count;
const apiUrl = joinUrl(
walineConfig.serverURL,
`api/comment?type=recent&count=${commentCount}`
);
const response = await fetch(apiUrl, {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch recent comments');
}
const data: {
nick: string;
sticky: 0 | 1;
status: string;
link: string;
comment: string;
url: string;
user_id: string;
objectId: string;
browser: string;
os: string;
type: string;
label: string;
avatar: string;
orig: string;
addr: string;
like: number;
time: number;
}[] = (await response.json()).data;
const { container, template } = getTemplate()!;
if (container && !template) {
// 说明已经加载完毕, 模板被删除了
return;
}
data.forEach((item) =>
createCommentItem(container, template!, {
avatarUrl: item.avatar,
commentContent: cleanCommentHtml(item.comment),
commentUrl: `${item.url}#${item.objectId}`,
author: item.nick,
time: new Date(item.time),
})
);
container.removeChild(template!);
}
document.addEventListener('astro:page-load', setup);
await setup();
</script>

View File

@ -129,6 +129,11 @@ export const asideConfig: AsideConfig = {
contents: ['stats', 'tags'],
stats: ['post-count', 'last-updated', 'site-words-count', 'site-run-days'],
},
recentComment: {
enable: true,
count: 5,
showAvatar: true,
},
};
export const licenseConfig: LicenseConfig = {

View File

@ -12,6 +12,7 @@ enum I18nKey {
randomPost = 'randomPost',
comments = 'comments',
recentComments = 'recentComments',
subscribe = 'subscribe',
backLinks = 'backLinks',
@ -45,6 +46,11 @@ enum I18nKey {
publishedAt = 'publishedAt',
license = 'license',
/** The replace text for the comment content, used in Recent Comments. */
commentReplaceLink = 'commentReplaceLink',
commentReplaceImage = 'commentReplaceImage',
commentReplaceCode = 'commentReplaceCode',
/** Note in the top of drafts content in dev mode. This key supports markdown syntax, using `markdown-it`. */
draftDevNote = 'draftDevNote',
}

View File

@ -15,6 +15,7 @@ export const en: Translation = {
[Key.randomPost]: 'Random Post',
[Key.comments]: 'Comments',
[Key.recentComments]: 'Recent Comments',
[Key.subscribe]: 'Subscribe',
[Key.backLinks]: 'Back Links',
@ -48,6 +49,10 @@ export const en: Translation = {
[Key.publishedAt]: 'Published at',
[Key.license]: 'License',
[Key.commentReplaceLink]: '[Link]',
[Key.commentReplaceImage]: '[Image]',
[Key.commentReplaceCode]: '[Code]',
[Key.draftDevNote]:
'This is a draft and will only be displayed in `DEV` mode. To disable draft preview, please modify `buildConfig.showDraftsOnDev` to `false` in `src/config.ts`.',
};

View File

@ -15,6 +15,7 @@ export const zh_CN: Translation = {
[Key.randomPost]: '随机文章',
[Key.comments]: '评论',
[Key.recentComments]: '最新评论',
[Key.subscribe]: '订阅',
[Key.backLinks]: '反向链接',
@ -48,6 +49,10 @@ export const zh_CN: Translation = {
[Key.publishedAt]: '发布于',
[Key.license]: '许可协议',
[Key.commentReplaceLink]: '[链接]',
[Key.commentReplaceImage]: '[图片]',
[Key.commentReplaceCode]: '[代码]',
[Key.draftDevNote]:
'这是一篇草稿,只会在 `DEV` 模式下显示。关闭草稿预览,请修改 `src/config.ts` 中的 `buildConfig.showDraftsOnDev` 为 `false`。',
};

View File

@ -15,6 +15,7 @@ export const zh_TW: Translation = {
[Key.randomPost]: '隨機文章',
[Key.comments]: '評論',
[Key.recentComments]: '最新評論',
[Key.subscribe]: '訂閱',
[Key.backLinks]: '反向連結',
@ -48,6 +49,10 @@ export const zh_TW: Translation = {
[Key.publishedAt]: '發佈於',
[Key.license]: '許可協議',
[Key.commentReplaceLink]: '[連結]',
[Key.commentReplaceImage]: '[圖片]',
[Key.commentReplaceCode]: '[程式碼]',
[Key.draftDevNote]:
'這是一篇草稿,只會在 `DEV` 模式下顯示。關閉草稿預覽,請修改 `src/config.ts` 中的 `buildConfig.showDraftsOnDev` 為 `false`。',
};

View File

@ -1,6 +1,7 @@
---
import { siteConfig } from '@/config';
import { commentConfig, siteConfig } from '@/config';
import ProfileCard from '@components/aside/ProfileCard.astro';
import ResentCommentsCard from '@components/aside/ResentCommentsCard.astro';
import SiteInfoCard from '@components/aside/SiteInfoCard.astro';
import CategoryBar from '@components/misc/CategoryBar.astro';
import PostsPage from '@components/PostsPage.astro';
@ -34,4 +35,7 @@ const categories = await getCategories();
<ProfileCard />
<SiteInfoCard />
</Fragment>
<Fragment slot="aside-sticky">
{commentConfig.enable && commentConfig.provider !== 'giscus' && <ResentCommentsCard />}
</Fragment>
</MainLayout>

View File

@ -361,6 +361,31 @@ export type AsideConfig = {
*/
stats: ('post-count' | 'last-updated' | 'site-words-count' | 'site-run-days')[];
};
/**
* Recent comments card.
*
*
*/
recentComment: {
/**
* Whether to enable the recent comments card.
*
*
*/
enable: boolean;
/**
* The number of recent comments displayed.
*
*
*/
count: number;
/**
* Whether to show the avatar of the commenter.
*
*
*/
showAvatar: boolean;
};
};
export type LicenseConfig = {