Commit eec8325

HPCesia <me@hpcesia.com>
2025-01-17 13:52:21
feat: support dark mode
1 parent 8ed39b9
.vscode/settings.json
@@ -3,7 +3,4 @@
   "editor.tabSize": 2,
   "editor.formatOnSave": true,
   "editor.defaultFormatter": "esbenp.prettier-vscode",
-  "[astro]": {
-    "editor.defaultFormatter": "astro-build.astro-vscode"
-  },
 }
\ No newline at end of file
src/components/widgets/Button.astro
@@ -0,0 +1,42 @@
+---
+import { AstroParameterConflictError } from '@/types/Errors';
+import type { HTMLAttributes } from 'astro/types';
+
+interface Props extends HTMLAttributes<'button'> {
+  href?: string;
+}
+const { href, onclick, class: className, ...rest } = Astro.props;
+
+if (href && onclick) throw new AstroParameterConflictError('href', 'onclick');
+---
+
+<button {...{ href, onclick, ...rest }} class:list={[className]}>
+  <slot />
+</button>
+
+<style lang="sass">
+  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
+      @apply bg-[var(--theme-color-light)] dark:bg-[var(--theme-color-dark)]
+</style>
+
+<script>
+  import { navigate } from 'astro:transitions/client';
+  document.querySelectorAll('button[href]').forEach((btn) => {
+    btn.addEventListener('click', () => {
+      let href = btn.getAttribute('href') || '/404.html';
+      navigate(href);
+    });
+  });
+</script>
src/components/widgets/DarkModeButton.astro
@@ -0,0 +1,91 @@
+---
+import type { HTMLAttributes } from 'astro/types';
+import Button from './Button.astro';
+import { Icon } from 'astro-icon/components';
+import I18nKey from '@i18n/i18nKey';
+import { i18n } from '@i18n/translation';
+
+interface Props extends Omit<HTMLAttributes<'button'>, 'onclick'> {
+  showText?: boolean;
+}
+
+const { class: className, showText, ...rest } = Astro.props;
+---
+
+<Button
+  class:list={[
+    'darkmode-btn',
+    'border-2 border-neutral-200 dark:border-neutral-800',
+    className,
+  ]}
+  {...rest}
+>
+  <Icon class="darkmode-icon-light" name="material-symbols:light-mode-rounded" />
+  <Icon class="darkmode-icon-dark" name="material-symbols:dark-mode-rounded" />
+  <Icon class="darkmode-icon-auto" name="material-symbols:night-sight-auto-rounded" />
+  <span class="px-2 ml-auto mr-2">
+    {
+      showText &&
+        <span class="darkmode-text-light">{i18n(I18nKey.lightMode)}</span>
+        <span class="darkmode-text-dark">{i18n(I18nKey.darkMode)}</span>
+        <span class="darkmode-text-auto">{i18n(I18nKey.systemMode)}</span>
+    }
+  </span>
+</Button>
+
+<script>
+  const darkmodeBtns = document.querySelectorAll('button.darkmode-btn');
+
+  function refreshButtons() {
+    darkmodeBtns.forEach((btn) => {
+      const iconLight = btn.querySelector('.darkmode-icon-light');
+      const iconDark = btn.querySelector('.darkmode-icon-dark');
+      const iconAuto = btn.querySelector('.darkmode-icon-auto');
+      const textLight = btn.querySelector('.darkmode-text-light');
+      const textDark = btn.querySelector('.darkmode-text-dark');
+      const textAuto = btn.querySelector('.darkmode-text-auto');
+
+      if ('darkMode' in localStorage && localStorage.darkMode === 'true') {
+        iconLight?.classList.add('hidden');
+        iconDark?.classList.remove('hidden');
+        iconAuto?.classList.add('hidden');
+        textLight?.classList.add('hidden');
+        textDark?.classList.remove('hidden');
+        textAuto?.classList.add('hidden');
+      } else if ('darkMode' in localStorage && localStorage.darkMode === 'false') {
+        iconLight?.classList.remove('hidden');
+        iconDark?.classList.add('hidden');
+        iconAuto?.classList.add('hidden');
+        textLight?.classList.remove('hidden');
+        textDark?.classList.add('hidden');
+        textAuto?.classList.add('hidden');
+      } else {
+        iconLight?.classList.add('hidden');
+        iconDark?.classList.add('hidden');
+        iconAuto?.classList.remove('hidden');
+        textLight?.classList.add('hidden');
+        textDark?.classList.add('hidden');
+        textAuto?.classList.remove('hidden');
+      }
+    });
+  }
+
+  darkmodeBtns.forEach((btn) => {
+    btn.addEventListener('click', () => {
+      if (!('darkMode' in localStorage)) {
+        localStorage.darkMode = false;
+        document.documentElement.classList.remove('dark');
+      } else if (localStorage.darkMode === 'false') {
+        localStorage.darkMode = true;
+        document.documentElement.classList.add('dark');
+      } else {
+        localStorage.removeItem('darkMode');
+        if (window.matchMedia('(prefers-color-scheme: dark)').matches)
+          document.documentElement.classList.add('dark');
+        else document.documentElement.classList.remove('dark');
+      }
+      refreshButtons();
+    });
+  });
+  refreshButtons();
+</script>
src/components/widgets/IconButton.astro
@@ -1,38 +0,0 @@
----
-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.assign(\`${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/TextButton.astro
@@ -1,35 +0,0 @@
----
-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.assign(\`${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/CatagoryBar.astro
@@ -0,0 +1,5 @@
+---
+
+---
+
+<div id="catagory-bar" class="flex w-full rounded-md px-2 py-3"></div>
src/components/Navbar.astro
@@ -1,10 +1,9 @@
 ---
-import { navbarConfig, siteConfig } from '@/config';
+import { navbarConfig, themeConfig } from '@/config';
 
 import { Icon } from 'astro-icon/components';
 
-import TextButton from './widgets/TextButton.astro';
-import IconButton from './widgets/IconButton.astro';
+import Button from './widgets/Button.astro';
 
 interface Props {
   title?: string;
@@ -14,18 +13,28 @@ let { title } = Astro.props;
 if (!title) title = 'Astral Halo';
 ---
 
-<div id="navbar" class="flex w-full h-[60px] bg-gray-100 items-center">
+<div
+  id="navbar"
+  transition:persist
+  class="flex w-full h-[60px] items-center border-b-2 border-b-neutral-200 dark:border-b-neutral-800 bg-neutral-50 dark:bg-neutral-950"
+>
   <div id="nav-left" class="left-0 flex mr-auto w-fit">
-    <TextButton id="site-name-wrapper" href="/">
+    <Button id="site-name-wrapper" href="/">
       <span class="text-xl font-bold">{title}</span>
-    </TextButton>
+    </Button>
   </div>
   <div id="nav-center" class="left-0 flex m-auto w-fit max-md:hidden">
     {
       navbarConfig.navbarCenterItems.map((item) => (
-        <TextButton id="nav-menu-item" href={item.href} onclick={item.onclick}>
-          <span class="text-xl">{item.text}</span>
-        </TextButton>
+        <Button
+          id="nav-menu-item"
+          href={item.href}
+          onclick={item.onclick}
+          title={item.text}
+          class="!px-4"
+        >
+          <span class="text-xl tracking-wide">{item.text}</span>
+        </Button>
       ))
     }
   </div>
@@ -33,25 +42,35 @@ if (!title) title = 'Astral Halo';
     <div class="flex max-md:hidden">
       {
         navbarConfig.navbarRightItems.onlyWide.map((item) => (
-          <IconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
+          <Button
+            id="nav-menu-item"
+            href={item.href}
+            onclick={item.onclick}
+            title={item.text}
+          >
             <Icon name={item.icon} class="text-2xl" />
-          </IconButton>
+          </Button>
         ))
       }
     </div>
     <div class="flex">
       {
         navbarConfig.navbarRightItems.always.map((item) => (
-          <IconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
+          <Button
+            id="nav-menu-item"
+            href={item.href}
+            onclick={item.onclick}
+            title={item.text}
+          >
             <Icon name={item.icon} class="text-2xl" />
-          </IconButton>
+          </Button>
         ))
       }
     </div>
     <div class="flex md:hidden">
-      <IconButton id="toggle-sidebar-btn">
+      <Button id="toggle-sidebar-btn">
         <Icon name="material-symbols:menu-rounded" class="text-2xl" />
-      </IconButton>
+      </Button>
     </div>
   </div>
 </div>
src/components/PostCards.astro
@@ -0,0 +1,1 @@
+<div id="post-cards"></div>
src/components/Sidebar.astro
@@ -1,33 +1,52 @@
 ---
-import { navbarConfig } from '@/config';
+import { navbarConfig, themeConfig } from '@/config';
 
 import { Icon } from 'astro-icon/components';
 
-import TextButton from './widgets/TextButton.astro';
-import IconButton from './widgets/IconButton.astro';
+import Button from './widgets/Button.astro';
+import DarkModeButton from './widgets/DarkModeButton.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 id="sidebar" transition:persist>
+  <div
+    id="sidebar-mask"
+    class="fixed z-40 hidden w-full h-full bg-black/10 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"
+    class="fixed md:hidden h-full z-50 -right-1/3 w-1/3 duration-500 ease-in-out border-l-2 border-l-neutral-200 dark:border-l-neutral-800 bg-neutral-50 dark:bg-neutral-950"
   >
-    {
-      navbarConfig.navbarCenterItems.map((item) => (
-        <TextButton id="nav-menu-item" href={item.href} onclick={item.onclick}>
-          <span class="text-xl">{item.text}</span>
-        </TextButton>
-      ))
-    }
-    {
-      navbarConfig.navbarRightItems.onlyWide.map((item) => (
-        <IconButton id="nav-menu-item" href={item.href} onclick={item.onclick} description={item.text}>
-          <Icon name={item.icon} class="text-2xl" />
-        </IconButton>
-      ))
-    }
+    <div id="sidebar-site-data"></div>
+    <DarkModeButton showText={true} class="text-lg !w-[calc(100%-1rem)]" />
+    <div id="sidebar-menu-text-items">
+      {
+        navbarConfig.navbarCenterItems.map((item) => (
+          <Button
+            id="sidebar-menu-item"
+            href={item.href}
+            onclick={item.onclick}
+            title={item.text}
+          >
+            <span class="text-xl">{item.text}</span>
+          </Button>
+        ))
+      }
+    </div>
+    <div id="sidebar-menu-icon-items" class="flex">
+      {
+        navbarConfig.navbarRightItems.onlyWide.map((item) => (
+          <Button
+            id="sidebar-menu-item"
+            href={item.href}
+            onclick={item.onclick}
+            title={item.text}
+          >
+            <Icon name={item.icon} class="text-2xl" />
+          </Button>
+        ))
+      }
+    </div>
   </div>
 </div>
 
src/layouts/GlobalLayout.astro
@@ -1,5 +1,6 @@
 ---
-import { profileConfig, siteConfig } from '@/config';
+import { profileConfig, siteConfig, themeConfig } from '@/config';
+import { ClientRouter } from 'astro:transitions';
 
 interface Props {
   title?: string;
@@ -25,6 +26,8 @@ const siteLang = lang.replace('_', '-');
     <meta charset="UTF-8" />
     <meta name="author" content={profileConfig.name} />
 
+    <ClientRouter />
+
     <!-- Open Graph / Facebook -->
     <meta property="og:site_name" content={siteConfig.title} />
     <meta property="og:url" content={Astro.url} />
@@ -42,7 +45,32 @@ const siteLang = lang.replace('_', '-');
 
     <slot name="head" />
   </head>
-  <body>
+  <body class="bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50">
     <slot />
   </body>
 </html>
+
+<script>
+  // 深色模式切换
+  function applyDarkMode() {
+    if (
+      localStorage.darkMode === 'true' ||
+      (!('darkMode' in localStorage) &&
+        window.matchMedia('(prefers-color-scheme: dark)').matches)
+    ) {
+      document.documentElement.classList.add('dark');
+    } else document.documentElement.classList.remove('dark');
+  }
+  document.addEventListener('astro:after-swap', applyDarkMode);
+  applyDarkMode();
+</script>
+
+<style
+  is:global
+  define:vars={{
+    'theme-color-light': themeConfig.themeColorLight,
+    'theme-color-dark': themeConfig.themeColorDark,
+    'theme-color-sub-light': themeConfig.themeColorSubLight,
+    'theme-color-sub-dark': themeConfig.themeColorSubDark,
+  }}
+></style>
src/layouts/MainLayout.astro
@@ -16,7 +16,7 @@ const { title, description, lang } = Astro.props;
   <slot slot="head" name="head" />
   <Sidebar />
   <div id="body-wrap">
-    <Navbar title={title} />
+    <Navbar />
     <!-- Main content -->
     <div id="content-wrapper">
       <slot />
src/pages/about.astro
@@ -0,0 +1,10 @@
+---
+import MainLayout from '@layouts/MainLayout.astro';
+
+import I18nKey from '@i18n/i18nKey';
+import { i18n } from '@i18n/translation';
+---
+
+<MainLayout title={i18n(I18nKey.about)}>
+  <div></div>
+</MainLayout>
src/pages/index.astro
@@ -1,7 +1,12 @@
 ---
+import PostCards from '@components/PostCards.astro';
+import CatagoryBar from '@components/widgets/CatagoryBar.astro';
 import MainLayout from '@layouts/MainLayout.astro';
 ---
 
 <MainLayout>
-  <h1>Welcome to Astro</h1>
+  <div id="main-content">
+    <CatagoryBar />
+    <PostCards />
+  </div>
 </MainLayout>
src/types/Color.ts
@@ -0,0 +1,5 @@
+export type HexColor = `#${string}`;
+export type RGBColor = `rgb(${number}, ${number}, ${number})`;
+export type RGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
+
+export type Color = HexColor | RGBColor | RGBAColor;
src/types/config.ts
@@ -1,17 +1,11 @@
+import type { Color } from './Color';
+
 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 = {
   avatar?: string;
   name: string;
@@ -23,6 +17,26 @@ export type ProfileConfig = {
   }[];
 };
 
+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 ThemeConfig = {
+  themeColorLight: Color;
+  themeColorSubLight: Color;
+  themeColorDark: Color;
+  themeColorSubDark: Color;
+};
+
 export type LicenseConfig = {
   enable: boolean;
   name: string;
src/config.ts
@@ -1,4 +1,10 @@
-import type { LicenseConfig, ProfileConfig, SiteConfig, NavbarConfig } from './types/config';
+import type {
+  LicenseConfig,
+  ProfileConfig,
+  SiteConfig,
+  NavbarConfig,
+  ThemeConfig,
+} from './types/config';
 
 import I18nKey from '@i18n/i18nKey';
 import { i18n } from '@i18n/translation';
@@ -27,15 +33,34 @@ export const navbarConfig: NavbarConfig = {
     onlyWide: [
       // Items displayed only when the width is greater than 768px.
       // 仅在宽度大于 768px 时显示的项目
-      { icon: 'material-symbols:rss-feed-rounded', text: i18n(I18nKey.subscribe), onclick: '' },
+      {
+        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: '' },
+      {
+        icon: 'material-symbols:casino',
+        text: i18n(I18nKey.randomPost),
+        onclick: '',
+      },
+      {
+        icon: 'material-symbols:search-rounded',
+        text: i18n(I18nKey.search),
+        onclick: '',
+      },
     ],
   },
 };
 
+export const themeConfig: ThemeConfig = {
+  themeColorLight: '#D75B3D',
+  themeColorSubLight: '#e5d5d5',
+  themeColorDark: '#397D9F',
+  themeColorSubDark: '#033D60',
+};
+
 export const licenseConfig: LicenseConfig = {
   enable: true,
   name: 'CC BY-NC-SA 4.0',
.prettierrc.cjs
@@ -1,13 +1,13 @@
 /** @type {import('prettier').Config} */
 module.exports = {
-  printWidth: 120,
+  printWidth: 96,
   semi: true,
   singleQuote: true,
   tabWidth: 2,
   trailingComma: 'es5',
   useTabs: false,
 
-  plugins: [require.resolve('prettier-plugin-astro')],
+  plugins: ['prettier-plugin-astro'],
 
   overrides: [{ files: '*.astro', options: { parser: 'astro' } }],
 };
astro.config.mjs
@@ -1,5 +1,4 @@
 // @ts-check
-// @ts-check
 import { defineConfig } from 'astro/config';
 
 import tailwind from '@astrojs/tailwind';
@@ -10,5 +9,5 @@ export default defineConfig({
   site: 'https://astral-halo.netilify.app/',
   base: '/',
   trailingSlash: 'always',
-  integrations: [tailwind(), icon()],
+  integrations: [tailwind({ nesting: true }), icon()],
 });
package.json
@@ -14,7 +14,7 @@
     "astro": "^5.1.5",
     "astro-compress": "2.3.5",
     "astro-icon": "^1.1.5",
-    "stylus": "^0.64.0",
+    "sass": "^1.83.4",
     "tailwindcss": "^3.4.17",
     "typescript": "^5.7.3"
   },
tailwind.config.mjs
@@ -8,5 +8,6 @@ export default {
       },
     },
   },
+  darkMode: 'selector',
   plugins: [],
 };