This commit is contained in:
도로롱
2025-10-23 20:51:28 +09:00
commit e6ab4973c0
44 changed files with 7642 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 Mark Horn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
![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
[![Deploy with Vercel](_deploy_vercel.svg)](https://vercel.com/new/clone?repository-url=https://github.com/markhorn-dev/astro-nano) [![Deploy with Netlify](_deploy_netlify.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/markhorn-dev/astro-nano)
## 📋 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

9
astro.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
export default defineConfig({
site: "https://damhw.ee",
integrations: [mdx(), sitemap(), tailwind()],
});

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "astro-nano",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"dev:network": "astro dev --host",
"build": "astro check && astro build",
"preview": "astro preview",
"preview:network": "astro preview --host",
"astro": "astro",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.0.2",
"@astrojs/rss": "^4.0.10",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^5.1.3",
"@fontsource/inter": "^5.0.17",
"@fontsource/lora": "^5.0.16",
"@tailwindcss/typography": "^0.5.10",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"astro": "^5.0.5",
"clsx": "^2.1.0",
"eslint": "^8.57.0",
"eslint-plugin-astro": "^0.32.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"sharp": "^0.33.3",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2"
}
}

6353
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/astro-nano.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
public/astro-sphere.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

11
public/favicon-dark.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="85" height="107" viewBox="0 0 85 107" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z" />
<path fill="url(#paint0_linear_1_59)" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z" />
<path fill="white" d="M0 69.5866C0 69.5866 14.3139 62.6137 28.6678 62.6137L39.4901 29.1204C39.8953 27.5007 41.0783 26.3999 42.4139 26.3999C43.7495 26.3999 44.9325 27.5007 45.3377 29.1204L56.1601 62.6137C73.1601 62.6137 84.8278 69.5866 84.8278 69.5866C84.8278 69.5866 60.5145 3.35233 60.467 3.21944C59.7692 1.2612 58.5911 0 57.0029 0H27.8274C26.2392 0 25.1087 1.2612 24.3634 3.21944C24.3108 3.34983 0 69.5866 0 69.5866Z" />
<defs>
<linearGradient id="paint0_linear_1_59" x1="22.4702" y1="107" x2="69.1451" y2="84.9468" gradientUnits="userSpaceOnUse">
<stop stop-color="#D83333"/>
<stop offset="1" stop-color="#F041FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

11
public/favicon-light.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="85" height="107" viewBox="0 0 85 107" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="black" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z" />
<path fill="url(#paint0_linear_1_59)" d="M27.5894 91.1365C22.7555 86.7178 21.3444 77.4335 23.3583 70.7072C26.8503 74.948 31.6888 76.2914 36.7005 77.0497C44.4375 78.2199 52.0359 77.7822 59.2232 74.2459C60.0454 73.841 60.8052 73.3027 61.7036 72.7574C62.378 74.714 62.5535 76.6892 62.318 78.6996C61.7452 83.5957 59.3086 87.3778 55.4332 90.2448C53.8835 91.3916 52.2437 92.4167 50.6432 93.4979C45.7262 96.8213 44.3959 100.718 46.2435 106.386C46.2874 106.525 46.3267 106.663 46.426 107C43.9155 105.876 42.0817 104.24 40.6845 102.089C39.2087 99.8193 38.5066 97.3081 38.4696 94.5909C38.4511 93.2686 38.4511 91.9345 38.2733 90.6309C37.8391 87.4527 36.3471 86.0297 33.5364 85.9478C30.6518 85.8636 28.37 87.6469 27.7649 90.4554C27.7187 90.6707 27.6517 90.8837 27.5847 91.1341L27.5894 91.1365Z" />
<path fill="black" d="M0 69.5866C0 69.5866 14.3139 62.6137 28.6678 62.6137L39.4901 29.1204C39.8953 27.5007 41.0783 26.3999 42.4139 26.3999C43.7495 26.3999 44.9325 27.5007 45.3377 29.1204L56.1601 62.6137C73.1601 62.6137 84.8278 69.5866 84.8278 69.5866C84.8278 69.5866 60.5145 3.35233 60.467 3.21944C59.7692 1.2612 58.5911 0 57.0029 0H27.8274C26.2392 0 25.1087 1.2612 24.3634 3.21944C24.3108 3.34983 0 69.5866 0 69.5866Z" />
<defs>
<linearGradient id="paint0_linear_1_59" x1="22.4702" y1="107" x2="69.1451" y2="84.9468" gradientUnits="userSpaceOnUse">
<stop stop-color="#D83333"/>
<stop offset="1" stop-color="#F041FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/patrick.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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>

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>

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>

