Commit 4ee904d

HPCesia <me@hpcesia.com>
2025-02-13 09:42:12
feat: banner
1 parent a046bd7
src/assets/img/demo_banner.jpg
Binary file
src/components/Banner.astro
@@ -0,0 +1,105 @@
+---
+import { siteConfig } from '@/config';
+import ImageWrapper from './utils/ImageWrapper.astro';
+
+interface Props {
+  src?: string;
+}
+
+const siteBanner = siteConfig.banner;
+if (siteBanner === false) throw Error('Should not show this error');
+
+const src = Astro.props.src || (siteBanner.src as string);
+const customBanner = Boolean(Astro.props.src !== undefined);
+---
+
+<div
+  id="banner"
+  class="relative max-h-screen scale-105 overflow-hidden opacity-0 duration-1000"
+  data-custom-banner={customBanner}
+>
+  <ImageWrapper src={src} class="h-full object-cover" />
+  <div class="from-base-100 to-base-100/0 absolute bottom-0 h-1/2 min-h-48 w-full bg-linear-0">
+  </div>
+</div>
+
+<script>
+  import { siteConfig } from '@/config';
+  import { pathMatch, pathsEqual, url } from '@utils/url-utils';
+
+  function getBannerHeight(path: string) {
+    if (siteConfig.banner === false) {
+      console.error('Banner is disabled. Should not show this error, must be a bug');
+      return null;
+    }
+    if (pathsEqual(url('/'), path)) {
+      return siteConfig.banner.homepageHeight;
+    }
+    if (pathMatch(/\/posts\/.*/, path)) {
+      return siteConfig.banner.postHeight;
+    }
+    for (const { pagePathRegex, height } of siteConfig.banner.pagesHeight) {
+      if (pathMatch(pagePathRegex, path)) {
+        return height;
+      }
+    }
+    return siteConfig.banner.defaultHeight;
+  }
+
+  function setupBanner() {
+    const banner = document.getElementById('banner');
+    banner?.classList.remove('opacity-0', 'scale-105');
+    banner!.style.height = getBannerHeight(window.location.pathname) as string;
+    banner!.style.translate = '0';
+  }
+
+  document.addEventListener('astro:after-swap', setupBanner);
+  setupBanner();
+
+  function swupSetupBanner() {
+    window.swup?.hooks.before('visit:start', (visit) => {
+      const banner = document.getElementById('banner');
+      if (!banner) {
+        console.error('Banner not found');
+        return;
+      }
+      const height = getBannerHeight(visit.to.url) as string;
+      banner.style.height = height;
+      banner.style.translate = `0 ${height}`;
+
+      const heightExtend = document.getElementById('page-height-extend');
+      if (!heightExtend) {
+        console.error('Height extend not found');
+        return;
+      }
+      heightExtend.classList.remove('hidden');
+    });
+
+    window.swup?.hooks.before('content:replace', (visit) => {
+      const banner = document.getElementById('banner');
+      if (!banner) {
+        console.error('Banner not found');
+        return;
+      }
+      const height = getBannerHeight(visit.to.url) as string;
+      banner.style.translate = `0 ${height}`;
+      const heightExtend = document.getElementById('page-height-extend');
+      if (!heightExtend) {
+        console.error('Height extend not found');
+        return;
+      }
+      heightExtend.classList.remove('hidden');
+    });
+
+    window.swup?.hooks.on('visit:end', () => {
+      const heightExtend = document.getElementById('page-height-extend');
+      heightExtend?.classList.add('hidden');
+    });
+  }
+
+  if (window.swup) {
+    swupSetupBanner();
+  } else {
+    document.addEventListener('swup:enable', swupSetupBanner);
+  }
+</script>
src/components/Navbar.astro
@@ -1,5 +1,5 @@
 ---
-import { navbarConfig } from '@/config';
+import { navbarConfig, siteConfig } from '@/config';
 import { i18n } from '@i18n/translation';
 import { Icon } from 'astro-icon/components';
 import Button from './widgets/Button.astro';
