feat(all): first init

This commit is contained in:
z0x 2025-01-08 22:06:02 -05:00
commit 67c6e47f58
34 changed files with 2155 additions and 0 deletions

View file

@ -0,0 +1,26 @@
---
import type { CollectionEntry } from "astro:content";
type Props = {
entry: CollectionEntry<"blog">;
};
const { entry } = Astro.props as {
entry: CollectionEntry<"blog">;
};
---
<a href={`/${entry.id}`} class="not-prose group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pr-10 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<div class="flex flex-1 flex-col truncate">
<div class="font-semibold">
{entry.data.title}
</div>
<div class="text-sm">
{entry.data.description}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"></line>
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"></polyline>
</svg>
</a>

View file

@ -0,0 +1,27 @@
---
---
<button
id="back-to-top"
class="group relative flex w-fit flex-nowrap rounded border border-black/15 py-1.5 pl-8 pr-3 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="absolute left-2 top-1/2 size-4 -translate-y-1/2 rotate-90 fill-none stroke-current stroke-2"
>
<line
x1="5"
y1="12"
x2="19"
y2="12"
class="translate-x-2 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"
></line>
<polyline
points="12 5 5 12 12 19"
class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"
></polyline>
</svg>
<div class="text-sm">Back to top</div>
</button>

View file

@ -0,0 +1,44 @@
---
interface Props {
type: "default" | "info" | "warning" | "error";
}
const { type = "default" } = Astro.props;
let emoji = "💡";
if (type === "info") {
emoji = "";
} else if (type === "warning") {
emoji = "⚠️";
} else if (type === "error") {
emoji = "🚨";
}
---
<div class={`not-prose callout callout-${type}`}>
<span class="emoji pointer-events-none select-none">{emoji}</span>
<slot />
</div>
<style>
.callout {
@apply relative my-4 flex rounded border border-orange-800 bg-orange-100 p-3 text-orange-950 dark:border-orange-200/20 dark:bg-orange-950/20 dark:text-orange-200;
}
.emoji {
@apply pr-3 text-xl;
}
.callout-info {
@apply border-blue-800 bg-blue-100 text-blue-950 dark:border-blue-200/20 dark:bg-blue-950/20 dark:text-blue-200;
}
.callout-warning {
@apply border-yellow-800 bg-yellow-100 text-yellow-950 dark:border-yellow-200/20 dark:bg-yellow-950/20 dark:text-yellow-200;
}
.callout-error {
@apply border-red-800 bg-red-100 text-red-950 dark:border-red-200/20 dark:bg-red-950/20 dark:text-red-200;
}
</style>

View file

@ -0,0 +1,5 @@
---
---
<div class="mx-auto max-w-screen-sm px-3"><slot /></div>

View file

@ -0,0 +1,52 @@
---
import Container from "@components/Container.astro";
import BackToTop from "@components/BackToTop.astro";
import { SITE } from "@consts";
---
<footer class="animate">
<Container>
<div class="relative">
<div class="absolute -top-12 right-0">
<BackToTop />
</div>
</div>
<div class="flex items-center justify-between">
<div>&copy; {new Date().getFullYear()} • {SITE.TITLE}</div>
<div class="flex flex-wrap items-center gap-1.5">
<a aria-label="RSS" href="/rss.xml" target="_blank" class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white">
<path d="M5 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<path d="M4 11a9 9 0 0 1 9 9"></path>
</svg>
</a>
<button id="light-theme-button" aria-label="Light theme" class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button id="dark-theme-button" aria-label="Dark theme" class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button id="system-theme-button" aria-label="System theme" class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
</div>
</div>
</Container>
</footer>

View file

