Commit c71d953

HPCesia <me@hpcesia.com>
2025-01-16 08:27:06
feat: Add navigation bar
Add a responsive nav bar with some animation. Just a demo.
1 parent 7fc97db
src/components/misc/GlobalBackground.astro
@@ -0,0 +1,3 @@
+---
+
+---
src/components/widgets/WidgetIconButton.astro
@@ -0,0 +1,38 @@
+---
+import { AstroParameterConflictError } from '@/types/Errors';
+
+interface Props {
+  id: string;
+  description?: string;
+  href?: string;
+  onclick?: string;
+}
+let { id, description, href, onclick } = Astro.props;
+
+if (!description) description = '';
+
+if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
+if (href) onclick = `window.location.href(\`${href}\`)`;
+else if (!onclick) onclick = '';
+---
+
+<button id={id} onclick={onclick} title={description} class="widget-icon-button">
+  <slot />
+</button>
+
+<style lang="stylus">
+.widget-icon-button
+  display flex
+  align-items center
+  padding 0.5rem
+  margin 0.5rem
+  text-align center
+  word-break break-word
+  transition-duration 300ms
+  border-radius 9999px
+  width fit-content
+  height fit-content
+
+  &:hover
+    background-color #3b82f6
+</style>
src/components/widgets/WidgetTextButton.astro
@@ -0,0 +1,35 @@
+---
+import { AstroParameterConflictError } from '@/types/Errors';
+
+interface Props {
+  id: string;
+  href?: string;
+  onclick?: string;
+}
+let { id, href, onclick } = Astro.props;
+
+if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
+if (href) onclick = `window.location.href(\`${href}\`)`;
+else onclick = '';
+---
+
+<button id={id} onclick={onclick} class="widget-text-button">
+  <slot />
+</button>
+
+<style lang="stylus">
+.widget-text-button
+  display flex
+  align-items center
+  padding 0.5rem 0.75rem
+  margin 0.5rem
+  text-align center
+  word-break break-word
+  transition-duration 300ms
+  border-radius 9999px
+  width fit-content
+  height fit-content
+
+  &:hover
+    background-color #3b82f6
+</style>
src/components/Navbar.astro
@@ -1,5 +1,57 @@
 ---
+import { navbarConfig, siteConfig } from '@/config';
 
+import { Icon } from 'astro-icon/components';
+
+import WidgetTextButton from './widgets/WidgetTextButton.astro';
+import WidgetIconButton from './widgets/WidgetIconButton.astro';
+
+interface Props {
+  title?: string;
+  lang?: string;
+}
+let { title } = Astro.props;
+if (!title) title = 'Astral Halo';
 ---
 