@@ -87,7 +87,7 @@ if (!title) title = 'Astral Halo';
         </div>
       </div>
     </div>
-    <div id="navbar-placeholder" class="pt-20"></div>
+    {siteConfig.banner === false && <div id="navbar-placeholder" class="pt-20" />}
     <!-- Page Content -->
     <slot />
   </div>
src/layouts/GlobalLayout.astro
@@ -2,6 +2,7 @@
 import { profileConfig, searchConfig, siteConfig } from '@/config';
 import '@/styles/global.css';
 import type { Favicon } from '@/types/config';
+import Banner from '@components/Banner.astro';
 import Navbar from '@components/Navbar.astro';
 import PageFooter from '@components/PageFooter.astro';
 import Search from '@components/Search.astro';
@@ -14,7 +15,8 @@ interface Props {
   lang?: string;
 }
 
-let { title, lang, description } = Astro.props;
+let { title, lang } = Astro.props;
+const { description } = Astro.props;
 
 let pageTitle: string;
 if (title) pageTitle = `${title} - ${siteConfig.title}`;
@@ -78,12 +80,14 @@ const favicons: Favicon[] =
     <SideToolBar />
     <Navbar>
       <slot name="header" />
+      {siteConfig.banner !== false && <Banner />}
       <div id="body-wrap" class="w-full items-center md:px-4">
         <slot />
       </div>
     </Navbar>
     {searchConfig.enable && <Search />}
     <PageFooter />
+    {siteConfig.banner !== false && <div id="page-height-extend" class="hidden" />}
   </body>
 </html>
 
src/layouts/MainLayout.astro
@@ -1,4 +1,5 @@
 ---