@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString("en-SE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
</time>

251
src/components/Head.astro Normal file
View file

@ -0,0 +1,251 @@
---
import "../styles/global.css";
import "../styles/callout.css";
import { ClientRouter } from "astro:transitions";
import "@fontsource/geist-sans/100.css";
import "@fontsource/geist-sans/200.css";
import "@fontsource/geist-sans/300.css";
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-sans/800.css";
import "@fontsource/geist-sans/900.css";
import "@fontsource/geist-mono/100.css";
import "@fontsource/geist-mono/200.css";
import "@fontsource/geist-mono/300.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/500.css";
import "@fontsource/geist-mono/600.css";
import "@fontsource/geist-mono/700.css";
import "@fontsource/geist-mono/800.css";
import "@fontsource/geist-mono/900.css";
interface Props {
title: string;
description: string;
image?: string;
}
const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22></text></svg>" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />
<ClientRouter />
<script is:inline>
function init() {
preloadTheme();
onScroll();
animate();
updateThemeButtons();
addCopyCodeButtons();
setGiscusTheme();
const backToTop = document.getElementById("back-to-top");
backToTop?.addEventListener("click", (event) => scrollToTop(event));
const backToPrev = document.getElementById("back-to-prev");
backToPrev?.addEventListener("click", () => window.history.back());
const lightThemeButton = document.getElementById("light-theme-button");
lightThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "light");
toggleTheme(false);
updateThemeButtons();
});
const darkThemeButton = document.getElementById("dark-theme-button");
darkThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "dark");
toggleTheme(true);
updateThemeButtons();
});
const systemThemeButton = document.getElementById("system-theme-button");
systemThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "system");
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
updateThemeButtons();
});
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
if (localStorage.theme === "system") {
toggleTheme(event.matches);
}
});
document.addEventListener("scroll", onScroll);
}
function updateThemeButtons() {
const theme = localStorage.getItem("theme");
const lightThemeButton = document.getElementById("light-theme-button");
const darkThemeButton = document.getElementById("dark-theme-button");
const systemThemeButton = document.getElementById("system-theme-button");
function removeActiveButtonTheme(button) {
button?.classList.remove("bg-black/5");
button?.classList.remove("dark:bg-white/5");
}
function addActiveButtonTheme(button) {
button?.classList.add("bg-black/5");
button?.classList.add("dark:bg-white/5");
}
removeActiveButtonTheme(lightThemeButton);
removeActiveButtonTheme(darkThemeButton);
removeActiveButtonTheme(systemThemeButton);
if (theme === "light") {
addActiveButtonTheme(lightThemeButton);
} else if (theme === "dark") {
addActiveButtonTheme(darkThemeButton);
} else {
addActiveButtonTheme(systemThemeButton);
}
}
function animate() {
const animateElements = document.querySelectorAll(".animate");
animateElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add("show");
}, index * 100);
});
}
function onScroll() {
if (window.scrollY > 0) {
document.documentElement.classList.add("scrolled");
} else {
document.documentElement.classList.remove("scrolled");
}
}
function scrollToTop(event) {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
function toggleTheme(dark) {
const css = document.createElement("style");
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
);
document.head.appendChild(css);
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
window.getComputedStyle(css).opacity;
document.head.removeChild(css);
setGiscusTheme();
}
function preloadTheme() {
const userTheme = localStorage.theme;
if (userTheme === "light" || userTheme === "dark") {
toggleTheme(userTheme === "dark");
} else {
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
}
}
function addCopyCodeButtons() {
let copyButtonLabel = "📋";
let codeBlocks = Array.from(document.querySelectorAll("pre"));
async function copyCode(codeBlock, copyButton) {
const codeText = codeBlock.innerText;
const buttonText = copyButton.innerText;
const textToCopy = codeText.replace(buttonText, "");
await navigator.clipboard.writeText(textToCopy);
copyButton.innerText = "✅";
setTimeout(() => {
copyButton.innerText = copyButtonLabel;
}, 2000);
}
for (let codeBlock of codeBlocks) {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
const copyButton = document.createElement("button");
copyButton.innerText = copyButtonLabel;
copyButton.classList = "copy-code";
codeBlock.setAttribute("tabindex", "0");
codeBlock.appendChild(copyButton);
codeBlock.parentNode.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
copyButton?.addEventListener("click", async () => {
await copyCode(codeBlock, copyButton);
});
}
}
const setGiscusTheme = () => {
const giscus = document.querySelector(".giscus-frame");
const isDark = document.documentElement.classList.contains("dark");
if (giscus) {
const url = new URL(giscus.src);
url.searchParams.set("theme", isDark ? "dark" : "light");
giscus.src = url.toString();
}
};
document.addEventListener("DOMContentLoaded", () => init());
document.addEventListener("astro:after-swap", () => init());
preloadTheme();
</script>

