initial
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<div class="mx-auto max-w-screen-sm px-5">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -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>
|
||||
© 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
---
|
||||
@@ -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 };
|
||||
@@ -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 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
|
||||

|
||||
|
||||
## 🕊️ 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
|
||||
@@ -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 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
|
||||

|
||||
|
||||
## 🕊️ 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
•
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
•
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}[];
|
||||
Reference in New Issue
Block a user