Commit 7ee18fb

HPCesia <me@hpcesia.com>
2025-01-24 05:15:24
feat: add post info and imporve footer
1 parent bd7fcd3
src/components/PageFooter.astro
@@ -1,24 +1,39 @@
 ---
-import { profileConfig, siteConfig } from '@/config';
+import { footerConfig, profileConfig } from '@/config';
 
 const currentYear = new Date().getFullYear();
 ---
 
-<footer id="footer">
+<footer id="footer" class="relative mt-auto">
   <div id="footer-links"></div>
   <div
     id="footer-bar-wrapper"
-    class="theme-card-bg theme-border mt-4 w-full overflow-hidden border-t-2 p-4"
+    class="theme-card-bg theme-border relative bottom-0 mt-4 max-h-fit w-full overflow-hidden border-t-2 p-4"
   >
-    <div id="footer-bar" class="flex justify-between">
+    <div id="footer-bar" class="flex justify-between gap-6">
       <div id="footer-left" class="text-center">
-        © {siteConfig.copyrightYear}{
-          siteConfig.copyrightYear < currentYear && `-${currentYear}`
-        } By <a href="/about/">{profileConfig.name}</a>
+        © {footerConfig.copyrightYear}{
+          footerConfig.copyrightYear < currentYear && `-${currentYear}`
+        }
+        <a href="/about/">{profileConfig.name}</a>
       </div>
-      <div id="footer-right">
-        Powered by <a href="https://astro.build/" class="mr-4 font-bold">Astro</a>
-        Theme <a href="https://github.com/HPCesia/astral-halo/" class="font-bold">Astro Halo</a>
+      <div id="footer-right" class="flex flex-wrap justify-items-end gap-4">
+        {
+          footerConfig.rightItems.map((item) => (
+            <span>
+              {item.map((c) => {
+                if (typeof c === 'string') return <span>{c}</span>;
+                else if ('link' in c)
+                  return (
+                    <a href={c.link} class={c.class || ''}>
+                      {c.text}
+                    </a>
+                  );
+                else return <span class:list={c.class || ''}>{c.text}</span>;
+              })}
+            </span>
+          ))
+        }
       </div>
     </div>
   </div>
src/components/PostInfo.astro
@@ -0,0 +1,83 @@
+---
+import { articleConfig } from '@/config';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
+import { countWords } from '@utils/content-utils';
+import MetaIcon from './widgets/MetaIcon.astro';
+
+interface Props {
+  title: string;
+  publishedAt: Date;
+  category: string;
+  tags: string[];
+  wordCount: ReturnType<typeof countWords>;
+  class?: string;
+}
+
+const { title, publishedAt, category, tags, wordCount, class: className } = Astro.props;
+
+const readTime =
+  Math.ceil(wordCount.cjk / articleConfig.readingTime.wordsPerMinute.cjk) +
+  Math.ceil(wordCount.nonCjk / articleConfig.readingTime.wordsPerMinute.nonCjk);
+
+const metas: ({ icon: string; text: string; link?: string } | undefined)[] = [
+  {
+    icon: 'material-symbols:calendar-clock-outline-rounded',
+    text: publishedAt.toLocaleDateString(),
+  },
+  articleConfig.wordCount
+    ? {
+        icon: 'material-symbols:docs-rounded',
+        text: `${wordCount.total} ${wordCount.total === 1 ? i18n(I18nKey.wordCount) : i18n(I18nKey.wordsCount)}`,
+      }
+    : undefined,
+  articleConfig.readingTime
+    ? {
+        icon: 'material-symbols:nest-clock-farsight-analog-rounded',
+        text: `${readTime} ${readTime === 1 ? i18n(I18nKey.minuteCount) : i18n(I18nKey.minutesCount)}`,
+      }
+    : undefined,
+  category
+    ? {
+        icon: 'material-symbols:category-outline-rounded',
+        text: category,
+        link: `/archives/categories/${category.replaceAll(/[\\/]/g, '-')}/1/`,
+      }
+    : undefined,
+  ...tags.map((tag) => {
+    return {
+      icon: 'material-symbols:tag-rounded',
+      text: tag,
+      link: `/archives/tags/${tag.replaceAll(/[\\/]/g, '-')}/1/`,
+    };
+  }),
+];
+---
+
+<div id="post-info" class:list={['flex flex-col', className]}>
+  <h1 class="text-3xl font-bold">{title}</h1>
+  <div id="post-meta" class="mt-4 flex flex-wrap gap-3">
+    {
+      metas.map((meta) => {
+        return (
+          meta && (
+            <div class="flex items-center">
+              <MetaIcon name={meta.icon} />
+              {meta.link ? (
+                <a
+                  href={meta.link}
+                  class="theme-text-second text-sm font-medium duration-100 hover:brightness-125"
+                  title={meta.text}
+                >
+                  {meta.text}
+                </a>
+              ) : (
+                <span class="theme-text-second text-sm font-medium">{meta.text}</span>
+              )}
+            </div>
+          )
+        );
+      })
+    }
+  </div>
+</div>
src/layouts/GridLayout.astro
@@ -10,13 +10,18 @@ const { title, description, lang } = Astro.props;
 ---
 
 <MainLayout title={title} description={description} lang={lang}>