31
src/components/Link.astro Normal file
View file

@ -0,0 +1,31 @@
---
import { cn } from "@lib/utils";
type Props = {
href: string;
external?: boolean;
underline?: boolean;
group?: boolean;
};
const {
href,
external,
underline = true,
group = false,
...rest
} = Astro.props;
---
<a
href={href}
target={external ? "_blank" : "_self"}
class={cn(
"inline-block decoration-black/30 dark:decoration-white/30 hover:decoration-black/50 focus-visible:decoration-black/50 dark:hover:decoration-white/50 dark:focus-visible:decoration-white/50 text-current hover:text-black focus-visible:text-black dark:hover:text-white dark:focus-visible:text-white transition-colors duration-300 ease-in-out",
underline && "underline underline-offset-[3px]",
group && "group"
)}
{...rest}
>
<slot />
</a>

View file

@ -0,0 +1,33 @@
---
const { prevPost, nextPost } = Astro.props;
---
<div class="grid grid-cols-2 gap-1.5 sm:gap-3">
{
prevPost?.id ? (
<a href={`/${prevPost?.id}`} class="group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pl-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute left-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" />
<polyline points="12 5 5 12 12 19" class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" />
</svg>
<div class="flex items-center text-sm">{prevPost?.data.title}</div>
</a>
) : (
<div class="invisible" />
)
}
{
nextPost?.id ? (
<a href={`/${nextPost?.id}`} class="group relative flex flex-grow flex-row-reverse flex-nowrap rounded-lg border border-black/15 px-4 py-4 pr-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" />
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" />
</svg>
<div class="flex items-center text-sm">{nextPost?.data.title}</div>
</a>
) : (
<div class="invisible" />
)
}
</div>

View file

@ -0,0 +1,50 @@
---
import TableOfContentsHeading from "./TableOfContentsHeading.astro";
const { headings } = Astro.props;
const toc = buildToc(headings);
export interface Heading {
depth: number;
id: string;
text: string;
}
function buildToc(headings: Heading[]) {
const toc: Heading[] = [];
const parentHeadings = new Map();
headings.forEach((h) => {
const heading = { ...h, subheadings: [] };
parentHeadings.set(heading.depth, heading);
if (heading.depth === 2) {
toc.push(heading);
} else {
parentHeadings.get(heading.depth - 1).subheadings.push(heading);
}
});
return toc;
}
---
<details open class="animate rounded-lg border border-black/15 dark:border-white/20">
<summary>Table of Contents</summary>
<nav class="">
<ul class="py-3">
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</nav>
</details>
<style>
summary {
@apply cursor-pointer rounded-t-lg px-3 py-1.5 font-medium transition-colors;
}
summary:hover {
@apply bg-black/5 dark:bg-white/5;
}
details[open] summary {
@apply bg-black/5 dark:bg-white/5;
}
</style>

View file

@ -0,0 +1,21 @@
---
import type { Heading } from "./TableOfContents.astro";
import Link from "./Link.astro";
const { heading } = Astro.props;
---
<li class="list-inside list-disc px-6 py-1.5 text-sm">
<Link href={"#" + heading.id} underline>
{heading.text}
</Link>
{
heading.subheadings.length > 0 && (
<ul class="translate-x-3">
{heading.subheadings.map((subheading: Heading) => (
<Astro.self heading={subheading} />
))}
</ul>
)
}
</li>