Commit f0536d2

HPCesia <me@hpcesia.com>
2025-01-21 09:08:16
feat: page-switch & aside
add page switch bar. add aside content, with profile card.
1 parent e625e71
.vscode/extensions.json
@@ -1,6 +1,3 @@
 {
-  "recommendations": [
-    "astro-build.astro-vscode",
-    "bradlc.vscode-tailwindcss"
-  ],
-}
\ No newline at end of file
+  "recommendations": ["astro-build.astro-vscode", "bradlc.vscode-tailwindcss"]
+}
.vscode/settings.json
@@ -6,12 +6,7 @@
   "css.validate": false,
   "scss.validate": false,
   "less.validate": false,
-  "stylelint.validate": [
-    "css",
-    "postcss",
-    "scss",
-    "astro",
-  ],
+  "stylelint.validate": ["css", "postcss", "scss", "astro"],
   "eslint.validate": [
     "javascript",
     "javascriptreact",
@@ -24,4 +19,4 @@
     "source.fixAll.eslint": "always",
     "source.fixAll.stylelint": "always"
   }
-}
\ No newline at end of file
+}
public/favicon.svg
@@ -1,9 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
-    <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
-    <style>
-        path { fill: #000; }
-        @media (prefers-color-scheme: dark) {
-            path { fill: #FFF; }
-        }
-    </style>
-</svg>
src/assets/img/avatar.jpg
Binary file
src/components/widgets/Button.astro
@@ -1,6 +1,6 @@
 ---
 import { AstroParameterConflictError } from '@/types/Errors';
-import type { HTMLAttributes } from 'astro/types';
+import type { HTMLAttributes, HTMLTag } from 'astro/types';
 
 interface Props extends HTMLAttributes<'button'> {
   href?: string;
@@ -8,11 +8,15 @@ interface Props extends HTMLAttributes<'button'> {
 const { href, onclick, class: className, ...rest } = Astro.props;
 
 if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
+
+const Tag = (href ? 'a' : Fragment) as HTMLTag;
 ---
 
-<button {...{ href, onclick, ...rest }} class:list={[className]}>
-  <slot />
-</button>
+<Tag {...{ href }}>
+  <button {...{ onclick, ...rest }} class:list={[className]}>
+    <slot />
+  </button>
+</Tag>
 
 <style lang="scss">
   button {
@@ -21,7 +25,6 @@ if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
     padding: 0.5rem;
     margin: 0.5rem;
     text-align: center;
-    word-break: break-word;
     transition-duration: 300ms;
     border-radius: 9999px;
     width: fit-content;
@@ -37,15 +40,3 @@ if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
     }
   }
 </style>
-
-<script>
-  import { navigate } from 'astro:transitions/client';
-  document.addEventListener('astro:page-load', () => {
-    document.querySelectorAll('button[href]').forEach((btn) => {
-      btn.addEventListener('click', () => {
-        const href = btn.getAttribute('href') || '/404.html';
-        navigate(href);
-      });
-    });
-  });
-</script>
src/components/widgets/DarkModeButton.astro
@@ -2,7 +2,7 @@
 import type { HTMLAttributes } from 'astro/types';
 import Button from './Button.astro';
 import { Icon } from 'astro-icon/components';
-import I18nKey from '@i18n/i18nKey';
+import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 
 interface Props extends Omit<HTMLAttributes<'button'>, 'onclick'> {
src/components/widgets/Pagination.astro
@@ -0,0 +1,159 @@
+---
+import { Icon } from 'astro-icon/components';
+import Button from './Button.astro';
+
+interface Props {
+  total: number;
+  current: number;
+  baseUrl: string;
+  specialPages?: { page: number; url: string }[];
+}
+
+const { total, current, baseUrl, specialPages } = Astro.props;
+
+let pages: { page: number; url: string }[] = [];
+
+function getPageUrl(page: number) {
+  return specialPages?.find((p) => p.page === page)?.url || `${baseUrl}/${page}`;
+}
+
+function pushPage(page: number) {
+  pages.push({ page, url: getPageUrl(page) });
+}
+
+if (total <= 5) Array.from({ length: total }).map((_, i) => pushPage(i + 1));
+else {
+  if (current <= 3) {
+    Array.from({ length: 3 }).map((_, i) => pushPage(i + 1));
+    pages.push({ page: -1, url: '' });
+    pushPage(total);
+  } else if (current >= total - 2) {
+    pushPage(1);
+    pages.push({ page: -1, url: '' });
+    Array.from({ length: 3 }).map((_, i) => pushPage(total - 2 + i));
+  } else {
+    pushPage(1);
+    pages.push({ page: -1, url: '' });
+    pushPage(current - 1);
+    pushPage(current);
+    pushPage(current + 1);
+    pages.push({ page: -1, url: '' });
+    pushPage(total);
+  }
+}
+---
+
+<div id="pagination" class="flex justify-center items-center gap-2">
+  {
+    pages.map((p) => {
+      return (
+        <Button
+          class:list={[
+            'theme-bg theme-border border-2 !rounded-xl',
+            current === p.page && 'theme-card-bg-hl',
+          ]}
+          href={p.url}
+        >
+          <span class="mx-1">{`${p.page === -1 ? '...' : p.page}`}</span>
+        </Button>
+      );
+    })
+  }
+  {
+    total > 1 && (
+      <div
+        id="page-jumper"
+        class="flex flex-row items-center theme-card-bg theme-border mx-2 rounded-xl border-2"
+        data-base-url={baseUrl}
+        data-special-pages={specialPages?.map((p) => `${p.page}:${p.url}`).join(',')}
+      >
+        <input
+          id="page-jumper-input"
+          type="number"
+          min="1"
+          max={total}
+          class="duration-300 theme-bg border-none !active:border-none pl-4"
+        />
+        <Button
+          id="page-jumper-button"
+          class="theme-card-bg !rounded-xl !m-0 relative right-0 duration-300"
+        >
+          <Icon name="material-symbols:keyboard-double-arrow-right-rounded" class="my-1" />
+        </Button>
+      </div>
+    )
+  }
+</div>
+
+<style lang="scss">
+  /* hide arrows from number input */
+  input::-webkit-outer-spin-button,
+  input::-webkit-inner-spin-button {
+    appearance: none;
+  }
+
+  input[type='number'] {
+    appearance: textfield;
+  }
+
+  input:focus {
+    outline: none;
+  }
+</style>
+
+<script>
+  import { navigate } from 'astro:transitions/client';
+
+  document.addEventListener('astro:page-load', () => {
+    const pageJumper = document.getElementById('page-jumper');
+    const pageJumperInput = document.getElementById(
+      'page-jumper-input'
+    ) as HTMLInputElement | null;
+    const pageJumperButton = document.getElementById('page-jumper-button');
+
+    function pageJumperMouseEnterCallback() {
+      if (pageJumperInput) pageJumperInput.style.width = '4rem';
+      pageJumperInput?.classList.remove('inert');
+      pageJumperInput?.classList.add('pl-4');
+      pageJumperInput?.focus();
+    }
+
+    function pageJumperMouseLeaveCallback() {
+      if (pageJumperInput) pageJumperInput.style.width = '0px';
+      pageJumperInput?.classList.add('inert');
+      pageJumperInput?.classList.remove('pl-4');
+      pageJumperInput?.blur();
+    }
+
+    function getPageUrl(page: number) {
+      const baseUrl = pageJumper?.getAttribute('data-base-url');
+      const specialPagesStr = pageJumper?.getAttribute('data-special-pages');
+      const specialPagesArray = specialPagesStr?.split(',');
+      const specialPages = specialPagesArray?.map((p) => {
+        const [page, url] = p.split(':', 2);
+        return { page: Number(page), url: url };
+      });
+      console.log(specialPages);
+      return specialPages?.find((p) => p.page === page)?.url || `${baseUrl}/${page}`;
+    }
+
+    function pageJumperExecHandler() {
+      const page = pageJumperInput?.value;
+      if (page) {
+        const pageUrl = getPageUrl(Number(page));
+        navigate(pageUrl);
+      }
+    }
+
+    pageJumper?.addEventListener('mouseenter', pageJumperMouseEnterCallback);
+    pageJumper?.addEventListener('mouseleave', pageJumperMouseLeaveCallback);
+    pageJumperMouseLeaveCallback();
+
+    pageJumperInput?.addEventListener('keydown', (event) => {
+      if (event.key === 'Enter') {
+        pageJumperExecHandler();
+      }
+    });
+    pageJumperButton?.addEventListener('click', pageJumperExecHandler);
+  });
+</script>
src/components/widgets/PostCard.astro
@@ -33,14 +33,14 @@ const metas: ({ icon: string; text: string; link?: string } | undefined)[] = [
     ? {
         icon: 'material-symbols:category-outline-rounded',
         text: category,
-        link: `/archives/categories/${category}/1/`,
+        link: `/archives/categories/${category.replaceAll(/[\\/]/g, '-')}/1/`,
       }
     : undefined,
   ...tags.map((tag) => {
     return {
       icon: 'material-symbols:tag-rounded',
       text: tag,
-      link: `/archives/tags/${tag}/1/`,
+      link: `/archives/tags/${tag.replaceAll(/[\\/]/g, '-')}/1/`,
     };
   }),
 ];
src/components/widgets/PostCardCover.astro
@@ -12,7 +12,9 @@ const { url, title, cover } = Astro.props;
 ---
 
 <a href={url} title={title} class="group">
-  <div><Icon name="material-symbols:chevron-right-rounded" /></div>
+  <div>
+    <Icon name="material-symbols:chevron-right-rounded" />
+  </div>
   <Image src={cover} alt={title} inferSize={true} />
 </a>
 
src/components/widgets/ProfileCard.astro
@@ -0,0 +1,30 @@
+---
+import { profileConfig } from '@/config';
+import { Image } from 'astro:assets';
+import avatarImg from '/src/assets/img/avatar.jpg';
+
+const avaterAttr = profileConfig.avatar
+  ? {
+      src: profileConfig.avatar,
+      alt: profileConfig.name,
+      inferSize: true,
+      width: 800,
+      height: 800,
+    }
+  : { src: avatarImg, alt: profileConfig.name };
+---
+
+<div
+  id="profile-card"
+  class="theme-card-bg theme-border rounded-xl border-2 flex flex-col items-center text-center"
+>
+  <div class="m-3 w-fit min-w-20">
+    <a href="/about/">
+      <Image class="rounded-full border-4 w-20 h-20 theme-border" {...avaterAttr} />
+    </a>
+  </div>
+  <div class="mx-3 mb-5 flex flex-col w-full">
+    <div class="text-lg mb-3">{profileConfig.name}</div>
+    <div>{profileConfig.bio}</div>
+  </div>
+</div>
src/components/widgets/ReadMoreButton.astro
@@ -1,13 +1,15 @@
 ---
 import type { ComponentProps } from 'astro/types';
 import { Icon } from 'astro-icon/components';
+import { i18n } from '@i18n/translation';
+import I18nKey from '@i18n/I18nKey';
 
 type Props = Omit<ComponentProps<typeof Icon>, 'name'>;
 
 const { href, title, ...rest } = Astro.props;
 ---
 
-<a href={href} title={title}>
+<a href={href} title={title || i18n(I18nKey.more)}>
   <Icon name="material-symbols:chevron-right-rounded" {...rest} />
 </a>
 
src/components/CategoryBar.astro
@@ -1,5 +1,40 @@
 ---
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
 
+interface Props {
+  categories: string[];
+  currentCategory?: string;
+}
+
+const { categories, currentCategory } = Astro.props;
 ---
 
-<div id="category-bar" class="flex w-full rounded-md px-2 py-3"></div>
+<div
+  id="category-bar"
+  class="theme-card-bg theme-border flex w-full rounded-xl px-2 py-3 border-2 mb-4"
+>
+  <a href={`/`} class:list={[currentCategory ? '' : 'theme-card-bg-hl']}>
+    {i18n(I18nKey.recentPosts)}
+  </a>
+  {
+    categories.map((category) => (
+      <a
+        href={`/archives/categories/${category}/1/`}
+        class:list={[currentCategory === category ? 'theme-card-bg-hl' : '']}
+      >
+        {category}
+      </a>
+    ))
+  }
+</div>
+
+<style lang="scss">
+  a {
+    @apply mx-2 rounded-md px-2 py-1;
+
+    &:hover {
+      @apply bg-[var(--theme-color-light-lighten)] dark:bg-[var(--theme-color-dark-lighten)];
+    }
+  }
+</style>
src/components/Navbar.astro
@@ -4,6 +4,7 @@ import { navbarConfig } from '@/config';
 import { Icon } from 'astro-icon/components';
 
 import Button from './widgets/Button.astro';
+import { i18n } from '@i18n/translation';
 
 interface Props {
   title?: string;
@@ -16,7 +17,7 @@ if (!title) title = 'Astral Halo';
 <div
   id="navbar"
   transition:persist
-  class="flex fixed w-full h-16 items-center border-b-2 theme-border theme-card-bg"
+  class="flex fixed z-30 w-full h-16 items-center border-b-2 theme-border theme-card-bg"
 >
   <div id="nav-left" class="left-0 flex mr-auto w-fit">
     <Button id="site-name" href="/">
@@ -25,31 +26,27 @@ if (!title) title = 'Astral Halo';
   </div>
   <div id="nav-center" class="left-0 flex m-auto w-fit max-md:hidden">
     {
-      navbarConfig.navbarCenterItems.map((item) => (
-        <Button
-          href={item.href}
-          onclick={item.onclick}
-          title={item.text}
-          class="nav-menu-item !px-4"
-        >
-          <span class="text-xl tracking-wide">{item.text}</span>
-        </Button>
-      ))
+      navbarConfig.navbarCenterItems.map((item) => {
+        const text = i18n(item.text);
+        return (
+          <Button href={item.href} onclick={item.onclick} title={text} class="!px-4">
+            <span class="text-xl tracking-wide">{text}</span>
+          </Button>
+        );
+      })
     }
   </div>
   <div id="nav-right" class="left-0 flex ml-auto w-fit">
     <div class="flex max-md:hidden">
       {
-        navbarConfig.navbarRightItems.onlyWide.map((item) => (
-          <Button
-            class="nav-menu-item"
-            href={item.href}
-            onclick={item.onclick}
-            title={item.text}
-          >
-            <Icon name={item.icon} class="text-2xl" />
-          </Button>
-        ))
+        navbarConfig.navbarRightItems.onlyWide.map((item) => {
+          const text = i18n(item.text);
+          return (
+            <Button class="nav-menu-item" href={item.href} onclick={item.onclick} title={text}>
+              <Icon name={item.icon} class="text-2xl" />
+            </Button>
+          );
+        })
       }
     </div>
     <div class="flex">
src/components/PostPage.astro
@@ -2,6 +2,7 @@
 import type { BlogPostData } from '@/types/data';
 import { siteConfig } from '@/config';
 import PostCard from './widgets/PostCard.astro';
+import Pagination from './widgets/Pagination.astro';
 
 interface Props {
   posts: {
@@ -10,12 +11,15 @@ interface Props {
   }[];
   currentPage: number;
   postsPerPage?: number;
+  baseUrl: string;
+  specialPage?: { page: number; url: string }[];
 }
 
-let { posts, currentPage, postsPerPage } = Astro.props;
+let { posts, currentPage, postsPerPage, baseUrl, specialPage } = Astro.props;
 
 postsPerPage = postsPerPage || siteConfig.postsPerPage;
 
+const totalPages = Math.ceil(posts.length / postsPerPage);
 posts = posts.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage);
 ---
 
@@ -36,4 +40,10 @@ posts = posts.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage
       );
     })
   }
+  <Pagination
+    total={totalPages}
+    current={currentPage}
+    baseUrl={baseUrl}
+    specialPages={specialPage}
+  />
 </div>
src/components/Sidebar.astro
@@ -15,7 +15,7 @@ import DarkModeButton from './widgets/DarkModeButton.astro';
   </div>
   <div
     id="sidebar-menu"
-    class="fixed h-full z-50 -right-1/3 w-1/3 duration-500 ease-in-out border-l-2 theme-border theme-card-bg"
+    class="fixed h-full z-50 -right-1/2 w-1/2 duration-500 ease-in-out border-l-2 theme-border theme-card-bg"
   >
     <div id="sidebar-site-data"></div>
     <DarkModeButton
src/components/SideToolBar.astro
@@ -4,7 +4,7 @@ import Button from './widgets/Button.astro';
 import DarkModeButton from './widgets/DarkModeButton.astro';
 ---
 
-<div id="side-toolbar" transition:persist class="fixed bottom-10 right-0">
+<div id="side-toolbar" transition:persist class="fixed bottom-10 right-0 z-30">
   <div id="stb-hide" class="translate-x-full duration-500 ease-in-out" inert>
     <DarkModeButton id="stb-dark-mode" class="" />
   </div>
src/components/TimeArchives.astro
@@ -0,0 +1,47 @@
+---
+import { getTimeArchives } from '@utils/content-utils';
+import PostCard from '@components/widgets/PostCard.astro';
+
+type AllTimeArchives = Awaited<ReturnType<typeof getTimeArchives>>;
+type YearArchives = AllTimeArchives[number];
+type MonthArchives = YearArchives['months'][number];
+
+interface Props {
+  group: AllTimeArchives | YearArchives | MonthArchives;
+}
+
+const { group } = Astro.props;
+---
+
+{
+  Array.isArray(group) ? (
+    group.map((year) => <Astro.self group={year} />)
+  ) : 'year' in group ? (
+    <Fragment>
+      <div class="text-2xl font-bold ml-2">
+        <a href={`/archives/${group.year}/`}>{group.year}</a>
+      </div>
+      {group.months.map((month) => (
+        <Astro.self group={month} />
+      ))}
+    </Fragment>
+  ) : (
+    <Fragment>
+      <div class="text-xl font-bold ml-3">{group.month}</div>
+      {group.posts.map((post) => {
+        const data = post.data;
+        return (
+          <PostCard
+            title={data.title}
+            url={'/posts/' + data.abbrlink + '/'}
+            published={data.published}
+            tags={data.tags}
+            category={data.category}
+            cover={data.cover}
+            description={data.description}
+          />
+        );
+      })}
+    </Fragment>
+  )
+}
src/i18n/langs/en.ts
@@ -1,4 +1,4 @@
-import Key from '../i18nKey';
+import Key from '../I18nKey';
 import type { Translation } from '../translation';
 
 export const en: Translation = {
src/i18n/langs/zh_CN.ts
@@ -1,4 +1,4 @@
-import Key from '../i18nKey';
+import Key from '../I18nKey';
 import type { Translation } from '../translation';
 
 export const zh_CN: Translation = {
src/i18n/langs/zh_TW.ts
@@ -1,4 +1,4 @@
-import Key from '../i18nKey';
+import Key from '../I18nKey';
 import type { Translation } from '../translation';
 
 export const zh_TW: Translation = {
src/i18n/translation.ts
@@ -1,14 +1,14 @@
 import { siteConfig } from '@/config';
-import type I18nKey from './i18nKey';
-
-import { en } from './langs/en';
-import { zh_CN } from './langs/zh_CN';
-import { zh_TW } from './langs/zh_TW';
+import I18nKey from './I18nKey';
 
 export type Translation = {
   [K in I18nKey]: string;
 };
 
+import { en } from './langs/en';
+import { zh_CN } from './langs/zh_CN';
+import { zh_TW } from './langs/zh_TW';
+
 const defaultTranslation = en;
 
 const map: { [key: string]: Translation } = {
@@ -24,7 +24,8 @@ export function getTranslation(lang: string): Translation {
   return map[lang.toLowerCase()] || defaultTranslation;
 }
 
-export function i18n(key: I18nKey): string {
+export function i18n(key: I18nKey | string): string {
   const lang = siteConfig.lang || 'en';
-  return getTranslation(lang)[key];
+  const translate = getTranslation(lang);
+  return key in I18nKey ? translate[key as I18nKey] : key;
 }
src/layouts/GridLayout.astro
@@ -0,0 +1,19 @@
+---
+import MainLayout from '@layouts/MainLayout.astro';
+
+interface Props {
+  title?: string;
+  description?: string;
+  lang?: string;
+}
+const { title, description, lang } = Astro.props;
+---
+
+<MainLayout title={title} description={description} lang={lang}>
+  <div id="main-content" class="my-4 w-full mx-4">
+    <slot />
+  </div>
+  <div id="aside-content" class="max-xl:hidden my-4 mx-2 w-96">
+    <slot name="aside" slot="aside" />
+  </div>
+</MainLayout>
src/layouts/MainLayout.astro
@@ -16,11 +16,12 @@ const { title, description, lang } = Astro.props;
   <slot slot="head" name="head" />
   <Sidebar />
   <SideToolBar />
-  <div id="body-wrap">
-    <Navbar />
+  <Navbar />
+  <div id="body-wrap" class="items-center w-full">
     <!-- Main content -->
-    <div id="content-wrapper">
+    <div id="content-wrapper" class="flex mx-auto gap-4 max-w-screen-xl">
       <slot />
     </div>
   </div>
+  <div id="footer"></div>
 </GlobalLayout>
src/pages/archives/categories/[category]/[page].astro
@@ -1,10 +1,12 @@
 ---
-import { getSortedPosts } from '@utils/content-utils';
-import MainLayout from '@layouts/MainLayout.astro';
+import { getCategories, getSortedPosts } from '@utils/content-utils';
 import PostPage from '@components/PostPage.astro';
 import { siteConfig } from '@/config';
 import { i18n } from '@i18n/translation';
-import I18nKey from '@i18n/i18nKey';
+import I18nKey from '@i18n/I18nKey';
+import GridLayout from '@layouts/GridLayout.astro';
+import CategoryBar from '@components/CategoryBar.astro';
+import ProfileCard from '@components/widgets/ProfileCard.astro';
 
 export async function getStaticPaths() {
   const posts = await getSortedPosts();
@@ -15,10 +17,10 @@ export async function getStaticPaths() {
     .map((category) => {
       const categoryPosts = posts.filter((post) => post.data.category === category);
       const pageNum = Math.ceil(categoryPosts.length / siteConfig.postsPerPage);
+      category = category.replaceAll(/[\\/]/g, '-');
       return Array.from({ length: pageNum }, (_, i) => ({
-        params: { category, page: (i + 1).toString() },
+        params: { category: category, page: (i + 1).toString() },
         props: {
-          category,
           posts: categoryPosts.slice(
             i * siteConfig.postsPerPage,
             (i + 1) * siteConfig.postsPerPage
@@ -30,12 +32,19 @@ export async function getStaticPaths() {
     .flat();
 }
 
-const { category, posts, currentPage } = Astro.props;
+const { posts, currentPage } = Astro.props;
+const { category } = Astro.params;
+const categories = await getCategories();
 ---
 
-<MainLayout>
-  <div id="main-content">
-    <PostPage posts={posts} currentPage={currentPage} />
-  </div>
-  <div id="aside-content"></div>
-</MainLayout>
+<GridLayout>
+  <CategoryBar categories={categories} currentCategory={category} />
+  <PostPage
+    posts={posts}
+    currentPage={currentPage}
+    baseUrl={`/archives/categories/${category}`}
+  />
+  <Fragment slot="aside">
+    <ProfileCard />
+  </Fragment>
+</GridLayout>
src/pages/archives/tags/[tag]/[page].astro
@@ -1,8 +1,9 @@
 ---
 import { getSortedPosts } from '@utils/content-utils';
-import MainLayout from '@layouts/MainLayout.astro';
 import PostPage from '@components/PostPage.astro';
 import { siteConfig } from '@/config';
+import GridLayout from '@layouts/GridLayout.astro';
+import ProfileCard from '@components/widgets/ProfileCard.astro';
 
 export async function getStaticPaths() {
   const posts = await getSortedPosts();
@@ -11,10 +12,10 @@ export async function getStaticPaths() {
     .map((tag) => {
       const tagPosts = posts.filter((post) => post.data.tags.includes(tag));
       const pageNum = Math.ceil(tagPosts.length / siteConfig.postsPerPage);
+      tag = tag.replaceAll(/[\\/]/g, '-');
       return Array.from({ length: pageNum }, (_, i) => ({
-        params: { tag, page: (i + 1).toString() },
+        params: { tag: tag, page: (i + 1).toString() },
         props: {
-          tag,
           posts: tagPosts.slice(i * siteConfig.postsPerPage, (i + 1) * siteConfig.postsPerPage),
           currentPage: i + 1,
         },
@@ -23,12 +24,13 @@ export async function getStaticPaths() {
     .flat();
 }
 
-const { tag, posts, currentPage } = Astro.props;
+const { posts, currentPage } = Astro.props;
+const { tag } = Astro.params;
 ---
 
-<MainLayout>
-  <div id="main-content">
-    <PostPage posts={posts} currentPage={currentPage} />
-  </div>
-  <div id="aside-content"></div>
-</MainLayout>
+<GridLayout>
+  <PostPage posts={posts} currentPage={currentPage} baseUrl={`/archives/tags/${tag}`} />
+  <Fragment slot="aside">
+    <ProfileCard />
+  </Fragment>
+</GridLayout>
src/pages/archives/[...time].astro
@@ -0,0 +1,37 @@
+---
+import TimeArchives from '@components/TimeArchives.astro';
+import ProfileCard from '@components/widgets/ProfileCard.astro';
+import GridLayout from '@layouts/GridLayout.astro';
+import { getTimeArchives } from '@utils/content-utils';
+
+type AllTimeArchives = Awaited<ReturnType<typeof getTimeArchives>>;
+type YearArchives = AllTimeArchives[number];
+type MonthArchives = YearArchives['months'][number];
+
+export async function getStaticPaths() {
+  const timeReducedPosts = await getTimeArchives();
+  let paths: {
+    params: { time: string | undefined };
+    props: { group: AllTimeArchives | YearArchives | MonthArchives };
+  }[] = [{ params: { time: undefined }, props: { group: timeReducedPosts } }];
+  paths = [
+    ...paths,
+    ...timeReducedPosts.map((group) => ({
+      params: { time: `${group.year}` },
+      props: { group: group },
+    })),
+  ];
+  return paths;
+}
+
+const { group } = Astro.props;
+---
+
+<GridLayout>
+  <div class="flex flex-col gap-4">
+    <TimeArchives group={group} />
+  </div>
+  <Fragment slot="aside">
+    <ProfileCard />
+  </Fragment>
+</GridLayout>
src/pages/posts/[article].astro
@@ -1,6 +1,8 @@
 ---
 import { getCollection, render } from 'astro:content';
-import MainLayout from '@layouts/MainLayout.astro';
+import GridLayout from '@layouts/GridLayout.astro';
+import ProfileCard from '@components/widgets/ProfileCard.astro';
+import '@/styles/article.scss';
 
 export async function getStaticPaths() {
   const articles = await getCollection('posts');
@@ -14,9 +16,13 @@ const { article } = Astro.props;
 const { Content } = await render(article);
 ---
 
-<MainLayout>
-  <div id="main-content">
-    <Content />
+<GridLayout>
+  <div class="theme-card-bg theme-border border-2 px-6 py-4 rounded-xl">
+    <article>
+      <Content />
+    </article>
   </div>
-  <div id="aside-content"></div>
-</MainLayout>
+  <Fragment slot="aside">
+    <ProfileCard />
+  </Fragment>
+</GridLayout>
src/pages/[...page].astro
@@ -1,9 +1,10 @@
 ---
-import { getSortedPosts } from '@utils/content-utils';
+import { getCategories, getSortedPosts } from '@utils/content-utils';
 import { siteConfig } from '@/config';
-import categoryBar from '@components/categoryBar.astro';
+import CategoryBar from '@components/CategoryBar.astro';
 import PostPage from '@components/PostPage.astro';
-import MainLayout from '@layouts/MainLayout.astro';
+import GridLayout from '@layouts/GridLayout.astro';
+import ProfileCard from '@components/widgets/ProfileCard.astro';
 
 export async function getStaticPaths() {
   const articles = await getSortedPosts();
@@ -17,16 +18,18 @@ export async function getStaticPaths() {
 
 const { page } = Astro.props;
 const articles = await getSortedPosts();
-const posts = articles.slice(
-  (page - 1) * siteConfig.postsPerPage,
-  page * siteConfig.postsPerPage
-);
+const categories = await getCategories();
 ---
 
-<MainLayout>
-  <div id="main-content">
-    <categoryBar></categoryBar>
-    <PostPage posts={posts} currentPage={page} />
-  </div>
-  <div id="aside-content"></div>
-</MainLayout>
+<GridLayout>
+  <CategoryBar categories={categories} />
+  <PostPage
+    posts={articles}
+    currentPage={page}
+    baseUrl={`/page`}
+    specialPage={[{ page: 1, url: '/' }]}
+  />
+  <Fragment slot="aside">
+    <ProfileCard />
+  </Fragment>
+</GridLayout>
src/pages/about.astro
@@ -1,7 +1,7 @@
 ---
 import MainLayout from '@layouts/MainLayout.astro';
 
-import I18nKey from '@i18n/i18nKey';
+import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 ---
 
src/scripts/utils.ts
@@ -0,0 +1,29 @@
+export function scrollToTop(duration: number = 500): void {
+  const start = window.scrollY;
+  const startTime = performance.now();
+  function scrollStep(timestamp: number) {
+    const currentTime = timestamp - startTime;
+    const progress = Math.min(currentTime / duration, 1);
+    window.scrollTo(0, start * (1 - progress));
+    if (currentTime < duration) {
+      window.requestAnimationFrame(scrollStep);
+    }
+  }
+  window.requestAnimationFrame(scrollStep);
+}
+
+export function getReadingProgress(): number {
+  const docEl = document.documentElement;
+  const bodyEl = document.body;
+  const totalHeight =
+    Math.max(
+      bodyEl.clientHeight,
+      bodyEl.scrollHeight,
+      bodyEl.offsetHeight,
+      docEl.clientHeight,
+      docEl.scrollHeight,
+      docEl.offsetHeight
+    ) - docEl.clientHeight;
+  const scrollY = window.scrollY;
+  return Math.round((scrollY / totalHeight) * 100);
+}
src/styles/article.scss
@@ -0,0 +1,43 @@
+article {
+  h1 {
+    @apply text-2xl font-bold;
+    @apply mt-4 mb-2;
+  }
+
+  h2 {
+    @apply text-xl font-bold;
+    @apply mt-4 mb-2;
+  }
+
+  h3 {
+    @apply text-lg font-bold;
+    @apply mt-4 mb-2;
+  }
+
+  h4 {
+    @apply text-base font-bold;
+    @apply mt-4 mb-2;
+  }
+
+  h5 {
+    @apply text-base font-bold;
+    @apply mt-4 mb-2;
+  }
+
+  p {
+    @apply my-2;
+  }
+
+  img {
+    @apply mx-auto my-4 rounded-md;
+  }
+
+  hr {
+    @apply border-dashed border-2;
+    @apply mx-2 my-4;
+  }
+
+  a {
+    @apply text-[var(--theme-color-light)] dark:text-[var(--theme-color-dark)];
+  }
+}
src/styles/globals.scss
@@ -3,6 +3,10 @@
 @tailwind components;
 @tailwind utilities;
 
+:root {
+  font-size: 17px;
+}
+
 @layer components {
   .theme-bg {
     @apply bg-[var(--theme-bg-color-light)] dark:bg-[var(--theme-bg-color-dark)];
@@ -12,6 +16,10 @@
     @apply bg-[var(--theme-card-bg-color-light)] dark:bg-[var(--theme-card-bg-color-dark)];
   }
 
+  .theme-card-bg-hl {
+    @apply bg-[var(--theme-color-light)] dark:bg-[var(--theme-color-dark)];
+  }
+
   .theme-text {
     @apply text-[var(--theme-text-color-light)] dark:text-[var(--theme-text-color-dark)];
   }
src/styles/variables.scss
@@ -1,7 +1,7 @@
 @use 'sass:color';
 
-$theme-color-light: #d75b3d;
-$theme-color-dark: #397d9f;
+$theme-color-light: #f69279;
+$theme-color-dark: #2e6c8b;
 $theme-bg-color-light: theme('colors.neutral.50');
 $theme-bg-color-dark: theme('colors.neutral.900');
 $theme-card-bg-color-light: $theme-bg-color-light;
src/types/config.ts
@@ -1,3 +1,5 @@
+import type I18nKey from '@i18n/I18nKey';
+
 export type SiteConfig = {
   title: string;
   subtitle: string;
@@ -18,11 +20,11 @@ export type ProfileConfig = {
 };
 
 export type NavbarConfig = {
-  navbarCenterItems: { text: string; href?: string; onclick?: string }[];
+  navbarCenterItems: { text: string | I18nKey; href?: string; onclick?: string }[];
   navbarRightItems: {
     onlyWide: {
       icon: string;
-      text?: string;
+      text: string | I18nKey;
       href?: string;
       onclick?: string;
     }[];
src/utils/content-utils.ts
@@ -1,5 +1,7 @@
 import { getCollection } from 'astro:content';
 import type { BlogPostData } from '@/types/data';
+import { i18n } from '@i18n/translation';
+import I18nKey from '@i18n/I18nKey';
 
 export async function getSortedPosts(): Promise<{ body: string; data: BlogPostData }[]> {
   const allBlogPosts = (await getCollection('posts')) as unknown as {
@@ -15,3 +17,51 @@ export async function getSortedPosts(): Promise<{ body: string; data: BlogPostDa
   );
   return sortedBlogPosts;
 }
+
+export async function getCategories(): Promise<string[]> {
+  const allBlogPosts = await getSortedPosts();
+  const categories = [
+    ...new Set(allBlogPosts.map((post) => post.data.category || i18n(I18nKey.uncategorized))),
+  ];
+  return categories;
+}
+
+export async function getTags(): Promise<string[]> {
+  const allBlogPosts = await getSortedPosts();
+  const tags = [...new Set(allBlogPosts.map((post) => post.data.tags || []).flat())];
+  return tags;
+}
+
+export async function getTimeArchives() {
+  const allBlogPosts = await getSortedPosts();
+  const yearReducedPosts = allBlogPosts.reduce(
+    (acc: { year: number; posts: { body: string; data: BlogPostData }[] }[], item) => {
+      const year = item.data.published.getFullYear();
+      const existYear = acc.find((group) => group.year === year);
+      if (existYear) {
+        existYear.posts.push(item);
+      } else {
+        acc.push({ year, posts: [item] });
+      }
+      return acc;
+    },
+    []
+  );
+  const timeReducedPosts = yearReducedPosts.map((group) => ({
+    year: group.year,
+    months: group.posts.reduce(
+      (acc: { month: number; posts: { body: string; data: BlogPostData }[] }[], item) => {
+        const month = item.data.published.getMonth() + 1;
+        const existMonth = acc.find((group) => group.month === month);
+        if (existMonth) {
+          existMonth.posts.push(item);
+        } else {
+          acc.push({ month: month, posts: [item] });
+        }
+        return acc;
+      },
+      []
+    ),
+  }));
+  return timeReducedPosts;
+}
src/config.ts
@@ -6,19 +6,18 @@ import type {
   ToolBarConfig,
 } from './types/config';
 
-import I18nKey from '@i18n/i18nKey';
-import { i18n } from '@i18n/translation';
+import I18nKey from '@i18n/I18nKey';
 
 export const siteConfig: SiteConfig = {
   title: 'Astral Halo',
   subtitle: '',
   lang: 'zh_CN', // "en" | "zh_CN" | "zh_TW"
   favicon: [''],
-  postsPerPage: 10,
+  postsPerPage: 2,
 };
 
 export const profileConfig: ProfileConfig = {
-  avatar: 'assets/images/demo-avatar.png',
+  // avatar: 'https://example.com/avatar.png', // must be a absolute URL, if not set, will use src/asset/img/avatar.jpg
   name: 'John Doe',
   bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
   links: [],
@@ -26,10 +25,10 @@ export const profileConfig: ProfileConfig = {
 
 export const navbarConfig: NavbarConfig = {
   navbarCenterItems: [
-    { text: i18n(I18nKey.archive), href: '/archives/' },
-    { text: i18n(I18nKey.categories), href: '/archives/categories/' },
-    { text: i18n(I18nKey.tags), href: '/archives/tags/' },
-    { text: i18n(I18nKey.about), href: '/about/' },
+    { text: I18nKey.archive, href: '/archives/' },
+    { text: I18nKey.categories, href: '/archives/categories/' },
+    { text: I18nKey.tags, href: '/archives/tags/' },
+    { text: I18nKey.about, href: '/about/' },
   ],
   navbarRightItems: {
     onlyWide: [
@@ -37,19 +36,19 @@ export const navbarConfig: NavbarConfig = {
       // 仅在宽度大于 768px 时显示的项目
       {
         icon: 'material-symbols:rss-feed-rounded',
-        text: i18n(I18nKey.subscribe),
+        text: I18nKey.subscribe,
         onclick: '',
       },
     ],
     always: [
       {
         icon: 'material-symbols:casino',
-        text: i18n(I18nKey.randomPost),
+        text: I18nKey.randomPost,
         onclick: '',
       },
       {
         icon: 'material-symbols:search-rounded',
-        text: i18n(I18nKey.search),
+        text: I18nKey.search,
         onclick: '',
       },
     ],
src/env.d.ts
@@ -1,2 +1,3 @@
+/* eslint-disable @typescript-eslint/triple-slash-reference */
 /// <reference types="astro/client" />
 /// <reference path="../.astro/types.d.ts" />
package.json
@@ -17,6 +17,7 @@
     "autoprefixer": "^10.4.20",
     "postcss-load-config": "^6.0.1",
     "sass": "^1.83.4",
+    "sharp": "^0.33.5",
     "tailwindcss": "^3.4.17",
     "typescript": "^5.7.3"
   },
@@ -35,4 +36,4 @@
     "stylelint-config-standard-scss": "^14.0.0",
     "typescript-eslint": "^8.20.0"
   }
-}
\ No newline at end of file
+}
tsconfig.json
@@ -11,12 +11,13 @@
       }
     ],
     "paths": {
-      "@components/*": ["src/components/*"],
       "@assets/*": ["src/assets/*"],
+      "@components/*": ["src/components/*"],
       "@constants/*": ["src/constants/*"],
-      "@utils/*": ["src/utils/*"],
       "@i18n/*": ["src/i18n/*"],
       "@layouts/*": ["src/layouts/*"],
+      "@scripts/*": ["src/scripts/*"],
+      "@utils/*": ["src/utils/*"],
       "@/*": ["src/*"]
     }
   },