View File

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

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>

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>

186
src/components/Head.astro Normal file
View File

@@ -0,0 +1,186 @@
---
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 { ClientRouter } from "astro:transitions";
import { SITE } from "@consts";
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)} />
<ClientRouter />
<!-- RSS Link -->
<link rel="alternate" type="application/rss+xml" title={SITE.NAME} href={new URL("rss.xml", Astro.site)} />
<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" || !localStorage.theme) {
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>

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
src/components/Link.astro Normal file
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>

40
src/consts.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Site, Metadata, Socials } from "@types";
export const SITE: Site = {
NAME: "Damhwee Ahn",
EMAIL: "me@damhw.ee",
NUM_POSTS_ON_HOMEPAGE: 5,
NUM_WORKS_ON_HOMEPAGE: 0,
NUM_PROJECTS_ON_HOMEPAGE: 3,
};
export const HOME: Metadata = {
TITLE: "Home",
DESCRIPTION: "도메인을 방치하기 싫어서 대충 astro-nano 기반으로 만든 웹사이트",
};
export const BLOG: Metadata = {
TITLE: "Blog",
DESCRIPTION: "똥글 모음집",
};
export const WORK: Metadata = {
TITLE: "Career",
DESCRIPTION: "커리어",
};
export const PROJECTS: Metadata = {
TITLE: "Projects",
DESCRIPTION: "프로젝트 레포, 데모 목록",
};
export const SOCIALS: Socials = [
{
NAME: "instagram",
HREF: "https://instagram.com/damhwee",
},
{
NAME: "github",
HREF: "https://github.com/yeorinhieut"
}
];

View File

@@ -0,0 +1,7 @@
---
title: "화이트햇 콘테스트 2025 예선 writeup (forensics/ai)"
description: "나머지는 시간이 된다면 작성할지도"
date: "2025년 10월 23일"
---
시간나면작성

35
src/content/config.ts Normal file
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 };

View File

@@ -0,0 +1,9 @@
---
title: "Percept"
description: "Percept 팀의 리드 개발자"
date: "2025년~"
demoURL: "https://percept.cloud"
repoURL: "https://github.com/percept-cloud"
---
지금은 방치된 Percept팀의 리드 개발자입니다. 수험기간이 끝난 뒤 부활할 예정입니다.

View File

@@ -0,0 +1,6 @@
---
company: "서울 양정고등학교"
role: "3학년 재학중"
dateStart: "2023"
dateEnd: "재학중"
---

2
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

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
src/lib/utils.ts Normal file
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}`;
}

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>

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>

140
src/pages/index.astro Normal file
View File

@@ -0,0 +1,140 @@
---
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">
Hi, I'm Damhwee Ahn <span class="text-xl">👋🏻</span>
</h4>
<div class="space-y-16">
<section>
<article class="space-y-4">
<p class="animate">
안녕하세요 저는 서울특별시 소재 양정고등학교에 재학중인 안담휘 입니다. 프로그래밍과 정보보안에 관심이 있는 초보입니다.
</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">
Career
</h5>
<Link href="/work">
See my career
</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>

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>

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
src/pages/robots.txt.ts Normal file
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
src/pages/rss.xml.ts Normal file
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}/`,
})),
});
}

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
src/styles/global.css Normal file
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-stone-900;
@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-stone-900/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
src/types.ts Normal file
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;
}[];

18
tailwind.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import defaultTheme from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter", ...defaultTheme.fontFamily.sans],
serif: ["Lora", ...defaultTheme.fontFamily.serif],
},
},
},
plugins: [require("@tailwindcss/typography")],
};

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@*": ["./src/*"]
}
}
}