+import { siteConfig } from '@/config';
 import GlobalLayout from './GlobalLayout.astro';
 
 interface Props {
@@ -13,8 +14,10 @@ const { title, description, lang } = Astro.props;
   <slot slot="head" name="head" />
   <slot slot="header" name="header" />
   <!-- Main content -->
-  <main class="mx-auto flex max-w-(--breakpoint-xl) flex-col gap-4">
-    <slot name="header-content" />
+  <main class="relative mx-auto flex max-w-(--breakpoint-xl) flex-col gap-4">
+    <div class:list={[siteConfig.banner !== false && 'absolute top-0 -translate-y-full']}>
+      <slot name="header-content" />
+    </div>
     <div class="flex gap-4">
       <div id="main-content" class="my-4 w-full">
         <slot />
src/pages/posts/[article].astro
@@ -1,6 +1,8 @@
 ---
+import { siteConfig } from '@/config';
 import License from '@components/misc/License.astro';
 import PostInfo from '@components/misc/PostInfo.astro';
+import ImageWrapper from '@components/utils/ImageWrapper.astro';
 import Markdown from '@components/utils/Markdown.astro';
 import PostPageLayout from '@layouts/PostPageLayout.astro';
 import { getOrCreateRenderResult } from '@utils/content-utils';
@@ -16,6 +18,8 @@ export async function getStaticPaths() {
 
 const { article } = Astro.props;
 const { Content, headings, remarkPluginFrontmatter } = await getOrCreateRenderResult(article);
+const hasCover =
+  article.data.cover !== '' && article.data.cover !== undefined && article.data.cover !== null;
 
 const description = article.data.description || remarkPluginFrontmatter.excerpt;
 ---
@@ -39,6 +43,15 @@ const description = article.data.description || remarkPluginFrontmatter.excerpt;
       class="mx-2 mt-4"
     />
   </Fragment>
+  {
+    siteConfig.banner === false && hasCover && (
+      <ImageWrapper
+        src={article.data.cover}
+        class="mb-6 rounded-xl shadow"
+        alt={article.data.title}
+      />
+    )
+  }
   <Markdown>
     <Content />
   </Markdown>
src/types/config.ts
@@ -125,6 +125,58 @@ export type SiteConfig = {
    * 每页显示的文章数量。
    */
   postsPerPage: number;
+  /**
+   * The configuration of the banner.
+   *
+   * 横幅的配置。
+   */
+  banner:
+    | false
+    | {
+        /**
+         * The URL of the banner.
+         *
+         * 横幅的 URL。
+         */
+        src: string;
+        /**
+         * The height of the banner in homepage.
+         *
+         * 主页中横幅的高度。
+         */
+        homepageHeight: `${number}${'vh' | 'rem' | 'px'}`;
+        /**
+         * The height of the banner in post page.
+         *
+         * 文章页面中横幅的高度。
+         */
+        postHeight: `${number}${'vh' | 'rem' | 'px'}`;
+        /**
+         * The height of the banner in pages.
+         *
+         * 页面中横幅的高度。
+         */
+        pagesHeight: {
+          /**
+           * The regular expression of the page path.
+           *
+           * 页面路径的正则表达式。
+           */
+          pagePathRegex: RegExp;
+          /**
+           * The height of the banner.
+           *
+           * 横幅的高度。
+           */
+          height: `${number}${'vh' | 'rem' | 'px'}`;
+        }[];
+        /**
+         * The default height of the banner.
+         *
+         * 横幅的默认高度。
+         */
+        defaultHeight: `${number}${'vh' | 'rem' | 'px'}`;
+      };
 };
 
 export type ProfileConfig = {
src/utils/content-utils.ts
@@ -2,7 +2,7 @@ import type { BlogPostData } from '@/types/data';
 import I18nKey from '@i18n/I18nKey';
 import { i18n } from '@i18n/translation';
 import type { MarkdownHeading } from 'astro';
-import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
+import type { AstroComponentFactory } from 'astro/runtime/server/index.d.ts';
 import { getCollection } from 'astro:content';
 import { type CollectionEntry, render } from 'astro:content';
 
src/utils/url-utils.ts
@@ -0,0 +1,27 @@
+function joinUrl(...parts: string[]): string {
+  const joined = parts.join('/');
+  return joined.replace(/\/+/g, '/');
+}
+
+export function pathsEqual(path1: string, path2: string) {
+  const normalizedPath1 = path1.replace(/^\/|\/$/g, '').toLowerCase();
+  const normalizedPath2 = path2.replace(/^\/|\/$/g, '').toLowerCase();
+  return normalizedPath1 === normalizedPath2;
+}
+
+export function pathMatch(regex: RegExp, path: string) {
+  const normalizedPath = path
+    .split('?')[0]
+    .split('#')[0]
+    .replace(/^\/|\/$/g, '')
+    .toLowerCase();
+  return regex.test(normalizedPath) || regex.test(getRelativeUrl(normalizedPath));
+}
+
+export function getRelativeUrl(path: string) {
+  return joinUrl('/', path.replace(import.meta.env.BASE_URL, ''));
+}
+
+export function url(path: string) {
+  return joinUrl('', import.meta.env.BASE_URL, path);
+}
src/config.ts
@@ -23,6 +23,18 @@ export const siteConfig: SiteConfig = {
   ],
   createAt: new Date('2025-01-01'),
   postsPerPage: 10,
+  banner: {
+    src: 'assets/img/demo_banner.jpg',
+    homepageHeight: '100vh',
+    postHeight: '40vh',
+    pagesHeight: [
+      // {
+      //   pagePathRegex: /\/about\//,
+      //   height: '50vh',
+      // },
+    ],
+    defaultHeight: '40vh',
+  },
 };
 
 export const profileConfig: ProfileConfig = {
@@ -135,7 +147,7 @@ export const commentConfig: CommentConfig = {
   enable: false,
   provider: 'twikoo',
   twikoo: {
-    envId: 'your-env-id',
+    envId: 'https://comment.hpcesia.com/.netlify/functions/twikoo',
   },
   giscus: {
     repo: 'your/repo',
astro.config.mjs
@@ -37,6 +37,8 @@ export default defineConfig({
       containers: ['main'],
       animationClass: 'swup-transition-',
       globalInstance: true,
+      smoothScrolling: true,
+      progress: true,
     }),
   ],
   markdown: {