This commit is contained in:
Michael
2024-10-16 00:10:58 +13:00
commit a44225879b
59 changed files with 19369 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
---
import type { CollectionEntry } from "astro:content";
type Props = {
entry: CollectionEntry<"blog"> | CollectionEntry<"projects">;
}
const { entry } = Astro.props;
---
<a href={`/${entry.collection}/${entry.slug}`} class="relative group flex flex-nowrap py-3 px-4 pr-10 rounded-lg border border-black/15 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 hover:text-black dark:hover:text-white transition-colors duration-300 ease-in-out">
<div class="flex flex-col flex-1 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 top-1/2 right-2 -translate-y-1/2 size-5 stroke-2 fill-none stroke-current">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 group-hover:translate-x-0 scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out" />
<polyline points="12 5 19 12 12 19" class="-translate-x-1 group-hover:translate-x-0 transition-transform duration-300 ease-in-out" />
</svg>
</a>
+20
View File
@@ -0,0 +1,20 @@
---
type Props = {
href: string;
}
const { href } = Astro.props;
---
<a href={href} class="relative group w-fit flex pl-7 pr-3 py-1.5 flex-nowrap rounded border border-black/15 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 hover:text-black dark:hover:text-white transition-colors duration-300 ease-in-out">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="absolute top-1/2 left-2 -translate-y-1/2 size-4 stroke-2 fill-none stroke-current">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-2 group-hover:translate-x-0 scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out" />
<polyline points="12 5 5 12 12 19" class="translate-x-1 group-hover:translate-x-0 transition-transform duration-300 ease-in-out" />
</svg>
<div class="text-sm">
<slot/>
</div>
</a>
+12
View File
@@ -0,0 +1,12 @@
<button id="back-to-top" class="relative group w-fit flex pl-8 pr-3 py-1.5 flex-nowrap rounded border border-black/15 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 hover:text-black dark:hover:text-white transition-colors duration-300 ease-in-out">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="absolute top-1/2 left-2 -translate-y-1/2 size-4 stroke-2 fill-none stroke-current rotate-90">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-2 group-hover:translate-x-0 scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out" />
<polyline points="12 5 5 12 12 19" class="translate-x-1 group-hover:translate-x-0 transition-transform duration-300 ease-in-out" />
</svg>
<div class="text-sm">
Back to top
</div>
</button>
+7
View File
@@ -0,0 +1,7 @@
---
---
<div class="mx-auto max-w-screen-sm px-5">
<slot />
</div>
+92
View File
@@ -0,0 +1,92 @@
---
import Container from "@components/Container.astro";
import { SITE } from "@consts";
import BackToTop from "@components/BackToTop.astro";
---
<footer class="animate">
<Container>
<div class="relative">
<div class="absolute right-0 -top-20">
<BackToTop />
</div>
</div>
<div class="flex justify-between items-center">
<div>
&copy; 2024 {`|`} {SITE.NAME}
</div>
<div class="flex flex-wrap gap-1 items-center">
<button
id="light-theme-button"
aria-label="Light theme"
class="group size-8 flex items-center justify-center rounded-full"
>
<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="group-hover:stroke-black group-hover:dark:stroke-white transition-colors duration-300 ease-in-out"
>
<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 size-8 flex items-center justify-center rounded-full"
>
<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="group-hover:stroke-black group-hover:dark:stroke-white transition-colors duration-300 ease-in-out"
>
<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 size-8 flex items-center justify-center rounded-full"
>
<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="group-hover:stroke-black group-hover:dark:stroke-white transition-colors duration-300 ease-in-out"
>
<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>
+17
View File
@@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString("en-us", {
month: "short",
day: "numeric",
year: "numeric",
})
}
</time>
+181
View File
@@ -0,0 +1,181 @@
---
import "../styles/global.css";
import "@fontsource/inter/latin-400.css";
import "@fontsource/inter/latin-600.css";
import "@fontsource/lora/400.css";
import "@fontsource/lora/600.css";
import inter400 from "@fontsource/inter/files/inter-latin-400-normal.woff2";
import inter600 from "@fontsource/inter/files/inter-latin-600-normal.woff2";
import lora400 from "@fontsource/lora/files/lora-latin-400-normal.woff2";
import lora600 from "@fontsource/lora/files/lora-latin-600-normal.woff2";
import { ViewTransitions } from "astro:transitions";
interface Props {
title: string;
description: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = "/nano.png" } = Astro.props;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/favicon-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/x-icon" href="/favicon-light.svg">
<meta name="generator" content={Astro.generator} />
<!-- Font preloads -->
<link rel="preload" href={inter400} as="font" type="font/woff2" crossorigin/>
<link rel="preload" href={inter600} as="font" type="font/woff2" crossorigin/>
<link rel="preload" href={lora400} as="font" type="font/woff2" crossorigin/>
<link rel="preload" href={lora600} as="font" type="font/woff2" crossorigin/>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<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)} />
<!-- Twitter -->
<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)} />
<ViewTransitions />
<script>
import type { TransitionBeforeSwapEvent } from "astro:transitions/client";
document.addEventListener("astro:before-swap", (e) =>
[
...(e as TransitionBeforeSwapEvent).newDocument.head.querySelectorAll(
"link[as=\"font\"]"
),
].forEach((link) => link.remove())
);
</script>
<script is:inline>
function init() {
preloadTheme();
onScroll();
animate();
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);
});
const darkThemeButton = document.getElementById("dark-theme-button");
darkThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "dark");
toggleTheme(true);
});
const systemThemeButton = document.getElementById("system-theme-button");
systemThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "system");
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
});
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", event => {
if (localStorage.theme === "system") {
toggleTheme(event.matches);
}
}
);
document.addEventListener("scroll", onScroll);
}
function animate() {
const animateElements = document.querySelectorAll(".animate");
animateElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add("show");
}, index * 150);
});
}
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);
}
function preloadTheme() {
const userTheme = localStorage.theme;
if (userTheme === "light" || userTheme === "dark") {
toggleTheme(userTheme === "dark");
} else {
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
}
}
document.addEventListener("DOMContentLoaded", () => init());
document.addEventListener("astro:after-swap", () => init());
preloadTheme();
</script>
+34
View File
@@ -0,0 +1,34 @@
---
import Container from "@components/Container.astro";
import Link from "@components/Link.astro";
import { SITE } from "@consts";
---
<header>
<Container>
<div class="flex flex-wrap gap-y-2 justify-between">
<Link href="/" underline={false}>
<div class="font-semibold">
{SITE.NAME}
</div>
</Link>
<nav class="flex gap-1">
<Link href="/blog">
blog
</Link>
<span>
{`/`}
</span>
<Link href="/work">
work
</Link>
<span>
{`/`}
</span>
<Link href="/projects">
projects
</Link>
</nav>
</div>
</Container>
</header>
+19
View File
@@ -0,0 +1,19 @@
---
import { cn } from "@lib/utils";
type Props = {
href: string;
external?: boolean;
underline?: boolean;
}
const { href, external, underline = true, ...rest } = Astro.props;
---
<a
href={href}
target={ external ? "_blank" : "_self" }
class={cn("inline-block decoration-black/15 dark:decoration-white/30 hover:decoration-black/25 hover:dark:decoration-white/50 text-current hover:text-black hover:dark:text-white transition-colors duration-300 ease-in-out", underline && "underline underline-offset-2")}
{...rest}>
<slot/>
</a>
+44
View File
@@ -0,0 +1,44 @@
import type { Site, Metadata, Socials } from "@types";
export const SITE: Site = {
NAME: "Michael Rausch",
EMAIL: "michael@rausch.nz",
NUM_POSTS_ON_HOMEPAGE: 3,
NUM_WORKS_ON_HOMEPAGE: 2,
NUM_PROJECTS_ON_HOMEPAGE: 3,
};
export const HOME: Metadata = {
TITLE: "Home",
DESCRIPTION: "Astro Nano is a minimal and lightweight blog and portfolio.",
};
export const BLOG: Metadata = {
TITLE: "Blog",
DESCRIPTION: "A collection of articles on topics I am passionate about.",
};
export const WORK: Metadata = {
TITLE: "Work",
DESCRIPTION: "Where I have worked and what I have done.",
};
export const PROJECTS: Metadata = {
TITLE: "Projects",
DESCRIPTION: "A collection of my projects, with links to repositories and demos.",
};
export const SOCIALS: Socials = [
{
NAME: "twitter-x",
HREF: "https://twitter.com/markhorn_dev",
},
{
NAME: "github",
HREF: "https://github.com/markhorn-dev"
},
{
NAME: "linkedin",
HREF: "https://www.linkedin.com/in/markhorn-dev",
}
];
@@ -0,0 +1,5 @@
---
title: "Coming Soon"
description: ""
date: "Mar 22 2024"
---
+35
View File
@@ -0,0 +1,35 @@
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().optional()
}),
});
const work = defineCollection({
type: "content",
schema: z.object({
company: z.string(),
role: z.string(),
dateStart: z.coerce.date(),
dateEnd: z.union([z.coerce.date(), z.string()]),
}),
});
const projects = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().optional(),
demoURL: z.string().optional(),
repoURL: z.string().optional()
}),
});
export const collections = { blog, work, projects };
+76
View File
@@ -0,0 +1,76 @@
---
title: "Astro Sphere"
description: "Portfolio and blog build with astro."
date: "Mar 18 2024"
demoURL: "https://astro-sphere-demo.vercel.app"
repoURL: "https://github.com/markhorn-dev/astro-sphere"
---
![Astro Sphere Lighthouse Score](/astro-sphere.jpg)
Astro Sphere is a static, minimalist, lightweight, lightning fast portfolio and blog theme based on my personal website.
It is primarily Astro, Tailwind and Typescript, with a very small amount of SolidJS for stateful components.
## 🚀 Deploy your own
<div class="flex gap-2">
<a target="_blank" aria-label="Deploy with Vercel" href="https://vercel.com/new/clone?repository-url=https://github.com/markhorn-dev/astro-sphere">
<img src="/deploy_vercel.svg" />
</a>
<a target="_blank" aria-label="Deploy with Netlify" href="https://app.netlify.com/start/deploy?repository=https://github.com/markhorn-dev/astro-sphere">
<img src="/deploy_netlify.svg" />
</a>
</div>
## 📋 Features
- ✅ 100/100 Lighthouse performance
- ✅ Responsive
- ✅ Accessible
- ✅ SEO-friendly
- ✅ Typesafe
- ✅ Minimal style
- ✅ Light/Dark Theme
- ✅ Animated UI
- ✅ Tailwind styling
- ✅ Auto generated sitemap
- ✅ Auto generated RSS Feed
- ✅ Markdown support
- ✅ MDX Support (components in your markdown)
- ✅ Searchable content (posts and projects)
## 💯 Lighthouse score
![Astro Sphere Lighthouse Score](/lighthouse.png)
## 🕊️ Lightweight
All pages under 100kb (including fonts)
## ⚡︎ Fast
Rendered in ~40ms on localhost
## 📄 Configuration
The blog posts on the demo serve as the documentation and configuration.
## 💻 Commands
All commands are run from the root of the project, from a terminal:
Replace npm with your package manager of choice. `npm`, `pnpm`, `yarn`, `bun`, etc
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run sync` | Generates TypeScript types for all Astro modules.|
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run lint` | Run ESLint |
| `npm run lint:fix` | Auto-fix ESLint issues |
## 🏛️ License
MIT
+79
View File
@@ -0,0 +1,79 @@
---
title: "Astro Nano"
description: "Minimal portfolio and blog build with astro and no frameworks."
date: "Mar 26 2024"
demoURL: "https://astro-nano-demo.vercel.app"
repoURL: "https://github.com/markhorn-dev/astro-nano"
---
![Astro Nano](/astro-nano.png)
Astro Nano is a static, minimalist, lightweight, lightning fast portfolio and blog theme.
Built with Astro, Tailwind and Typescript, an no frameworks.
It was designed as an even more minimal theme than my popular theme [Astro Sphere](https://github.com/markhorn-dev/astro-sphere)
## 🚀 Deploy your own
<div class="flex gap-2">
<a target="_blank" aria-label="Deploy with Vercel" href="https://vercel.com/new/clone?repository-url=https://github.com/markhorn-dev/astro-nano">
<img src="/deploy_vercel.svg" />
</a>
<a target="_blank" aria-label="Deploy with Netlify" href="https://app.netlify.com/start/deploy?repository=https://github.com/markhorn-dev/astro-nano">
<img src="/deploy_netlify.svg" />
</a>
</div>
## 📋 Features
- ✅ 100/100 Lighthouse performance
- ✅ Responsive
- ✅ Accessible
- ✅ SEO-friendly
- ✅ Typesafe
- ✅ Minimal style
- ✅ Light/Dark Theme
- ✅ Animated UI
- ✅ Tailwind styling
- ✅ Auto generated sitemap
- ✅ Auto generated RSS Feed
- ✅ Markdown support
- ✅ MDX Support (components in your markdown)
## 💯 Lighthouse score
![Astro Nano Lighthouse Score](/lighthouse.png)
## 🕊️ Lightweight
No frameworks or added bulk
## ⚡︎ Fast
Rendered in ~40ms on localhost
## 📄 Configuration
The blog posts on the demo serve as the documentation and configuration.
## 💻 Commands
All commands are run from the root of the project, from a terminal:
Replace npm with your package manager of choice. `npm`, `pnpm`, `yarn`, `bun`, etc
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run dev:network` | Starts local dev server on local network |
| `npm run sync` | Generates TypeScript types for all Astro modules.|
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run preview:network` | Preview build on local network |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run lint` | Run ESLint |
| `npm run lint:fix` | Auto-fix ESLint issues |
## 🏛️ License
MIT
@@ -0,0 +1,8 @@
---
company: "University of Canterbury"
role: "Software Engineer"
dateStart: "08/01/2023"
dateEnd: "present"
---
Voluptatem est quaerat voluptas praesentium ipsa dolorem dignissimos nulla ratione distinctio quae maiores eligendi nostrum? Quibusdam, debitis voluptatum, lorem ipsum dolor. Sit amet consectetur adipisicing elit. Iure illo neque tempora.
+8
View File
@@ -0,0 +1,8 @@
---
company: "Actuality"
role: "Software Engineer"
dateStart: "02/11/2022"
dateEnd: "05/05/2023"
---
Created a suite of innovative Augmented Reality product visualisation tools.
+8
View File
@@ -0,0 +1,8 @@
---
company: "Standard"
role: "Software Engineer | Director"
dateStart: "03/16/2018"
dateEnd: "07/01/2019"
---
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Iure illo neque tempora, voluptatem est quaerat voluptas praesentium ipsa dolorem dignissimos nulla ratione distinctio quae maiores eligendi nostrum? Quibusdam, debitis voluptatum.
+2
View File
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
+27
View File
@@ -0,0 +1,27 @@
---
import Head from "@components/Head.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import { SITE } from "@consts";
type Props = {
title: string;
description: string;
};
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<Head title={`${title} | ${SITE.NAME}`} description={description} />
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date) {
return Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric"
}).format(date);
}
export function readingTime(html: string) {
const textOnly = html.replace(/<[^>]+>/g, "");
const wordCount = textOnly.split(/\s+/).length;
const readingTimeMinutes = ((wordCount / 200) + 1).toFixed();
return `${readingTimeMinutes} min read`;
}
export function dateRange(startDate: Date, endDate?: Date | string): string {
const startMonth = startDate.toLocaleString("default", { month: "short" });
const startYear = startDate.getFullYear().toString();
let endMonth;
let endYear;
if (endDate) {
if (typeof endDate === "string") {
endMonth = "";
endYear = endDate;
} else {
endMonth = endDate.toLocaleString("default", { month: "short" });
endYear = endDate.getFullYear().toString();
}
}
return `${startMonth} ${startYear} - ${endMonth} ${endYear}`;
}
+49
View File
@@ -0,0 +1,49 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import { readingTime } from "@lib/utils";
import BackToPrev from "@components/BackToPrev.astro";
export async function getStaticPaths() {
const posts = (await getCollection("blog"))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<"blog">;
const post = Astro.props;
const { Content } = await post.render();
---
<PageLayout title={post.data.title} description={post.data.description}>
<Container>
<div class="animate">
<BackToPrev href="/blog">
Back to blog
</BackToPrev>
</div>
<div class="space-y-1 my-10">
<div class="animate flex items-center gap-1.5">
<div class="font-base text-sm">
<FormattedDate date={post.data.date} />
</div>
&bull;
<div class="font-base text-sm">
{readingTime(post.body)}
</div>
</div>
<div class="animate text-2xl font-semibold text-black dark:text-white">
{post.data.title}
</div>
</div>
<article class="animate">
<Content />
</article>
</Container>
</PageLayout>
+56
View File
@@ -0,0 +1,56 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import ArrowCard from "@components/ArrowCard.astro";
import { BLOG } from "@consts";
const data = (await getCollection("blog"))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
type Acc = {
[year: string]: CollectionEntry<"blog">[];
}
const posts = data.reduce((acc: Acc, post) => {
const year = post.data.date.getFullYear().toString();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(posts).sort((a, b) => parseInt(b) - parseInt(a));
---
<PageLayout title={BLOG.TITLE} description={BLOG.DESCRIPTION}>
<Container>
<div class="space-y-10">
<div class="animate font-semibold text-black dark:text-white">
Blog
</div>
<div class="space-y-4">
{years.map(year => (
<section class="animate space-y-4">
<div class="font-semibold text-black dark:text-white">
{year}
</div>
<div>
<ul class="flex flex-col gap-4">
{
posts[year].map((post) => (
<li>
<ArrowCard entry={post}/>
</li>
))
}
</ul>
</div>
</section>
))}
</div>
</div>
</Container>
</PageLayout>
+144
View File
@@ -0,0 +1,144 @@
---
import { getCollection } from "astro:content";
import Container from "@components/Container.astro";
import PageLayout from "@layouts/PageLayout.astro";
import ArrowCard from "@components/ArrowCard.astro";
import Link from "@components/Link.astro";
import { dateRange } from "@lib/utils";
import { SITE, HOME, SOCIALS } from "@consts";
const blog = (await getCollection("blog"))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0,SITE.NUM_POSTS_ON_HOMEPAGE);
const projects = (await getCollection("projects"))
.filter(project => !project.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0,SITE.NUM_PROJECTS_ON_HOMEPAGE);
const allwork = (await getCollection("work"))
.sort((a, b) => new Date(b.data.dateStart).valueOf() - new Date(a.data.dateStart).valueOf())
.slice(0,SITE.NUM_WORKS_ON_HOMEPAGE);
const work = await Promise.all(
allwork.map(async (item) => {
const { Content } = await item.render();
return { ...item, Content };
})
);
---
<PageLayout title={HOME.TITLE} description={HOME.DESCRIPTION}>
<Container>
<h4 class="animate font-semibold text-black dark:text-white text-4xl font-serif">
Hi, I'm Michael <span class="text-4xl">👋🏻</span>
</h4>
<div class="space-y-16">
<section>
<article class="space-y-4">
<p class="animate">
I'm a lead software engineer at the University of Canterbury, working on <Link href="https://uconline.ac.nz" external>Tuihono UC | UC Online</Link>.
I specialize in full-stack development. While my primary expertise is in software engineering, I also have a strong interest in cloud infrastructure and DevOps. Based in Christchurch, New Zealand
<br/><br/>
Feel free to connect with me through any of the social media links at the bottom of this page!
</p>
</article>
</section>
<section class="animate space-y-6">
<div class="flex flex-wrap gap-y-2 items-center justify-between">
<h5 class="font-semibold text-black dark:text-white">
Latest posts
</h5>
<Link href="/blog">
See all posts
</Link>
</div>
<ul class="flex flex-col gap-4">
{blog.map(post => (
<li>
<ArrowCard entry={post} />
</li>
))}
</ul>
</section>
<section class="animate space-y-6">
<div class="flex flex-wrap gap-y-2 items-center justify-between">
<h5 class="font-semibold text-black dark:text-white">
Work Experience
</h5>
<Link href="/work">
See all work
</Link>
</div>
<ul class="flex flex-col space-y-4">
{work.map(entry => (
<li>
<div class="text-sm opacity-75">
{dateRange(entry.data.dateStart, entry.data.dateEnd)}
</div>
<div class="font-semibold text-black dark:text-white">
{entry.data.company}
</div>
<div class="text-sm opacity-75">
{entry.data.role}
</div>
<article>
<entry.Content />
</article>
</li>
))}
</ul>
</section>
<section class="animate space-y-6">
<div class="flex flex-wrap gap-y-2 items-center justify-between">
<h5 class="font-semibold text-black dark:text-white">
Recent projects
</h5>
<Link href="/projects">
See all projects
</Link>
</div>
<ul class="flex flex-col gap-4">
{projects.map(project => (
<li>
<ArrowCard entry={project} />
</li>
))}
</ul>
</section>
<section class="animate space-y-4">
<h5 class="font-semibold text-black dark:text-white">
Let's Connect
</h5>
<article>
<p>
If you want to get in touch with me about something or just to say hi,
reach out on social media or send me an email.
</p>
</article>
<ul class="flex flex-wrap gap-2">
{SOCIALS.map(SOCIAL => (
<li class="flex gap-x-2 text-nowrap">
<Link href={SOCIAL.HREF} external aria-label={`${SITE.NAME} on ${SOCIAL.NAME}`}>
{SOCIAL.NAME}
</Link>
{"/"}
</li>
))}
<li class="line-clamp-1">
<Link href={`mailto:${SITE.EMAIL}`} aria-label={`Email ${SITE.NAME}`}>
{SITE.EMAIL}
</Link>
</li>
</ul>
</section>
</div>
</Container>
</PageLayout>
+67
View File
@@ -0,0 +1,67 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import { readingTime } from "@lib/utils";
import BackToPrev from "@components/BackToPrev.astro";
import Link from "@components/Link.astro";
export async function getStaticPaths() {
const projects = (await getCollection("projects"))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return projects.map((project) => ({
params: { slug: project.slug },
props: project,
}));
}
type Props = CollectionEntry<"projects">;
const project = Astro.props;
const { Content } = await project.render();
---
<PageLayout title={project.data.title} description={project.data.description}>
<Container>
<div class="animate">
<BackToPrev href="/projects">
Back to projects
</BackToPrev>
</div>
<div class="space-y-1 my-10">
<div class="animate flex items-center gap-1.5">
<div class="font-base text-sm">
<FormattedDate date={project.data.date} />
</div>
&bull;
<div class="font-base text-sm">
{readingTime(project.body)}
</div>
</div>
<div class="animate text-2xl font-semibold text-black dark:text-white">
{project.data.title}
</div>
{(project.data.demoURL || project.data.repoURL) && (
<nav class="animate flex gap-1">
{project.data.demoURL && (
<Link href={project.data.demoURL} external>
demo
</Link>
)}
{project.data.demoURL && project.data.repoURL && (
<span>/</span>
)}
{project.data.repoURL && (
<Link href={project.data.repoURL} external>
repo
</Link>
)}
</nav>
)}
</div>
<article class="animate">
<Content />
</article>
</Container>
</PageLayout>
+30
View File
@@ -0,0 +1,30 @@
---
import { getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import ArrowCard from "@components/ArrowCard.astro";
import { PROJECTS } from "@consts";
const projects = (await getCollection("projects"))
.filter(project => !project.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
<PageLayout title={PROJECTS.TITLE} description={PROJECTS.DESCRIPTION}>
<Container>
<div class="space-y-10">
<div class="animate font-semibold text-black dark:text-white">
Projects
</div>
<ul class="animate flex flex-col gap-4">
{
projects.map((project) => (
<li>
<ArrowCard entry={project}/>
</li>
))
}
</ul>
</div>
</Container>
</PageLayout>
+16
View File
@@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
const robotsTxt = `
User-agent: *
Allow: /
Sitemap: ${new URL("sitemap-index.xml", import.meta.env.SITE).href}
`.trim();
export const GET: APIRoute = () => {
return new Response(robotsTxt, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
};
+30
View File
@@ -0,0 +1,30 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import { HOME } from "@consts";
type Context = {
site: string
}
export async function GET(context: Context) {
const blog = (await getCollection("blog"))
.filter(post => !post.data.draft);
const projects = (await getCollection("projects"))
.filter(project => !project.data.draft);
const items = [...blog, ...projects]
.sort((a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf());
return rss({
title: HOME.TITLE,
description: HOME.DESCRIPTION,
site: context.site,
items: items.map((item) => ({
title: item.data.title,
description: item.data.description,
pubDate: item.data.date,
link: `/${item.collection}/${item.slug}/`,
})),
});
}
+51
View File
@@ -0,0 +1,51 @@
---
import { getCollection } from "astro:content";
import PageLayout from "@layouts/PageLayout.astro";
import Container from "@components/Container.astro";
import { dateRange } from "@lib/utils";
import { WORK } from "@consts";
const collection = (await getCollection("work"))
.sort((a, b) => new Date(b.data.dateStart).valueOf() - new Date(a.data.dateStart).valueOf());
const work = await Promise.all(
collection.map(async (item) => {
const { Content } = await item.render();
return { ...item, Content };
})
);
---
<PageLayout title={WORK.TITLE} description={WORK.DESCRIPTION}>
<Container>
<div class="space-y-10">
<div class="animate font-semibold text-black dark:text-white">
Work
</div>
<ul class="flex flex-col space-y-4">
{
work.map(entry => (
<li class="animate">
<div class="text-sm opacity-75">
{dateRange(entry.data.dateStart, entry.data.dateEnd)}
</div>
<div class="font-semibold text-black dark:text-white">
{entry.data.company}
</div>
<div class="text-sm opacity-75">
{entry.data.role}
</div>
<article>
<entry.Content />
</article>
</li>
))
}
</ul>
<!--
<ul class="animate flex flex-col gap-4">
</ul> -->
</div>
</Container>
</PageLayout>
+73
View File
@@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow-y: scroll;
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
html,
body {
@apply size-full;
}
body {
@apply font-sans antialiased;
@apply flex flex-col;
@apply bg-stone-100 dark:bg-black;
@apply text-black/50 dark:text-white/75;
}
header {
@apply fixed top-0 left-0 right-0 z-50 py-5;
@apply bg-stone-100/75 dark:bg-black/25;
@apply backdrop-blur-sm saturate-200;
}
main {
@apply flex-1 py-32;
}
footer {
@apply py-5 text-sm;
}
article {
@apply max-w-full prose dark:prose-invert prose-img:mx-auto prose-img:my-auto;
@apply prose-headings:font-semibold prose-p:font-serif;
@apply prose-headings:text-black prose-headings:dark:text-white;
}
@layer utilities {
article a {
@apply font-sans text-current underline underline-offset-2;
@apply decoration-black/15 dark:decoration-white/30;
@apply transition-colors duration-300 ease-in-out;
}
article a:hover {
@apply text-black dark:text-white;
@apply decoration-black/25 dark:decoration-white/50;
}
}
.animate {
@apply opacity-0 translate-y-3;
@apply transition-all duration-700 ease-out;
}
.animate.show {
@apply opacity-100 translate-y-0;
}
html #back-to-top {
@apply opacity-0 pointer-events-none;
}
html.scrolled #back-to-top {
@apply opacity-100 pointer-events-auto;
}
+17
View File
@@ -0,0 +1,17 @@
export type Site = {
NAME: string;
EMAIL: string;
NUM_POSTS_ON_HOMEPAGE: number;
NUM_WORKS_ON_HOMEPAGE: number;
NUM_PROJECTS_ON_HOMEPAGE: number;
};
export type Metadata = {
TITLE: string;
DESCRIPTION: string;
};
export type Socials = {
NAME: string;
HREF: string;
}[];