mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-14 07:33:07 +08:00
feat!: AstroPaper v6 (#631)
* feat: add initial configuration and social icons for AstroPaper theme * feat: add theme styles and design tokens * chore: configure Astro with sitemap, i18n, fonts, and tooling - Set site URL, i18n (single locale, no prefix), sitemap integration - Add Google Sans Code font via stable fonts API - Configure Shiki dual-theme markdown (min-light / night-owl) - Fix astro-paper.config.ts to use relative import (alias not available when Vite loads root-level config files) - Add @astrojs/sitemap and @typescript-eslint/parser dependencies - Wire @typescript-eslint/parser into ESLint so TypeScript syntax in Astro frontmatter (type Props, etc.) parses correctly * feat: add base styles, dark variant, and typography - global.css: dark variant via [data-theme], base layer for * / a / button selectors, scrollbar tokens, layout utilities (max-w-app, app-layout), active-nav as @utility for Tailwind variant support - html and body classes moved directly onto elements in BaseLayout (Tailwind-native approach) so only truly global selectors remain here - typography.css: prose overrides, Shiki light/dark code block switching, diff/highlight/word-highlight transformer styles * feat: add i18n types, English strings, and useTranslations helper Plain typed object approach — no third-party i18n library needed. Paraglide.js was considered but requires SSR output (hard blocker for a static Astro site). - UIStrings interface with nav, post, pagination, and a11y groups - en.ts with `satisfies UIStrings` for literal-type preservation - useTranslations(locale) helper falls back to English automatically Adding a new language is a one-file addition; full routing stays as a registry add-on. * feat: add client-side theme toggle script - Reads window.__theme.value set by the inline FOUC-prevention script to avoid re-detecting the theme on first load - Toggles data-theme on <html> and persists to localStorage - Fills <meta name="theme-color"> with the computed background colour so Android's browser chrome matches the page - Re-runs setup on astro:after-swap for View Transitions navigation - Carries theme-color across astro:before-swap to prevent navbar flicker on Android during page transitions - Syncs with OS prefers-color-scheme changes at runtime * feat: add BaseLayout and PostLayout BaseLayout (replaces scaffold Layout.astro): - Font, canonical URL via Astro.site, RSS autodiscovery - FOUC-prevention inline script sets data-theme before first paint and exposes window.__theme.value for theme.ts to reuse - html/body classes on elements directly (Tailwind-native) - <slot name="head"> for child layout injection - ClientRouter for View Transitions (respects lightAndDarkMode flag) - googleVerification rendered from config instead of .env — it is a public string, not a secret, and not environment-specific PostLayout (new): - Wraps BaseLayout; injects article-specific head content via slot - Overrides og:type to "article" - Adds article:published_time / article:modified_time conditionally - JSON-LD BlogPosting with conditional datePublished/dateModified (fixes old bug where absent dates produced "undefined" string) - Structured data only on actual post pages (not home/archives/tags) SiteConfig: add optional googleVerification field * chore: replace scaffold index page with BaseLayout placeholder * feat: add Header component * feat: add Footer and Socials component * chore: install dayjs, slugify, zod, @astrojs/mdx and add MDX integration * feat: add resolved config that applies defaults in one place Defaults for posts, features, socials, and shareLinks are merged in src/config.ts so all consuming code gets non-optional types with no scattered ?? fallbacks. * feat: add content schema, blog path, and post utilities Renames collection blog→posts, moves content to src/content/posts/, adds .mdx support, and adds a typed pages collection. Includes getSortedPosts, postFilter, getPath, and slugify utilities. * feat: add Datetime and Card components * refactor: switch layouts and components to resolved config Replaces @/astro-paper.config imports with @/config and removes optional chaining on feature flags now that the resolved config guarantees non-optional values. * feat: add home and footer i18n strings * feat: implement home page with hero, featured, and recent posts Replaces the BaseLayout placeholder with the full index page: hero section with RSS link and social links, featured posts section, recent posts capped at posts.perIndex, and an All Posts link. All section labels come from i18n. * feat: add pagination component * feat: add Breadcrumb component with locale support * feat: add Main component * refactor: update code formatting in Card.astro * feat: add posts page * fix: add missing @astrojs/mdx dependency * refactor: rename meta to pages in i18n * refactor: improve code formatting in Breadcrumb * refactor: destructure props in Main component for clarity * feat: add tag pages * feat: add About page * chore: update i18n configuration to use site language from config * feat: add Archives page * feat: integrate Pagefind search functionality * feat: add dynamic Open Graph image generation for posts * feat: add post detail page * feat: add Shiki transformers * chore: remove unused edit link text * chore: update .gitignore to include generated Pagefind directory * chore: update site URL in configuration to production link * chore: replace favicon and remove unused SVG assets, add new Open Graph image * feat: update site config with default values * feat: add default OG image path resolution * fix: ensure edit post feature checks for enabled status correctly * feat: implement dynamic Open Graph image generation * feat: add RSS feed generation functionality * feat: add 404 Not Found page * feat: add robots.txt generation for SEO optimization * refactor: change getSortedPosts to a named export * refactor: change postFilter to a named export * refactor: rename BaseLayout to Layout * refactor: standardize locale handling across components * refactor: update getPostsByGroupCondition location * chore: remove unused Welcome component * refactor(i18n): update links using getRelativeLocaleUrl * refactor(i18n): add dynamic locale loading * fix: update language attribute to use current locale * fix: correct wording for updatedAt in post translations * fix: update layout titles to use correct translation keys for tags pages * chore: use new colors for better DX and a11y * feat: add underline icon for active icon nav * feat: add toc for markdown contents * refactor: remove unused feature flags in config * refactor(i18n): enhance locale handling and path management * fix: ts unused variable hint * refactor: extract locale stripping logic into a util func * refactor(i18n): improve locale handling in tag pages * refactor: update post URL helper functions * refactor(i18n): replace pathWithBase with getRelativeLocaleUrl for locale handling * refactor: rename fileWithBase to getAssetPath * refactor: use runtime agnostic APIs for file paths * refactor: use transition directive for heading transition * refactor: remove unused title element from tag page * fix: decouple ClientRouter from lightAndDarkMode flag * refactor: rename `postPaths.ts` to `getPostPaths.ts` * refactor: remove unused `minutesRead` string from post translations * refactor: simplify AdjacentPostNav component by passing prevPost and nextPost directly * refactor: remove unused code declaration * docs: add existing blog posts * chore: update astro and @astrojs/check dependencies to latest versions * build: use experimental svg optimizer * chore(repo): restore project meta files and Docker configs * refactor: update code formatting with Prettier * docs: update README and docs * docs: add JSDoc comments to utility functions * docs: add JSDoc comments to resolved config file * fix(og): avoid double slashes when appending index.png * chore(ci): update Node.js version to 22.12.0 in CI workflow * feat: improve og image generation with Astro's getFontFileURL API (#632) * feat: improve og image generation with Astro's getFontFileURL API - remove @resvg/resvg-js and use sharp to convert SVG to PNG - update og image generation to use Astro's getFontFileURL API for better font handling * docs: update README and dynamic OG images documentation - remove outdated content and clarify dynamic OG image generation process in README. - add new image example and update instructions for handling non-Latin characters in dynamic OG images. - adjust copyright year in README to 2026. * fix(header): adjust icon button alignment in mobile * feat(docs): add ResponsiveTable to use it in MDX posts (#634) * feat(docs): add ResponsiveTable to use it in MDX posts Introduce ResponsiveTable with variant-based styling. Rename docs posts to .mdx, wrap tables in the component, and remove redundant table `code` rules from prose typography. * chore(prettier): ignore .mdx files when checking format * chore(ci): update Node.js version to 24 in CI workflow * bump: upgrade Astro and dependencies * fix: remove redundant backUrl script bug * fix(header): adjust button and icon alignment * fix(a11y): improve muted-foreground color contrast * docs: update predefined color schemes * chore: support site verification env as fallback for googleVerification * docs: add astro paper v6 updates in docs * docs: add AstroPaper-v6.png in astro-paper-6 blog post * fix: update back-to-top button DOM placement (#641) Closes #626, #131, #493
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
---
|
||||
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
|
||||
import LinkButton from "./LinkButton.astro";
|
||||
import { SITE } from "@/config";
|
||||
---
|
||||
|
||||
{
|
||||
SITE.showBackButton && (
|
||||
<div class="app-layout flex items-center justify-start">
|
||||
<LinkButton
|
||||
id="back-button"
|
||||
href="/"
|
||||
class="focus-outline -ms-2 mt-8 mb-2 hover:text-foreground/75"
|
||||
>
|
||||
<IconChevronLeft class="inline-block size-6 rtl:rotate-180" />
|
||||
<span>Go back</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
/* Update Search Praam */
|
||||
function updateGoBackUrl() {
|
||||
const backButton: HTMLAnchorElement | null =
|
||||
document.querySelector("#back-button");
|
||||
|
||||
const backUrl = sessionStorage.getItem("backUrl");
|
||||
|
||||
if (backUrl && backButton) {
|
||||
backButton.href = backUrl;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", updateGoBackUrl);
|
||||
updateGoBackUrl();
|
||||
</script>
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
|
||||
import IconArrowNarrowUp from "@/assets/icons/IconArrowNarrowUp.svg";
|
||||
---
|
||||
|
||||
<div
|
||||
id="btt-btn-container"
|
||||
class:list={[
|
||||
"fixed end-4 bottom-8 z-50",
|
||||
"md:sticky md:end-auto md:float-end md:me-1",
|
||||
"translate-y-14 opacity-0 transition duration-500",
|
||||
]}
|
||||
>
|
||||
<button
|
||||
data-button="back-to-top"
|
||||
class:list={[
|
||||
"group relative bg-background px-2 py-1",
|
||||
"size-14 rounded-full shadow-xl",
|
||||
"md:h-8 md:w-fit md:rounded-md md:shadow-none md:focus-visible:rounded-none",
|
||||
"md:bg-background/35 md:bg-clip-padding md:backdrop-blur-lg",
|
||||
]}
|
||||
>
|
||||
<span
|
||||
id="progress-indicator"
|
||||
class="absolute inset-0 -z-10 block size-14 scale-110 rounded-full bg-transparent md:hidden md:h-8 md:rounded-md"
|
||||
></span>
|
||||
<IconChevronLeft class="inline-block rotate-90 md:hidden" />
|
||||
<span class="sr-only text-sm group-hover:text-accent md:not-sr-only">
|
||||
<IconArrowNarrowUp class="inline-block size-4" />
|
||||
Back To Top
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
/** Scrolls the document to the top when
|
||||
* the "Back to Top" button is clicked. */
|
||||
function backToTop() {
|
||||
const rootElement = document.documentElement;
|
||||
const btnContainer = document.querySelector("#btt-btn-container");
|
||||
const backToTopBtn = document.querySelector("[data-button='back-to-top']");
|
||||
const progressIndicator = document.querySelector("#progress-indicator");
|
||||
|
||||
if (!rootElement || !btnContainer || !backToTopBtn || !progressIndicator)
|
||||
return;
|
||||
|
||||
// Attach click event handler for back-to-top button
|
||||
backToTopBtn.addEventListener("click", () => {
|
||||
document.body.scrollTop = 0; // For Safari
|
||||
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
|
||||
});
|
||||
|
||||
// Handle button visibility according to scroll position
|
||||
let lastVisible = null;
|
||||
function handleScroll() {
|
||||
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
|
||||
const scrollTop = rootElement.scrollTop;
|
||||
const scrollPercent = Math.floor((scrollTop / scrollTotal) * 100);
|
||||
|
||||
progressIndicator.style.setProperty(
|
||||
"background-image",
|
||||
`conic-gradient(var(--accent), var(--accent) ${scrollPercent}%, transparent ${scrollPercent}%)`
|
||||
);
|
||||
|
||||
const isVisible = scrollTop / scrollTotal > 0.3;
|
||||
|
||||
if (isVisible !== lastVisible) {
|
||||
btnContainer.classList.toggle("opacity-100", isVisible);
|
||||
btnContainer.classList.toggle("translate-y-0", isVisible);
|
||||
btnContainer.classList.toggle("opacity-0", !isVisible);
|
||||
btnContainer.classList.toggle("translate-y-14", !isVisible);
|
||||
lastVisible = isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
let ticking = false;
|
||||
document.addEventListener("scroll", () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
handleScroll();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
backToTop();
|
||||
</script>
|
||||
@@ -1,24 +1,58 @@
|
||||
---
|
||||
// Remove current url path and remove trailing slash if exists
|
||||
const currentUrlPath = Astro.url.pathname.replace(/\/+$/, "");
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import { stripBase, stripLocale } from "@/utils/withBase";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
|
||||
// Strip base prefix and trailing slash, then locale prefix so segments
|
||||
// reflect the logical page path only.
|
||||
const pathWithoutBase = stripBase(Astro.url.pathname).replace(/\/+$/, "");
|
||||
const currentUrlPath = stripLocale(pathWithoutBase, locale);
|
||||
|
||||
// Get url array from path
|
||||
// eg: /tags/tailwindcss => ['tags', 'tailwindcss']
|
||||
const breadcrumbList = currentUrlPath.split("/").slice(1);
|
||||
const breadcrumbList = currentUrlPath.split("/").slice(1).filter(Boolean);
|
||||
|
||||
// if breadcrumb is Home > Posts > 1 <etc>
|
||||
// replace Posts with Posts (page number)
|
||||
const decodeSegment = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const navLabels: Record<string, string> = {
|
||||
posts: t.nav.posts,
|
||||
tags: t.nav.tags,
|
||||
about: t.nav.about,
|
||||
archives: t.nav.archives,
|
||||
search: t.nav.search,
|
||||
};
|
||||
|
||||
// if breadcrumb is Home > Posts > [page] <etc>
|
||||
// replace Posts with localized "Posts (page number)"
|
||||
if (breadcrumbList[0] === "posts") {
|
||||
breadcrumbList.splice(0, 2, `Posts (page ${breadcrumbList[1] || 1})`);
|
||||
breadcrumbList.splice(
|
||||
0,
|
||||
2,
|
||||
`${t.nav.posts} (${t.pagination.page.toLowerCase()} ${breadcrumbList[1] || 1})`
|
||||
);
|
||||
}
|
||||
|
||||
// if breadcrumb is Home > Tags > [tag] > [page] <etc>
|
||||
// replace [tag] > [page] with [tag] (page number)
|
||||
// replace [tag] > [page] with localized [tag] (page number)
|
||||
if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
|
||||
breadcrumbList.splice(
|
||||
1,
|
||||
3,
|
||||
`${breadcrumbList[1]} ${Number(breadcrumbList[2]) === 1 ? "" : "(page " + breadcrumbList[2] + ")"}`
|
||||
`${decodeSegment(breadcrumbList[1])} ${
|
||||
Number(breadcrumbList[2]) === 1
|
||||
? ""
|
||||
: `(${t.pagination.page.toLowerCase()} ${breadcrumbList[2]})`
|
||||
}`
|
||||
);
|
||||
}
|
||||
---
|
||||
@@ -28,7 +62,9 @@ if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
|
||||
class="font-light [&>li]:inline [&>li:not(:last-child)>a]:hover:opacity-100"
|
||||
>
|
||||
<li>
|
||||
<a href="/" class="opacity-80">Home</a>
|
||||
<a href={getRelativeLocaleUrl(locale, "")} class="opacity-80">
|
||||
{t.nav.home}
|
||||
</a>
|
||||
<span aria-hidden="true" class="opacity-80">»</span>
|
||||
</li>
|
||||
{
|
||||
@@ -40,15 +76,20 @@ if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
|
||||
aria-current="page"
|
||||
>
|
||||
{/* make the last part lowercase in Home > Tags > some-tag */}
|
||||
{decodeURIComponent(breadcrumb)}
|
||||
{navLabels[breadcrumb] ?? decodeSegment(breadcrumb)}
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<a href={`/${breadcrumb}/`} class="capitalize opacity-70">
|
||||
{breadcrumb}
|
||||
<a
|
||||
href={getRelativeLocaleUrl(locale, breadcrumb)}
|
||||
class="capitalize opacity-70"
|
||||
>
|
||||
{navLabels[breadcrumb] ?? decodeSegment(breadcrumb)}
|
||||
</a>
|
||||
<span aria-hidden="true">»</span>
|
||||
<span aria-hidden="true" class="opacity-70">
|
||||
»
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getPostUrl } from "@/utils/getPostPaths";
|
||||
import { slugifyStr } from "@/utils/slugify";
|
||||
import { getPath } from "@/utils/getPath";
|
||||
import Datetime from "./Datetime.astro";
|
||||
|
||||
type Props = {
|
||||
variant?: "h2" | "h3";
|
||||
} & CollectionEntry<"blog">;
|
||||
} & CollectionEntry<"posts">;
|
||||
|
||||
const { variant: Heading = "h2", id, data, filePath } = Astro.props;
|
||||
|
||||
@@ -15,16 +15,14 @@ const { title, description, ...props } = data;
|
||||
|
||||
<li class="my-6">
|
||||
<a
|
||||
href={getPath(id, filePath)}
|
||||
href={getPostUrl(id, filePath, Astro.currentLocale)}
|
||||
class:list={[
|
||||
"inline-block text-lg font-medium text-accent",
|
||||
"text-accent inline-block text-lg font-medium",
|
||||
"decoration-dashed underline-offset-4 hover:underline",
|
||||
"focus-visible:no-underline focus-visible:underline-offset-0",
|
||||
]}
|
||||
>
|
||||
<Heading
|
||||
style={{ viewTransitionName: slugifyStr(title.replaceAll(".", "-")) }}
|
||||
>
|
||||
<Heading transition:name={slugifyStr(title.replaceAll(".", "-"))}>
|
||||
{title}
|
||||
</Heading>
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,8 @@ import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import IconCalendar from "@/assets/icons/IconCalendar.svg";
|
||||
import { SITE } from "@/config";
|
||||
import config from "@/config";
|
||||
import { useTranslations } from "@/i18n";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -24,17 +25,20 @@ const {
|
||||
timezone: postTimezone,
|
||||
} = Astro.props;
|
||||
|
||||
/* ========== Formatted Datetime ========== */
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
|
||||
const isModified = modDatetime && modDatetime > pubDatetime;
|
||||
|
||||
const datetime = dayjs(isModified ? modDatetime : pubDatetime).tz(
|
||||
postTimezone || SITE.timezone
|
||||
postTimezone ?? config.site.timezone
|
||||
);
|
||||
|
||||
const date = datetime.format("D MMM, YYYY"); // e.g., '22 Mar, 2025'
|
||||
const date = datetime.format("D MMM, YYYY");
|
||||
---
|
||||
|
||||
<div class:list={["flex items-center gap-x-2 opacity-80", className]}>
|
||||
<div
|
||||
class:list={["text-muted-foreground flex items-center gap-x-2", className]}
|
||||
>
|
||||
<IconCalendar
|
||||
class:list={[
|
||||
"inline-block size-6 min-w-5.5",
|
||||
@@ -44,12 +48,14 @@ const date = datetime.format("D MMM, YYYY"); // e.g., '22 Mar, 2025'
|
||||
{
|
||||
isModified && (
|
||||
<span class:list={["text-sm", { "sm:text-base": size === "lg" }]}>
|
||||
Updated:
|
||||
{t.post.updatedAt}:
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<time
|
||||
class:list={["text-sm", { "sm:text-base": size === "lg" }]}
|
||||
datetime={datetime.toISOString()}>{date}</time
|
||||
datetime={datetime.toISOString()}
|
||||
>
|
||||
{date}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import IconEdit from "@/assets/icons/IconEdit.svg";
|
||||
import { SITE } from "@/config";
|
||||
|
||||
type Props = {
|
||||
hideEditPost?: CollectionEntry<"blog">["data"]["hideEditPost"];
|
||||
class?: string;
|
||||
post: CollectionEntry<"blog">;
|
||||
};
|
||||
|
||||
const { hideEditPost, post, class: className = "" } = Astro.props;
|
||||
|
||||
const href = `${SITE.editPost.url}${post.filePath}`;
|
||||
const showEditPost =
|
||||
SITE.editPost.enabled && !hideEditPost && href.trim() !== "";
|
||||
---
|
||||
|
||||
{
|
||||
showEditPost && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class:list={[
|
||||
"flex justify-baseline gap-1.5 opacity-80 hover:text-accent",
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<IconEdit class="inline-block" />
|
||||
<span>{SITE.editPost.text}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import Socials from "./Socials.astro";
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
import { useTranslations } from "@/i18n";
|
||||
|
||||
type Props = {
|
||||
noMarginTop?: boolean;
|
||||
} & HTMLAttributes<"footer">;
|
||||
|
||||
const { noMarginTop = false, class: className, ...attrs } = Astro.props;
|
||||
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer
|
||||
@@ -16,17 +18,13 @@ const { noMarginTop = false, class: className, ...attrs } = Astro.props;
|
||||
{...attrs}
|
||||
>
|
||||
<div
|
||||
class:list={[
|
||||
"py-6 sm:py-4",
|
||||
"border-t border-border",
|
||||
"flex flex-col items-center justify-between sm:flex-row-reverse",
|
||||
]}
|
||||
class="border-border flex flex-col items-center justify-between border-t py-6 sm:flex-row-reverse sm:py-4"
|
||||
>
|
||||
<Socials />
|
||||
<div class="my-2 flex flex-col items-center whitespace-nowrap sm:flex-row">
|
||||
<span>Copyright © {currentYear}</span>
|
||||
<span>{t.footer.copyright} © {currentYear}</span>
|
||||
<span class="hidden sm:inline"> | </span>
|
||||
<span>All rights reserved.</span>
|
||||
<span>{t.footer.allRightsReserved}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
+92
-66
@@ -1,23 +1,35 @@
|
||||
---
|
||||
import IconX from "@/assets/icons/IconX.svg";
|
||||
import IconMoon from "@/assets/icons/IconMoon.svg";
|
||||
import IconSearch from "@/assets/icons/IconSearch.svg";
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import IconUnderline from "@/assets/icons/IconUnderline.svg";
|
||||
import IconMenuDeep from "@/assets/icons/IconMenuDeep.svg";
|
||||
import IconArchive from "@/assets/icons/IconArchive.svg";
|
||||
import IconSunHigh from "@/assets/icons/IconSunHigh.svg";
|
||||
import IconMenuDeep from "@/assets/icons/IconMenuDeep.svg";
|
||||
import IconSearch from "@/assets/icons/IconSearch.svg";
|
||||
import IconMoon from "@/assets/icons/IconMoon.svg";
|
||||
import IconX from "@/assets/icons/IconX.svg";
|
||||
import LinkButton from "./LinkButton.astro";
|
||||
import { SITE } from "@/config";
|
||||
import { stripBase, stripLocale } from "@/utils/withBase";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const { pathname } = Astro.url;
|
||||
const { site, features } = config;
|
||||
|
||||
// Remove trailing slash from current pathname if exists
|
||||
const currentPath =
|
||||
pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
|
||||
// Strip the base prefix so isActive comparisons work regardless of deploy subdirectory.
|
||||
// e.g. with base "/blog": "/blog/posts/slug" → "/posts/slug"
|
||||
const relativePath = stripBase(Astro.url.pathname);
|
||||
const pathWithoutTrailingSlash =
|
||||
relativePath.endsWith("/") && relativePath !== "/"
|
||||
? relativePath.slice(0, -1)
|
||||
: relativePath;
|
||||
|
||||
const currentPath = stripLocale(pathWithoutTrailingSlash, locale);
|
||||
|
||||
const isActive = (path: string) => {
|
||||
const currentPathArray = currentPath.split("/").filter(p => p.trim());
|
||||
const pathArray = path.split("/").filter(p => p.trim());
|
||||
|
||||
return currentPath === path || currentPathArray[0] === pathArray[0];
|
||||
};
|
||||
---
|
||||
@@ -25,28 +37,22 @@ const isActive = (path: string) => {
|
||||
<a
|
||||
id="skip-to-content"
|
||||
href="#main-content"
|
||||
class="absolute start-16 -top-full z-50 bg-background px-3 py-2 text-accent backdrop-blur-lg transition-all focus:top-4"
|
||||
class="bg-background text-accent absolute inset-s-16 -top-full z-50 px-3 py-2 backdrop-blur-lg transition-all focus:top-4"
|
||||
>
|
||||
Skip to content
|
||||
{t.a11y.skipToContent}
|
||||
</a>
|
||||
|
||||
<header
|
||||
class="app-layout flex flex-col items-center justify-between sm:flex-row"
|
||||
>
|
||||
<div
|
||||
id="top-nav-wrap"
|
||||
class:list={[
|
||||
"py-4 sm:py-6",
|
||||
"border-b border-border",
|
||||
"relative w-full bg-background",
|
||||
"flex items-baseline justify-between sm:items-center",
|
||||
]}
|
||||
class="border-border bg-background relative flex w-full items-baseline justify-between border-b py-4 sm:items-center sm:py-6"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
href={getRelativeLocaleUrl(locale, "")}
|
||||
class="absolute py-1 text-xl leading-8 font-semibold whitespace-nowrap sm:static sm:my-auto sm:text-2xl sm:leading-none"
|
||||
>
|
||||
{SITE.title}
|
||||
{site.title}
|
||||
</a>
|
||||
<nav
|
||||
id="nav-menu"
|
||||
@@ -55,83 +61,100 @@ const isActive = (path: string) => {
|
||||
<button
|
||||
id="menu-btn"
|
||||
class="focus-outline self-end p-2 sm:hidden"
|
||||
aria-label="Open Menu"
|
||||
aria-label={t.a11y.openMenu}
|
||||
aria-expanded="false"
|
||||
aria-controls="menu-items"
|
||||
data-label-open={t.a11y.openMenu}
|
||||
data-label-close={t.a11y.closeMenu}
|
||||
>
|
||||
<IconX id="close-icon" class="hidden" />
|
||||
<IconMenuDeep id="menu-icon" />
|
||||
</button>
|
||||
<ul
|
||||
id="menu-items"
|
||||
class:list={[
|
||||
"mt-4 grid w-44 grid-cols-2 place-content-center gap-2",
|
||||
"[&>li>a]:block [&>li>a]:px-4 [&>li>a]:py-3 [&>li>a]:text-center [&>li>a]:font-medium [&>li>a]:hover:text-accent sm:[&>li>a]:px-2 sm:[&>li>a]:py-1",
|
||||
"hidden",
|
||||
"sm:mt-0 sm:flex sm:w-auto sm:gap-x-5 sm:gap-y-0",
|
||||
]}
|
||||
class="[&>li>a]:hover:text-accent mt-4 hidden w-44 grid-cols-2 place-content-center gap-2 sm:mt-0 sm:flex sm:w-auto sm:gap-x-5 sm:gap-y-0 sm:[&>li]:h-8 [&>li>a]:block [&>li>a]:px-4 [&>li>a]:py-3 [&>li>a]:text-center [&>li>a]:font-medium sm:[&>li>a]:px-2 sm:[&>li>a]:py-1"
|
||||
>
|
||||
<li class="col-span-2">
|
||||
<a href="/posts" class:list={{ "active-nav": isActive("/posts") }}>
|
||||
Posts
|
||||
<a
|
||||
href={getRelativeLocaleUrl(locale, "posts")}
|
||||
class:list={{ "active-nav": isActive("/posts") }}
|
||||
>
|
||||
{t.nav.posts}
|
||||
</a>
|
||||
</li>
|
||||
<li class="col-span-2">
|
||||
<a href="/tags" class:list={{ "active-nav": isActive("/tags") }}>
|
||||
Tags
|
||||
<a
|
||||
href={getRelativeLocaleUrl(locale, "tags")}
|
||||
class:list={{ "active-nav": isActive("/tags") }}
|
||||
>
|
||||
{t.nav.tags}
|
||||
</a>
|
||||
</li>
|
||||
<li class="col-span-2">
|
||||
<a href="/about" class:list={{ "active-nav": isActive("/about") }}>
|
||||
About
|
||||
<a
|
||||
href={getRelativeLocaleUrl(locale, "about")}
|
||||
class:list={{ "active-nav": isActive("/about") }}
|
||||
>
|
||||
{t.nav.about}
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
SITE.showArchives && (
|
||||
features.showArchives && (
|
||||
<li class="col-span-2">
|
||||
<LinkButton
|
||||
href="/archives"
|
||||
href={getRelativeLocaleUrl(locale, "archives")}
|
||||
class:list={[
|
||||
"focus-outline flex justify-center p-3 sm:p-1",
|
||||
{
|
||||
"active-nav [&>svg]:stroke-accent": isActive("/archives"),
|
||||
},
|
||||
"focus-outline flex size-full justify-center p-3 sm:relative sm:size-8 sm:p-0",
|
||||
{ "active-nav": isActive("/archives") },
|
||||
]}
|
||||
title="Archives"
|
||||
aria-label="archives"
|
||||
title={t.nav.archives}
|
||||
aria-label={t.nav.archives}
|
||||
>
|
||||
<IconArchive class="hidden sm:inline-block" />
|
||||
<span class="sm:sr-only">Archives</span>
|
||||
<IconArchive class="hidden sm:absolute sm:top-1/2 sm:left-1/2 sm:block sm:size-6 sm:-translate-x-1/2 sm:-translate-y-1/2" />
|
||||
<span class="sm:sr-only">{t.nav.archives}</span>
|
||||
{isActive("/archives") && (
|
||||
<IconUnderline
|
||||
aria-hidden="true"
|
||||
class="scale-125 max-sm:hidden sm:absolute sm:bottom-0 sm:w-6"
|
||||
/>
|
||||
)}
|
||||
</LinkButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
<li class="col-span-1 flex items-center justify-center">
|
||||
<LinkButton
|
||||
href="/search"
|
||||
class:list={[
|
||||
"focus-outline flex p-3 sm:p-1",
|
||||
{ "[&>svg]:stroke-accent": isActive("/search") },
|
||||
]}
|
||||
title="Search"
|
||||
aria-label="search"
|
||||
>
|
||||
<IconSearch />
|
||||
<span class="sr-only">Search</span>
|
||||
</LinkButton>
|
||||
</li>
|
||||
{
|
||||
SITE.lightAndDarkMode && (
|
||||
features.search !== false && (
|
||||
<li class="col-span-1 flex items-center justify-center">
|
||||
<LinkButton
|
||||
href={getRelativeLocaleUrl(locale, "search")}
|
||||
class:list={["focus-outline relative size-8"]}
|
||||
title={t.nav.search}
|
||||
aria-label={t.nav.search}
|
||||
>
|
||||
<IconSearch class="absolute top-1/2 left-1/2 size-6 -translate-x-1/2 -translate-y-1/2" />
|
||||
<span class="sr-only">{t.nav.search}</span>
|
||||
{isActive("/search") && (
|
||||
<IconUnderline
|
||||
aria-hidden="true"
|
||||
class="absolute bottom-0 w-6 scale-125 max-sm:inset-s-2"
|
||||
/>
|
||||
)}
|
||||
</LinkButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
features.lightAndDarkMode && (
|
||||
<li class="col-span-1 flex items-center justify-center">
|
||||
<button
|
||||
id="theme-btn"
|
||||
class="focus-outline relative size-12 p-4 sm:size-8 hover:[&>svg]:stroke-accent"
|
||||
title="Toggles light & dark"
|
||||
class="focus-outline hover:[&>svg]:stroke-accent relative size-12 p-4 sm:size-8"
|
||||
title={t.a11y.toggleTheme}
|
||||
aria-label="auto"
|
||||
aria-live="polite"
|
||||
>
|
||||
<IconMoon class="absolute top-[50%] left-[50%] -translate-[50%] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<IconSunHigh class="absolute top-[50%] left-[50%] -translate-[50%] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<IconMoon class="absolute top-[50%] left-[50%] translate-[-50%] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<IconSunHigh class="absolute top-[50%] left-[50%] translate-[-50%] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
@@ -143,20 +166,24 @@ const isActive = (path: string) => {
|
||||
|
||||
<script>
|
||||
function toggleNav() {
|
||||
const menuBtn = document.querySelector("#menu-btn");
|
||||
const menuBtn = document.querySelector<HTMLButtonElement>("#menu-btn");
|
||||
const menuItems = document.querySelector("#menu-items");
|
||||
const menuIcon = document.querySelector("#menu-icon");
|
||||
const closeIcon = document.querySelector("#close-icon");
|
||||
|
||||
if (!menuBtn || !menuItems || !menuIcon || !closeIcon) return;
|
||||
|
||||
const openLabel = menuBtn.dataset.labelOpen ?? "Open menu";
|
||||
const closeLabel = menuBtn.dataset.labelClose ?? "Close menu";
|
||||
|
||||
menuBtn.addEventListener("click", () => {
|
||||
const openMenu = menuBtn.getAttribute("aria-expanded") === "true";
|
||||
|
||||
menuBtn.setAttribute("aria-expanded", openMenu ? "false" : "true");
|
||||
menuBtn.setAttribute("aria-label", openMenu ? "Open Menu" : "Close Menu");
|
||||
menuBtn.setAttribute("aria-label", openMenu ? openLabel : closeLabel);
|
||||
|
||||
menuItems.classList.toggle("hidden");
|
||||
menuItems.classList.toggle("grid");
|
||||
menuIcon.classList.toggle("hidden");
|
||||
closeIcon.classList.toggle("hidden");
|
||||
});
|
||||
@@ -164,6 +191,5 @@ const isActive = (path: string) => {
|
||||
|
||||
toggleNav();
|
||||
|
||||
// Runs on view transitions navigation
|
||||
document.addEventListener("astro:after-swap", toggleNav);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import config from "@/config";
|
||||
|
||||
type Props = {
|
||||
pageTitle: string | [string, string];
|
||||
pageDesc?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { pageTitle, pageDesc, class: className } = Astro.props;
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const backUrl = config.features.showBackButton
|
||||
? Astro.url.pathname
|
||||
: getRelativeLocaleUrl(locale, "");
|
||||
---
|
||||
|
||||
<main
|
||||
data-backUrl={backUrl}
|
||||
id="main-content"
|
||||
class:list={["app-layout pb-4", className]}
|
||||
>
|
||||
<h1 class="text-2xl font-semibold sm:text-3xl">{pageTitle}</h1>
|
||||
<p class="mt-2 mb-6 italic">{pageDesc}</p>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
const mainContent: HTMLElement | null =
|
||||
document.querySelector("#main-content");
|
||||
const backUrl = mainContent?.dataset?.backurl;
|
||||
if (backUrl) {
|
||||
sessionStorage.setItem("backUrl", backUrl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -4,38 +4,41 @@ import type { CollectionEntry } from "astro:content";
|
||||
import IconArrowLeft from "@/assets/icons/IconArrowLeft.svg";
|
||||
import IconArrowRight from "@/assets/icons/IconArrowRight.svg";
|
||||
import LinkButton from "./LinkButton.astro";
|
||||
import { useTranslations } from "@/i18n";
|
||||
|
||||
type Props = {
|
||||
page: Page<CollectionEntry<"blog">>;
|
||||
page: Page<CollectionEntry<"posts">>;
|
||||
};
|
||||
|
||||
const { page } = Astro.props;
|
||||
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
---
|
||||
|
||||
{
|
||||
page.lastPage > 1 && (
|
||||
<nav
|
||||
class="mt-auto mb-8 flex justify-center"
|
||||
class="mt-auto mb-8 flex justify-center gap-4"
|
||||
role="navigation"
|
||||
aria-label="Pagination Navigation"
|
||||
>
|
||||
<LinkButton
|
||||
disabled={!page.url.prev}
|
||||
href={page.url.prev as string}
|
||||
class:list={["me-4 select-none", { "opacity-50": !page.url.prev }]}
|
||||
aria-label="Goto Previous Page"
|
||||
class:list={["select-none", { "opacity-50": !page.url.prev }]}
|
||||
aria-label={t.a11y.goToPreviousPage}
|
||||
>
|
||||
<IconArrowLeft class="inline-block rtl:rotate-180" />
|
||||
Prev
|
||||
{t.pagination.prev}
|
||||
</LinkButton>
|
||||
{page.currentPage} / {page.lastPage}
|
||||
<LinkButton
|
||||
disabled={!page.url.next}
|
||||
href={page.url.next as string}
|
||||
class:list={["ms-4 select-none", { "opacity-50": !page.url.next }]}
|
||||
aria-label="Goto Next Page"
|
||||
class:list={["select-none", { "opacity-50": !page.url.next }]}
|
||||
aria-label={t.a11y.goToNextPage}
|
||||
>
|
||||
Next
|
||||
{t.pagination.next}
|
||||
<IconArrowRight class="inline-block rtl:rotate-180" />
|
||||
</LinkButton>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type Props = {
|
||||
class?: string;
|
||||
variant?: /** No borders */
|
||||
| "minimal"
|
||||
/** 1st, 3rd, 5th … body rows tinted — classic zebra. */
|
||||
| "striped"
|
||||
/** 1st, 3rd, 5th … body rows tinted + no borders. */
|
||||
| "striped-minimal";
|
||||
};
|
||||
|
||||
const { class: className, variant } = Astro.props;
|
||||
|
||||
const variantClasses = {
|
||||
minimal: "[&_td]:border-0 [&_th]:border-0",
|
||||
striped: "[&_tbody_tr]:odd:bg-muted/25",
|
||||
};
|
||||
---
|
||||
|
||||
<div
|
||||
data-table-variant={variant}
|
||||
class:list={[
|
||||
"overflow-hidden [&_table]:my-0 [&_table]:min-w-xl",
|
||||
variant === "minimal" && variantClasses.minimal,
|
||||
variant === "striped" && variantClasses.striped,
|
||||
variant === "striped-minimal" &&
|
||||
`${variantClasses.minimal} ${variantClasses.striped}`,
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<div class="relative w-full overflow-x-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
import { SHARE_LINKS } from "@/constants";
|
||||
import LinkButton from "./LinkButton.astro";
|
||||
|
||||
const URL = Astro.url;
|
||||
---
|
||||
|
||||
{
|
||||
SHARE_LINKS.length > 0 && (
|
||||
<div class="flex flex-none flex-col items-center justify-center gap-1 md:items-start">
|
||||
<span class="italic">Share this post on:</span>
|
||||
<div class="text-center">
|
||||
{SHARE_LINKS.map(social => (
|
||||
<LinkButton
|
||||
href={`${social.href + URL}`}
|
||||
class="scale-90 p-2 hover:rotate-6 sm:p-1"
|
||||
title={social.linkTitle}
|
||||
>
|
||||
<social.icon class="inline-block size-6 scale-125 fill-transparent stroke-current stroke-2 opacity-90 group-hover:fill-transparent sm:scale-110" />
|
||||
<span class="sr-only">{social.linkTitle}</span>
|
||||
</LinkButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
---
|
||||
import { SOCIALS } from "@/constants";
|
||||
import config from "@/config";
|
||||
import LinkButton from "./LinkButton.astro";
|
||||
|
||||
const { site, socials } = config;
|
||||
|
||||
const icons = import.meta.glob<{
|
||||
default: (_props: Record<string, unknown>) => unknown;
|
||||
}>("/src/assets/icons/socials/*.svg");
|
||||
|
||||
const items = await Promise.all(
|
||||
socials.map(async ({ name, url, linkTitle }) => {
|
||||
const mod = await icons[`/src/assets/icons/socials/${name}.svg`]?.();
|
||||
const Icon = mod?.default;
|
||||
const title =
|
||||
linkTitle ??
|
||||
(url.startsWith("mailto:") || name === "mail"
|
||||
? `Send an email to ${site.title}`
|
||||
: `${site.title} on ${name.charAt(0).toUpperCase() + name.slice(1)}`);
|
||||
return { url, title, Icon };
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{
|
||||
SOCIALS.map(social => (
|
||||
<LinkButton
|
||||
href={social.href}
|
||||
class="p-2 hover:rotate-6 sm:p-1"
|
||||
title={social.linkTitle}
|
||||
>
|
||||
<social.icon class="inline-block size-6 scale-125 fill-transparent stroke-current stroke-2 opacity-90 group-hover:fill-transparent sm:scale-110" />
|
||||
<span class="sr-only">{social.linkTitle}</span>
|
||||
</LinkButton>
|
||||
))
|
||||
items.map(({ url, title, Icon }) =>
|
||||
Icon ? (
|
||||
<LinkButton href={url} class="p-2 hover:rotate-6 sm:p-1" title={title}>
|
||||
<Icon class="inline-block size-6 scale-125 fill-transparent stroke-current stroke-2 opacity-90 group-hover:fill-transparent sm:scale-110" />
|
||||
<span class="sr-only">{title}</span>
|
||||
</LinkButton>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import IconHash from "@/assets/icons/IconHash.svg";
|
||||
import config from "@/config";
|
||||
|
||||
type Props = {
|
||||
tag: string;
|
||||
@@ -8,17 +10,19 @@ type Props = {
|
||||
};
|
||||
|
||||
const { tag, tagName, size = "lg" } = Astro.props;
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
---
|
||||
|
||||
<li>
|
||||
<a
|
||||
href={`/tags/${tag}/`}
|
||||
href={getRelativeLocaleUrl(locale, `tags/${tag}/`)}
|
||||
transition:name={tag}
|
||||
class:list={[
|
||||
"flex items-center gap-0.5",
|
||||
"border-b-2 border-dashed border-foreground",
|
||||
"hover:-mt-0.5 hover:border-accent hover:text-accent",
|
||||
"focus-visible:border-none focus-visible:text-accent",
|
||||
"border-foreground border-b-2 border-dashed",
|
||||
"hover:border-accent hover:text-accent hover:-mt-0.5",
|
||||
"focus-visible:text-accent focus-visible:border-none",
|
||||
{ "text-sm": size === "sm" },
|
||||
{ "text-lg": size === "lg" },
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user