Commit 5e341cd

HPCesia <me@hpcesia.com>
2025-03-18 12:30:57
feat(aside): recent comments card
1 parent 5af071b
src/components/aside/recent-comments/Twikoo.astro
@@ -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>
src/components/aside/recent-comments/Waline.astro
@@ -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>
src/components/aside/ResentCommentsCard.astro
@@ -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`
+        );
+    }
+  })()
+}
src/i18n/langs/en.ts
@@ -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`.',
 };
src/i18n/langs/zh_CN.ts
@@ -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`。',
 };
src/i18n/langs/zh_TW.ts
@@ -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`。',
 };
src/i18n/I18nKey.ts
@@ -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',
 }
src/pages/[...page].astro
@@ -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>
src/types/config.ts
@@ -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 = {
src/config.ts
@@ -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 = {