Commit 8c92120

HPCesia <me@hpcesia.com>
2025-02-10 12:56:26
feat: site info card
1 parent b0884c1
src/components/aside/siteinfo/Stats.astro
@@ -0,0 +1,83 @@
+---
+import { asideConfig, siteConfig } from '@/config';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import { getOrCreateRenderResult, getPostsCount } from '@utils/content-utils';
+import { Icon } from 'astro-icon/components';
+import { getCollection } from 'astro:content';
+---
+
+<div class="stats stats-vertical w-full">
+  {
+    asideConfig.siteInfo.stats.map(async (entry) => {
+      switch (entry) {
+        case 'post-count': {
+          const postCount = await getPostsCount();
+          return (
+            <div class="stat">
+              <div class="stat-title flex flex-row items-center gap-1">
+                <Icon name="material-symbols:folder-open-rounded" />
+                <span>{i18n(I18nKey.totalPosts)}</span>
+              </div>
+              <div class="stat-value text-base">{`${postCount} ${postCount > 1 ? i18n(I18nKey.postsCount) : i18n(I18nKey.postCount)}`}</div>
+            </div>
+          );
+        }
+        case 'last-updated':
+          return (
+            <div class="stat">
+              <div class="stat-title flex flex-row items-center gap-1">
+                <Icon name="material-symbols:refresh-rounded" />
+                <span>{i18n(I18nKey.lastUpdated)}</span>
+              </div>
+              <div class="stat-value text-base">
+                <time datetime={new Date().toISOString()}>
+                  {new Date().toLocaleDateString(siteConfig.lang.replace('_', '-'))}
+                </time>
+              </div>
+            </div>
+          );
+        case 'site-words-count':
+          return (
+            <div class="stat">
+              <div class="stat-title flex flex-row items-center gap-1">
+                <Icon name="material-symbols:docs-rounded" />
+                <span>{i18n(I18nKey.totalWords)}</span>
+              </div>
+              <div class="stat-value text-base">
+                {(async () => {
+                  const entries = await getCollection('posts');
+                  const words = await Promise.all(
+                    entries.map(async (entry) => {
+                      const { remarkPluginFrontmatter } = await getOrCreateRenderResult(entry);
+                      return remarkPluginFrontmatter.words as number;
+                    })
+                  );
+                  const total = words.reduce((acc, cur) => acc + cur, 0);
+                  return `${total} ${total > 1 ? i18n(I18nKey.wordsCount) : i18n(I18nKey.wordCount)}`;
+                })()}
+              </div>
+            </div>
+          );
+        case 'site-run-days':
+          return (
+            <div class="stat">
+              <div class="stat-title flex flex-row items-center gap-1">
+                <Icon name="material-symbols:calendar-clock-rounded" />
+                <span>{i18n(I18nKey.runTime)}</span>
+              </div>
+              <div class="stat-value text-base">
+                <time
+                  datetime={siteConfig.createAt.toISOString()}
+                  data-force-relative="true"
+                  data-no-ago="true"
+                >
+                  {siteConfig.createAt.toLocaleDateString(siteConfig.lang.replace('_', '-'))}
+                </time>
+              </div>
+            </div>
+          );
+      }
+    })
+  }
+</div>
src/components/aside/siteinfo/Tags.astro
@@ -0,0 +1,49 @@
+---
+import Button from '@components/widgets/Button.astro';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import { getTags, getTagUrl } from '@utils/content-utils';
+const tagsMap = await getTags();
+---
+
+<div id="aside-siteinfo-tags">
+  <div
+    class="relative flex max-h-40 flex-wrap gap-0 overflow-hidden after:pointer-events-none after:absolute after:bottom-0 after:hidden after:h-24 after:w-full after:content-end after:bg-gradient-to-b after:from-black/0 after:to-black/15"
+  >
+    {
+      Array.from(tagsMap.entries()).map(([tag, count]) => (
+        <Button href={getTagUrl(tag)} title={`${tag}`} class="btn-ghost btn-primary btn-sm">
+          {tag}
+          <sup>{count}</sup>
+        </Button>
+      ))
+    }
+  </div>
+  <div class="btn bg-base-200/50 btn-wide hidden rounded-sm hover:brightness-125">
+    {i18n(I18nKey.more)}
+  </div>
+</div>
+
+<script>
+  function initTags() {
+    const tags = document.getElementById('aside-siteinfo-tags');
+    const tagsList = tags?.children[0] as HTMLDivElement;
+    const moreBtn = tags?.children[1] as HTMLDivElement;
+
+    const clientHeight = tagsList.clientHeight;
+    const scrollHeight = tagsList.scrollHeight;
+
+    if (scrollHeight > clientHeight) {
+      tagsList.classList.remove('after:hidden');
+      moreBtn.classList.remove('hidden');
+    }
+
+    moreBtn.addEventListener('click', () => {
+      tagsList.classList.remove('max-h-40');
+      tagsList.classList.add('after:hidden');
+      moreBtn.classList.add('hidden');
+    });
+  }
+
+  document.addEventListener('astro:page-load', initTags);
+</script>
src/components/aside/SiteInfoCard.astro
@@ -0,0 +1,31 @@
+---
+import { asideConfig } from '@/config';
+import Stats from './siteinfo/Stats.astro';
+import Tags from './siteinfo/Tags.astro';
+---
+
+<div class="card border-base-300 border-2" transition:name="site-info-card">
+  <div class="card-body px-4 py-2">
+    <ul>
+      {
+        asideConfig.siteInfo.contents.map((part) => {
+          const defaultClass = 'border-base-content/10 not-first:border-t';
+          switch (part) {
+            case 'stats':
+              return (
+                <li class:list={['', defaultClass]}>
+                  <Stats />
+                </li>
+              );
+            case 'tags':
+              return (
+                <li class:list={['py-2', defaultClass]}>
+                  <Tags />
+                </li>
+              );
+          }
+        })
+      }
+    </ul>
+  </div>
+</div>
src/i18n/langs/en.ts
@@ -19,6 +19,11 @@ export const en: Translation = {
   [Key.uncategorized]: 'Uncategorized',
   [Key.untagged]: 'No Tags',
 
+  [Key.totalPosts]: 'Total Posts',
+  [Key.totalWords]: 'Total Words',
+  [Key.lastUpdated]: 'Last Updated',
+  [Key.runTime]: 'Run Time',
+
   [Key.wordCount]: 'word',
   [Key.wordsCount]: 'words',
   [Key.minuteCount]: 'minute',
src/i18n/langs/zh_CN.ts
@@ -19,6 +19,11 @@ export const zh_CN: Translation = {
   [Key.uncategorized]: '未分类',
   [Key.untagged]: '无标签',
 
+  [Key.totalPosts]: '文章总数',
+  [Key.totalWords]: '字数总计',
+  [Key.lastUpdated]: '最后更新',
+  [Key.runTime]: '运行时间',
+
   [Key.wordCount]: '字',
   [Key.wordsCount]: '字',
   [Key.minuteCount]: '分钟',
src/i18n/langs/zh_TW.ts
@@ -19,6 +19,11 @@ export const zh_TW: Translation = {
   [Key.uncategorized]: '未分類',
   [Key.untagged]: '無標籤',
 
+  [Key.totalPosts]: '文章總數',
+  [Key.totalWords]: '字數總計',
+  [Key.lastUpdated]: '最後更新',
+  [Key.runTime]: '運行時間',
+
   [Key.wordCount]: '字',
   [Key.wordsCount]: '字',
   [Key.minuteCount]: '分鐘',
src/i18n/I18nKey.ts
@@ -16,6 +16,11 @@ enum I18nKey {
   uncategorized = 'uncategorized',
   untagged = 'untagged',
 
+  totalPosts = 'totalPosts',
+  totalWords = 'totalWords',
+  lastUpdated = 'lastUpdated',
+  runTime = 'runTime',
+
   wordCount = 'wordCount',
   wordsCount = 'wordsCount',
   minuteCount = 'minuteCount',
src/types/config.ts
@@ -1,5 +1,6 @@
 import type I18nKey from '@i18n/I18nKey';
 
+// ============================================================================
 export type Favicon = {
   /**
    * The URL of the favicon.
@@ -85,6 +86,8 @@ export type ButtonSubConfig<T extends string> = T extends 'text'
       )
     : never;
 
+// ============================================================================
+
 export type SiteConfig = {
   /**
    * The title of the site.
@@ -110,6 +113,12 @@ export type SiteConfig = {
    * 站点的 favicon。
    */
   favicon: Favicon[];
+  /**
+   * The time when the site was created.
+   *
+   * 站点建立时间
+   */
+  createAt: Date;
   /**
    * The number of posts displayed per page.
    *
@@ -207,6 +216,23 @@ export type ToolBarConfig = {
   items: ButtonSubConfig<'icon'>[];
 };
 
+export type AsideConfig = {
+  siteInfo: {
+    /**
+     * The contents displayed in the site info.
+     *
+     * 在站点信息中显示的内容。
+     */
+    contents: ('stats' | 'tags')[];
+    /**
+     * The stats displayed in the site info.
+     *
+     * 在站点信息中显示的统计数据。
+     */
+    stats: ('post-count' | 'last-updated' | 'site-words-count' | 'site-run-days')[];
+  };
+};
+
 export type LicenseConfig = {
   /**
    * Whether to enable the license.
src/config.ts
@@ -1,5 +1,6 @@
 import type {
   ArticleConfig,
+  AsideConfig,
   CommentConfig,
   FooterConfig,
   LicenseConfig,
@@ -20,6 +21,7 @@ export const siteConfig: SiteConfig = {
     // Leave this array empty to use the default favicon.
     // 留空数组以使用默认的 favicon。
   ],
+  createAt: new Date('2025-01-01'),
   postsPerPage: 10,
 };
 
@@ -79,6 +81,13 @@ export const toolBarConfig: ToolBarConfig = {
   items: [],
 };
 
+export const asideConfig: AsideConfig = {
+  siteInfo: {
+    contents: ['stats', 'tags'],
+    stats: ['post-count', 'last-updated', 'site-words-count', 'site-run-days'],
+  },
+};
+
 export const licenseConfig: LicenseConfig = {
   enable: true,
   name: 'CC BY-NC-SA 4.0',