-  <div id="main-content" class="my-4 w-full">
-    <slot />
-  </div>
-  <div id="aside-content" class="my-4 flex w-96 flex-col gap-4 max-xl:hidden">
-    <slot name="aside-fixed" slot="aside-fixed" />
-    <div class="sticky top-20 flex flex-col gap-4">
-      <slot name="aside-sticky" slot="aside-sticky" />
+  <div class="mx-auto flex max-w-screen-xl flex-col gap-4">
+    <slot name="header-content" />
+    <div class="flex gap-4">
+      <div id="main-content" class="my-4 w-full">
+        <slot />
+      </div>
+      <div id="aside-content" class="my-4 flex w-96 flex-col gap-4 max-xl:hidden">
+        <slot name="aside-fixed" />
+        <div class="sticky top-20 flex flex-col gap-4">
+          <slot name="aside-sticky" />
+        </div>
+      </div>
     </div>
   </div>
 </MainLayout>
src/layouts/MainLayout.astro
@@ -18,11 +18,10 @@ const { title, description, lang } = Astro.props;
   <Sidebar />
   <SideToolBar />
   <Navbar />
+  <slot name="header" />
   <div id="body-wrap" class="w-full items-center md:px-4">
     <!-- Main content -->
-    <div id="content-wrapper" class="mx-auto flex max-w-screen-xl gap-4">
-      <slot />
-    </div>
+    <slot />
   </div>
   <PageFooter />
 </GlobalLayout>
src/pages/posts/[article].astro
@@ -1,9 +1,12 @@
 ---
+import { articleConfig } from '@/config';
 import '@/styles/article.scss';
 import License from '@components/License.astro';
+import PostInfo from '@components/PostInfo.astro';
 import ProfileCard from '@components/widgets/ProfileCard.astro';
 import TOC from '@components/widgets/TOC.astro';
 import GridLayout from '@layouts/GridLayout.astro';
+import { countWords } from '@utils/content-utils';
 import { getCollection, render } from 'astro:content';
 
 export async function getStaticPaths() {
@@ -16,9 +19,21 @@ export async function getStaticPaths() {
 
 const { article } = Astro.props;
 const { Content, headings } = await render(article);
+
+const wordCount = countWords(article.body || '');
 ---
 
 <GridLayout>
+  <Fragment slot="header-content">
+    <PostInfo
+      title={article.data.title}
+      publishedAt={article.data.published}
+      category={article.data.category}
+      tags={article.data.tags}
+      wordCount={wordCount}
+      class="mx-2 mt-4"
+    />
+  </Fragment>
   <div class="theme-card-bg theme-border rounded-xl border-2 px-6 py-4">
     <article>
       <Content />
@@ -29,6 +44,6 @@ const { Content, headings } = await render(article);
     <ProfileCard />
   </Fragment>
   <Fragment slot="aside-sticky">
-    <TOC headings={headings} />
+    {articleConfig.toc && <TOC headings={headings} />}
   </Fragment>
 </GridLayout>
src/types/config.ts
@@ -4,7 +4,6 @@ export type SiteConfig = {
   title: string;
   subtitle: string;
   lang: string;
-  copyrightYear: number;
   favicon: (string | { src: string; theme?: 'light' | 'dark' })[];
   postsPerPage: number;
 };
@@ -48,3 +47,20 @@ export type LicenseConfig = {
   name: string;
   url: string;
 };
+
+export type FooterConfig = {
+  copyrightYear: number;
+  rightItems: (string | { text: string; link?: string; class?: string })[][];
+};
+
+export type ArticleConfig = {
+  toc: boolean;
+  wordCount: boolean;
+  readingTime: {
+    enable: boolean;
+    wordsPerMinute: {
+      cjk: number;
+      nonCjk: number;
+    };
+  };
+};
src/utils/content-utils.ts
@@ -65,3 +65,19 @@ export async function getTimeArchives() {
   }));
   return timeReducedPosts;
 }
+
+export function countWords(text: string): { cjk: number; nonCjk: number; total: number } {
+  const cjkRegex =
+    /[\u4E00-\u9FFF\u3400-\u4DBF\u20000-\u2A6DF\u2A700-\u2B73F\u2B740-\u2B81F\u2B820-\u2CEAF\uF900-\uFAFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/g;
+  const cjkCount = (text.match(cjkRegex) || []).length;
+  const nonCjkText = text.replace(cjkRegex, '');
+  const wordCount = nonCjkText
+    .trim()
+    .split(/\s+/)
+    .filter((word) => word.length > 0).length;
+  return {
+    cjk: cjkCount,
+    nonCjk: wordCount,
+    total: cjkCount + wordCount,
+  };
+}
src/config.ts
@@ -1,4 +1,6 @@
 import type {
+  ArticleConfig,
+  FooterConfig,
   LicenseConfig,
   NavbarConfig,
   ProfileConfig,
@@ -12,7 +14,6 @@ export const siteConfig: SiteConfig = {
   subtitle: '',
   lang: 'zh_CN', // "en" | "zh_CN" | "zh_TW"
   favicon: [''],
-  copyrightYear: 2025,
   postsPerPage: 10,
 };
 
@@ -65,3 +66,35 @@ export const licenseConfig: LicenseConfig = {
   name: 'CC BY-NC-SA 4.0',
   url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
 };
+
+export const footerConfig: FooterConfig = {
+  copyrightYear: 2025,
+  rightItems: [
+    [
+      {
+        text: 'Astro',
+        link: 'https://astro.build/',
+        class: 'font-bold text-sm',
+      },
+    ],
+    [
+      {
+        text: 'Astral Halo',
+        link: 'https://github.com/HPCesia/astral-halo/',
+        class: 'font-bold text-sm',
+      },
+    ],
+  ],
+};
+
+export const articleConfig: ArticleConfig = {
+  toc: true,
+  wordCount: true,
+  readingTime: {
+    enable: true,
+    wordsPerMinute: {
+      cjk: 300,
+      nonCjk: 160,
+    },
+  },
+};