-<div id="navbar"></div>
+<div id="navbar" class="flex w-full h-[60px] bg-gray-100 items-center">
+  <div id="nav-left" class="left-0 flex mr-auto w-fit">
+    <WidgetTextButton id="site-name-wrapper" href="/">
+      <span class="text-xl font-bold">{title}</span>
+    </WidgetTextButton>
+  </div>
+  <div id="nav-center" class="left-0 flex m-auto w-fit max-md:hidden">
+    {
+      navbarConfig.navbarCenterItems.map((item) => (
+        <WidgetTextButton id="nav-menu-item" href={item.href} onclick={item.onclick}>
+          <span class="text-xl">{item.text}</span>
+        </WidgetTextButton>
+      ))
+    }
+  </div>
+  <div id="nav-right" class="left-0 flex ml-auto w-fit">
+    <div class="flex max-md:hidden">
+      {
+        navbarConfig.navbarRightItems.onlyWide.map((item) => (
+          <WidgetIconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
+            <Icon name={item.icon} class="text-2xl" />
+          </WidgetIconButton>
+        ))
+      }
+    </div>
+    <div class="flex">
+      {
+        navbarConfig.navbarRightItems.always.map((item) => (
+          <WidgetIconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
+            <Icon name={item.icon} class="text-2xl" />
+          </WidgetIconButton>
+        ))
+      }
+    </div>
+    <div class="flex md:hidden">
+      <WidgetIconButton id="toggle-sidebar-btn">
+        <Icon name="material-symbols:menu-rounded" class="text-2xl" />
+      </WidgetIconButton>
+    </div>
+  </div>
+</div>
src/components/Sidebar.astro
@@ -0,0 +1,57 @@
+---
+import { navbarConfig } from '@/config';
+
+import { Icon } from 'astro-icon/components';
+
+import WidgetTextButton from './widgets/WidgetTextButton.astro';
+import WidgetIconButton from './widgets/WidgetIconButton.astro';
+---
+
+<div id="sidebar">
+  <div id="sidebar-mask" class="fixed z-40 hidden w-full h-full bg-black/20 backdrop-blur-md backdrop-saturate-100">
+  </div>
+  <div
+    id="sidebar-menu"
+    class="md:hidden fixed z-50 -right-1/3 w-1/3 h-full duration-500 border-l-2 border-gray-200 bg-white ease-in-out"
+  >
+    {
+      navbarConfig.navbarCenterItems.map((item) => (
+        <WidgetTextButton id="nav-menu-item" href={item.href} onclick={item.onclick}>
+          <span class="text-xl">{item.text}</span>
+        </WidgetTextButton>
+      ))
+    }
+    {
+      navbarConfig.navbarRightItems.onlyWide.map((item) => (
+        <WidgetIconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
+          <Icon name={item.icon} class="text-2xl" />
+        </WidgetIconButton>
+      ))
+    }
+  </div>
+</div>
+
+<script>
+  const toggleSidebarBtns = document.querySelectorAll('button#toggle-sidebar-btn');
+  const sidebarMask = document.getElementById('sidebar-mask');
+  const sidebarMenu = document.getElementById('sidebar-menu');
+
+  toggleSidebarBtns.forEach((btn) => {
+    btn.addEventListener('click', () => {
+      sidebarMask?.classList.toggle('hidden');
+      sidebarMenu?.classList.toggle('-translate-x-full');
+    });
+  });
+
+  sidebarMask?.addEventListener('click', () => {
+    sidebarMask?.classList.add('hidden');
+    sidebarMenu?.classList.remove('-translate-x-full');
+  });
+
+  window.addEventListener('resize', () => {
+    if (window.innerWidth > 768) {
+      sidebarMask?.classList.add('hidden');
+      sidebarMenu?.classList.remove('-translate-x-full');
+    }
+  });
+</script>
src/components/SideMenu.astro
@@ -1,8 +0,0 @@
----
-
----
-
-<div id="side-menu">
-  <div id="sm-hide"></div>
-  <div id="sm-show"></div>
-</div>
src/i18n/langs/en.ts
@@ -0,0 +1,38 @@
+import Key from '../i18nKey';
+import type { Translation } from '../translation';
+
+export const en: Translation = {
+  [Key.home]: 'Home',
+  [Key.about]: 'About',
+  [Key.archive]: 'Archive',
+  [Key.search]: 'Search',
+
+  [Key.tags]: 'Tags',
+  [Key.categories]: 'Categories',
+  [Key.recentPosts]: 'Recent Posts',
+  [Key.randomPost]: 'Random Post',
+
+  [Key.comments]: 'Comments',
+  [Key.subscribe]: 'Subscribe',
+
+  [Key.untitled]: 'Untitled',
+  [Key.uncategorized]: 'Uncategorized',
+  [Key.noTags]: 'No Tags',
+
+  [Key.wordCount]: 'word',
+  [Key.wordsCount]: 'words',
+  [Key.minuteCount]: 'minute',
+  [Key.minutesCount]: 'minutes',
+  [Key.postCount]: 'post',
+  [Key.postsCount]: 'posts',
+
+  [Key.lightMode]: 'Light',
+  [Key.darkMode]: 'Dark',
+  [Key.systemMode]: 'System',
+
+  [Key.more]: 'More',
+
+  [Key.author]: 'Author',
+  [Key.publishedAt]: 'Published at',
+  [Key.license]: 'License',
+};
src/i18n/langs/zh_CN.ts
@@ -0,0 +1,38 @@
+import Key from '../i18nKey';
+import type { Translation } from '../translation';
+
+export const zh_CN: Translation = {
+  [Key.home]: '主页',
+  [Key.about]: '关于',
+  [Key.archive]: '归档',
+  [Key.search]: '搜索',
+
+  [Key.tags]: '标签',
+  [Key.categories]: '分类',
+  [Key.recentPosts]: '最新文章',
+  [Key.randomPost]: '随机文章',
+
+  [Key.comments]: '评论',
+  [Key.subscribe]: '订阅',
+
+  [Key.untitled]: '无标题',
+  [Key.uncategorized]: '未分类',
+  [Key.noTags]: '无标签',
+
+  [Key.wordCount]: '字',
+  [Key.wordsCount]: '字',
+  [Key.minuteCount]: '分钟',
+  [Key.minutesCount]: '分钟',
+  [Key.postCount]: '篇文章',
+  [Key.postsCount]: '篇文章',
+
+  [Key.lightMode]: '亮色',
+  [Key.darkMode]: '暗色',
+  [Key.systemMode]: '跟随系统',
+
+  [Key.more]: '更多',
+
+  [Key.author]: '作者',
+  [Key.publishedAt]: '发布于',
+  [Key.license]: '许可协议',
+};
src/i18n/langs/zh_TW.ts
@@ -0,0 +1,38 @@
+import Key from '../i18nKey';
+import type { Translation } from '../translation';
+
+export const zh_TW: Translation = {
+  [Key.home]: '首頁',
+  [Key.about]: '關於',
+  [Key.archive]: '彙整',
+  [Key.search]: '搜尋',
+
+  [Key.tags]: '標籤',
+  [Key.categories]: '分類',
+  [Key.recentPosts]: '最新文章',
+  [Key.randomPost]: '隨機文章',
+
+  [Key.comments]: '評論',
+  [Key.subscribe]: '訂閱',
+
+  [Key.untitled]: '無標題',
+  [Key.uncategorized]: '未分類',
+  [Key.noTags]: '無標籤',
+
+  [Key.wordCount]: '字',
+  [Key.wordsCount]: '字',
+  [Key.minuteCount]: '分鐘',
+  [Key.minutesCount]: '分鐘',
+  [Key.postCount]: '篇文章',
+  [Key.postsCount]: '篇文章',
+
+  [Key.lightMode]: '亮色',
+  [Key.darkMode]: '暗色',
+  [Key.systemMode]: '跟隨系統',
+
+  [Key.more]: '更多',
+
+  [Key.author]: '作者',
+  [Key.publishedAt]: '發佈於',
+  [Key.license]: '許可協議',
+};
src/i18n/i18nKey.ts
@@ -0,0 +1,37 @@
+enum I18nKey {
+  home = 'home',
+  about = 'about',
+  archive = 'archive',
+  search = 'search',
+
+  tags = 'tags',
+  categories = 'categories',
+  recentPosts = 'recentPosts',
+  randomPost = 'randomPost',
+
+  comments = 'comments',
+  subscribe = 'subscribe',
+
+  untitled = 'untitled',
+  uncategorized = 'uncategorized',
+  noTags = 'noTags',
+
+  wordCount = 'wordCount',
+  wordsCount = 'wordsCount',
+  minuteCount = 'minuteCount',
+  minutesCount = 'minutesCount',
+  postCount = 'postCount',
+  postsCount = 'postsCount',
+
+  lightMode = 'lightMode',
+  darkMode = 'darkMode',
+  systemMode = 'systemMode',
+
+  more = 'more',
+
+  author = 'author',
+  publishedAt = 'publishedAt',
+  license = 'license',
+}
+
+export default I18nKey;
src/i18n/translation.ts
@@ -0,0 +1,30 @@
+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';
+
+export type Translation = {
+  [K in I18nKey]: string;
+};
+
+const defaultTranslation = en;
+
+const map: { [key: string]: Translation } = {
+  en: en,
+  en_us: en,
+  en_gb: en,
+  en_au: en,
+  zh_cn: zh_CN,
+  zh_tw: zh_TW,
+};
+
+export function getTranslation(lang: string): Translation {
+  return map[lang.toLowerCase()] || defaultTranslation;
+}
+
+export function i18n(key: I18nKey): string {
+  const lang = siteConfig.lang || 'en';
+  return getTranslation(lang)[key];
+}
src/layouts/MainLayout.astro
@@ -1,9 +1,10 @@
 ---
-import Navbar from '@components/Navbar.astro';
-import SideMenu from '@components/SideMenu.astro';
-
 import Layout from './Layout.astro';
 
+import Navbar from '@components/Navbar.astro';
+import SideToolBar from '@components/SideToolBar.astro';
+import Sidebar from '@components/Sidebar.astro';
+
 interface Props {
   title?: string;
   description?: string;
@@ -15,13 +16,9 @@ const { title, description, lang, pageType } = Astro.props;
 
 <Layout title={title} description={description} lang={lang}>
   <slot slot="head" name="head" />
+  <Sidebar />
   <div id="body-wrap">
-    <div id="navbar-wrapper">
-      <Navbar />
-    </div>
-    <div id="sidemenu-wrapper">
-      <SideMenu />
-    </div>
+    <Navbar title={title} />
     <!-- Main content -->
     <div id="content-wrapper">
       <slot />
src/types/config.ts
@@ -1,7 +1,15 @@
 export type SiteConfig = {
   title: string;
-
   lang: string;
+  favicon: (string | { src: string; theme?: 'light' | 'dark' })[];
+};
+
+export type NavbarConfig = {
+  navbarCenterItems: { text: string; href?: string; onclick?: string }[];
+  navbarRightItems: {
+    onlyWide: { icon: string; text?: string; href?: string; onclick?: string }[];
+    always: { icon: string; text?: string; href?: string; onclick?: string }[];
+  };
 };
 
 export type ProfileConfig = {
src/types/Errors.ts
@@ -0,0 +1,9 @@
+import { AstroError } from 'astro/errors';
+
+export class AstroParameterConflictError extends AstroError {
+  constructor(...params: string[]) {
+    let message = `The following parameters are in conflict: ${params.join(', ')}. Please ensure that only one of these parameters is provided.`;
+    super(message);
+    this.name = 'ParameterConflictError';
+  }
+}
src/config.ts
@@ -1,23 +1,43 @@
-import type {
-  LicenseConfig,
-  ProfileConfig,
-  SiteConfig,
-} from "./types/config";
+import type { LicenseConfig, ProfileConfig, SiteConfig, NavbarConfig } from './types/config';
+
+import I18nKey from '@i18n/i18nKey';
+import { i18n } from '@i18n/translation';
 
 export const siteConfig: SiteConfig = {
-  title: "Astral Halo",
-  lang: "en", // "en" | "zh_CN"
+  title: 'Astral Halo',
+  lang: 'zh_CN', // "en" | "zh_CN" | "zh_TW"
+  favicon: [''],
 };
 
 export const profileConfig: ProfileConfig = {
-  avatar: "assets/images/demo-avatar.png",
-  name: "John Doe",
-  bio: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+  avatar: 'assets/images/demo-avatar.png',
+  name: 'John Doe',
+  bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
   links: [],
 };
 
+export const navbarConfig: NavbarConfig = {
+  navbarCenterItems: [
+    { text: i18n(I18nKey.archive), href: '/archive' },
+    { text: i18n(I18nKey.categories), href: '/categories' },
+    { text: i18n(I18nKey.tags), href: '/tags' },
+    { text: i18n(I18nKey.about), href: '/about' },
+  ],
+  navbarRightItems: {
+    onlyWide: [
+      // Items displayed only when the width is greater than 768px.
+      // 仅在宽度大于 768px 时显示的项目
+      { icon: 'material-symbols:rss-feed-rounded', text: i18n(I18nKey.subscribe), onclick: '' },
+    ],
+    always: [
+      { icon: 'material-symbols:casino', text: i18n(I18nKey.randomPost), onclick: '' },
+      { icon: 'material-symbols:search-rounded', text: i18n(I18nKey.search), onclick: '' },
+    ],
+  },
+};
+
 export const licenseConfig: LicenseConfig = {
   enable: true,
-  name: "CC BY-NC-SA 4.0",
-  url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",
+  name: 'CC BY-NC-SA 4.0',
+  url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
 };
astro.config.mjs
@@ -1,10 +1,14 @@
 // @ts-check
 // @ts-check
-import { defineConfig } from "astro/config";
+import { defineConfig } from 'astro/config';
+
+import tailwind from '@astrojs/tailwind';
+import icon from 'astro-icon';
 
 // https://astro.build/config
 export default defineConfig({
-    site: "https://astral-halo.netilify.app/",
-    base: "/",
-    trailingSlash: "always",
+  site: 'https://astral-halo.netilify.app/',
+  base: '/',
+  trailingSlash: 'always',
+  integrations: [tailwind(), icon()],
 });
package.json
@@ -9,19 +9,23 @@
     "astro": "astro"
   },
   "dependencies": {
-    "astro": "^5.1.5"
+    "@astrojs/tailwind": "^5.1.4",
+    "@iconify-json/material-symbols": "^1.2.12",
+    "astro": "^5.1.5",
+    "astro-compress": "2.3.5",
+    "astro-icon": "^1.1.5",
+    "stylus": "^0.64.0",
+    "tailwindcss": "^3.4.17",
+    "typescript": "^5.7.3"
   },
   "devDependencies": {
     "@astrojs/check": "^0.9.4",
-    "@astrojs/tailwind": "^5.1.4",
     "@astrojs/ts-plugin": "^1.10.4",
-    "astro-compress": "2.3.5",
     "astro-eslint-parser": "^1.1.0",
     "eslint": "^9.16.0",
     "eslint-plugin-astro": "^1.3.1",
     "prettier": "^3.4.2",
     "prettier-plugin-astro": "^0.14.1",
-    "typescript": "^5.7.3",
     "typescript-eslint": "^8.17.0"
   }
 }
\ No newline at end of file
tailwind.config.mjs
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
+  theme: {
+    extend: {
+      letterSpacing: {
+        'extra-wide': '0.25em',
+      },
+    },
+  },
+  plugins: [],
+};