restore portfolio
This commit is contained in:
parent
82c21e9479
commit
13a67bbf8d
28 changed files with 7747 additions and 19 deletions
|
|
@ -1,5 +1,16 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
// https://astro.build/config
|
export default defineConfig({
|
||||||
export default defineConfig({});
|
output: "server",
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: "standalone",
|
||||||
|
}),
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
});
|
||||||
20
content.config.ts
Normal file
20
content.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/blog",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
blog,
|
||||||
|
};
|
||||||
5122
package-lock.json
generated
Normal file
5122
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,9 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^7.2.0"
|
"@astrojs/node": "^11.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"astro": "^7.2.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
219
scripts/spotify-token.py
Normal file
219
scripts/spotify-token.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import http.server
|
||||||
|
import urllib.parse
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ID = "4442e700b9ee46c08b24f351abbe030c"
|
||||||
|
|
||||||
|
REDIRECT_URI = "http://127.0.0.1:8888/callback"
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
"user-read-currently-playing",
|
||||||
|
"user-read-playback-state",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||||
|
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
code = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
|
||||||
|
CallbackHandler.code = params.get("code", [None])[0]
|
||||||
|
CallbackHandler.error = params.get("error", [None])[0]
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
if CallbackHandler.code:
|
||||||
|
message = """
|
||||||
|
<h1>Spotify authorization received.</h1>
|
||||||
|
<p>You can close this tab.</p>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Spotify authorization failed.</h1>
|
||||||
|
<p>Check the terminal for details.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.wfile.write(
|
||||||
|
f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Spotify OAuth</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{message}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_secret():
|
||||||
|
secret = input("SPOTIFY_CLIENT_SECRET: ").strip()
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
raise SystemExit("Client secret cannot be empty.")
|
||||||
|
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorization_url():
|
||||||
|
params = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"scope": " ".join(SCOPES),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{AUTH_URL}?{params}"
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_token(client_secret, code):
|
||||||
|
credentials = f"{CLIENT_ID}:{client_secret}"
|
||||||
|
|
||||||
|
encoded_credentials = base64.b64encode(
|
||||||
|
credentials.encode("utf-8")
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {encoded_credentials}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print()
|
||||||
|
print("Spotify OAuth token generator")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
client_secret = get_client_secret()
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(
|
||||||
|
("127.0.0.1", 8888),
|
||||||
|
CallbackHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
authorization_url = build_authorization_url()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Opening Spotify authorization page...")
|
||||||
|
print()
|
||||||
|
print(authorization_url)
|
||||||
|
print()
|
||||||
|
|
||||||
|
webbrowser.open(authorization_url)
|
||||||
|
|
||||||
|
print("Waiting for Spotify callback...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while CallbackHandler.code is None and CallbackHandler.error is None:
|
||||||
|
server.handle_request()
|
||||||
|
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
if CallbackHandler.error:
|
||||||
|
print("Spotify authorization failed:")
|
||||||
|
print(CallbackHandler.error)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
code = CallbackHandler.code
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise SystemExit("No authorization code received.")
|
||||||
|
|
||||||
|
print("Authorization code received.")
|
||||||
|
print("Exchanging code for tokens...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
response = exchange_code_for_token(client_secret, code)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
print("=" * 60)
|
||||||
|
print("SPOTIFY TOKEN REQUEST FAILED")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"HTTP status: {response.status_code}")
|
||||||
|
print()
|
||||||
|
print("Spotify response:")
|
||||||
|
print(response.text)
|
||||||
|
print()
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
print("Spotify returned invalid JSON:")
|
||||||
|
print(response.text)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
refresh_token = data.get("refresh_token")
|
||||||
|
access_token = data.get("access_token")
|
||||||
|
expires_in = data.get("expires_in")
|
||||||
|
scope = data.get("scope")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("SPOTIFY TOKENS")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
print("SPOTIFY_REFRESH_TOKEN:")
|
||||||
|
print(refresh_token)
|
||||||
|
else:
|
||||||
|
print("WARNING: Spotify did not return a refresh_token.")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
print("Access token received: YES")
|
||||||
|
else:
|
||||||
|
print("Access token received: NO")
|
||||||
|
|
||||||
|
print(f"Expires in: {expires_in} seconds")
|
||||||
|
print(f"Granted scopes: {scope}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("IMPORTANT")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print("Store the refresh token in your .env file.")
|
||||||
|
print("Do not commit it to Git.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
73
src/components/Header.astro
Normal file
73
src/components/Header.astro
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<header class="border-b border-[var(--border)]">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-5 lg:px-8">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="font-mono text-sm font-bold tracking-tight"
|
||||||
|
>
|
||||||
|
SEMYON<span class="text-[var(--accent)]">.</span>DEV
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-7 font-mono text-[11px] md:flex">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/projects"
|
||||||
|
class="text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
WORK
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#lab"
|
||||||
|
class="text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
LAB
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
BLOG
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#about"
|
||||||
|
class="text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
ABOUT
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="h-4 w-px bg-[var(--border)]"></span>
|
||||||
|
|
||||||
|
<!-- TODO: заменить # на реальную ссылку DonationAlerts -->
|
||||||
|
<a
|
||||||
|
href="https://www.donationalerts.com/r/alastor3301"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-[var(--accent)] transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
|
SUPPORT ↗
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- TODO: заменить # на GitHub -->
|
||||||
|
<a
|
||||||
|
href="https://github.com/Trytonottry"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
GITHUB ↗
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="font-mono text-xs text-[var(--muted)] md:hidden"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
MENU
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
186
src/components/Hero.astro
Normal file
186
src/components/Hero.astro
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="mx-auto grid min-h-[calc(100vh-73px)] max-w-7xl gap-16 px-6 py-20 lg:grid-cols-[minmax(0,1fr)_380px] lg:px-8 lg:py-24"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-center">
|
||||||
|
|
||||||
|
<div class="mb-8 flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent)] opacity-40"></span>
|
||||||
|
<span class="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent)]"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
SYSTEM ONLINE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
SYSTEMS / AUTOMATION / INFRASTRUCTURE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="max-w-5xl text-[4rem] font-bold leading-[0.88] tracking-[-0.065em] sm:text-[5.5rem] lg:text-[7.5rem]"
|
||||||
|
>
|
||||||
|
BUILD.
|
||||||
|
<br />
|
||||||
|
BREAK.
|
||||||
|
<br />
|
||||||
|
<span class="text-[var(--accent)]">UNDERSTAND.</span>
|
||||||
|
<br />
|
||||||
|
REPEAT.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-col gap-8 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
|
||||||
|
<p class="max-w-xl text-base leading-7 text-[var(--muted)] sm:text-lg">
|
||||||
|
Systems administrator and infrastructure engineer.
|
||||||
|
I build infrastructure, automate systems and investigate
|
||||||
|
things when they inevitably break.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hidden font-mono text-right text-[10px] leading-5 text-[var(--muted)] sm:block">
|
||||||
|
<div>ALASTOR.LOL</div>
|
||||||
|
<div>SYS / 001</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-wrap gap-3">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#projects"
|
||||||
|
class="group border border-[var(--text)] px-5 py-3 font-mono text-[11px] uppercase tracking-wider transition-all duration-200 hover:bg-[var(--text)] hover:text-black"
|
||||||
|
>
|
||||||
|
<span>View projects</span>
|
||||||
|
<span class="ml-3 transition-transform duration-200 group-hover:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="border border-[var(--border)] px-5 py-3 font-mono text-[11px] uppercase tracking-wider text-[var(--muted)] transition-all duration-200 hover:border-[var(--text)] hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
Read blog →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end">
|
||||||
|
|
||||||
|
<div class="w-full border border-[var(--border)] bg-[var(--surface)] font-mono text-xs">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-[var(--accent)]"></span>
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
SYSTEM STATUS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
OPERATIONAL
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
|
||||||
|
<div class="mb-6 text-[10px] uppercase tracking-[0.2em] text-[var(--muted)]">
|
||||||
|
CAPABILITIES
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-[var(--muted)]">Linux</span>
|
||||||
|
<span class="text-[var(--accent)]">●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-[var(--border)]"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-[var(--muted)]">Infrastructure</span>
|
||||||
|
<span class="text-[var(--accent)]">●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-[var(--border)]"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-[var(--muted)]">Networking</span>
|
||||||
|
<span class="text-[var(--accent)]">●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-[var(--border)]"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-[var(--muted)]">Automation</span>
|
||||||
|
<span class="text-[var(--accent)]">●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 border-t border-[var(--border)] pt-6">
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-[var(--muted)]">HOMELAB</span>
|
||||||
|
<span class="text-[var(--accent)]">87%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-1 bg-[var(--border)]">
|
||||||
|
<div class="h-full w-[87%] bg-[var(--accent)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid grid-cols-2 gap-4 border-t border-[var(--border)] pt-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] text-[var(--muted)]">HOSTS</div>
|
||||||
|
<div class="mt-2 text-xl">06</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] text-[var(--muted)]">SERVICES</div>
|
||||||
|
<div class="mt-2 text-xl">30+</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 border-t border-[var(--border)] pt-5 text-[10px] text-[var(--muted)]">
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>UPTIME</span>
|
||||||
|
<span class="text-[var(--text)]">99.9%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-between">
|
||||||
|
<span>STATUS</span>
|
||||||
|
<span class="text-[var(--accent)]">BUILDING</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto hidden max-w-7xl px-6 pb-6 lg:block lg:px-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--muted)]">
|
||||||
|
<span>SCROLL TO EXPLORE</span>
|
||||||
|
<span>ALASTOR.LOL / 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
90
src/components/LatestPosts.astro
Normal file
90
src/components/LatestPosts.astro
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
---
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = (await getCollection("blog"))
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.data.pubDate.valueOf() -
|
||||||
|
a.data.pubDate.valueOf(),
|
||||||
|
)
|
||||||
|
.slice(0, 3);
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
04 / NOTES
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||||
|
Latest notes
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
VIEW ALL NOTES →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 space-y-px border border-[var(--border)] bg-[var(--border)]">
|
||||||
|
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.id}/`}
|
||||||
|
class="group block bg-[var(--surface)] p-6 transition-colors hover:bg-[var(--surface-hover)] lg:p-8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid gap-5 md:grid-cols-[150px_1fr_auto] md:items-center">
|
||||||
|
|
||||||
|
<time
|
||||||
|
datetime={post.data.pubDate.toISOString()}
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{post.data.pubDate.toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{post.data.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
READ →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
275
src/components/Now.astro
Normal file
275
src/components/Now.astro
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
<div class="border-y border-[var(--border)]">
|
||||||
|
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] py-4">
|
||||||
|
<div class="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
01 / NOW
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="now-status"
|
||||||
|
class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
id="status-dot"
|
||||||
|
class="h-1.5 w-1.5 rounded-full bg-[var(--muted)]"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<span id="status-text">SYNCING</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid divide-y divide-[var(--border)] md:grid-cols-4 md:divide-x md:divide-y-0">
|
||||||
|
|
||||||
|
<div class="py-7 md:pr-8">
|
||||||
|
<div class="font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
CURRENT STATE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-sm leading-relaxed">
|
||||||
|
Building things,<br />
|
||||||
|
break fixing things.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-7 md:px-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
♪ LISTENING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="spotify-indicator"
|
||||||
|
class="hidden font-mono text-[9px] uppercase tracking-widest text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
id="spotify-link"
|
||||||
|
href="#"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-3 block text-sm font-medium transition-colors hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<span id="spotify-title">Loading...</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="spotify-artist"
|
||||||
|
class="mt-1 text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
Spotify
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 font-mono text-[9px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
SPOTIFY
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-7 md:px-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
PLAYING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="steam-indicator"
|
||||||
|
class="hidden font-mono text-[9px] uppercase tracking-widest text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
id="steam-link"
|
||||||
|
href="#"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-3 block text-sm font-medium transition-colors hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<span id="steam-game">Loading...</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-[var(--muted)]">
|
||||||
|
Steam
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 font-mono text-[9px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
STEAM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-7 md:pl-8">
|
||||||
|
<div class="font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
BUILDING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-sm font-medium">
|
||||||
|
alastor.lol
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-xs text-[var(--muted)]">
|
||||||
|
This website
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 font-mono text-[9px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
ASTRO · TYPESCRIPT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const spotifyTitle =
|
||||||
|
document.querySelector("#spotify-title");
|
||||||
|
|
||||||
|
const spotifyArtist =
|
||||||
|
document.querySelector("#spotify-artist");
|
||||||
|
|
||||||
|
const spotifyLink =
|
||||||
|
document.querySelector("#spotify-link") as HTMLAnchorElement | null;
|
||||||
|
|
||||||
|
const spotifyIndicator =
|
||||||
|
document.querySelector("#spotify-indicator");
|
||||||
|
|
||||||
|
const steamGame =
|
||||||
|
document.querySelector("#steam-game");
|
||||||
|
|
||||||
|
const steamLink =
|
||||||
|
document.querySelector("#steam-link") as HTMLAnchorElement | null;
|
||||||
|
|
||||||
|
const steamIndicator =
|
||||||
|
document.querySelector("#steam-indicator");
|
||||||
|
|
||||||
|
const statusText =
|
||||||
|
document.querySelector("#status-text");
|
||||||
|
|
||||||
|
const statusDot =
|
||||||
|
document.querySelector("#status-dot");
|
||||||
|
|
||||||
|
function updateOverallStatus() {
|
||||||
|
const spotifyLive =
|
||||||
|
spotifyIndicator?.classList.contains("inline");
|
||||||
|
|
||||||
|
const steamLive =
|
||||||
|
steamIndicator?.classList.contains("inline");
|
||||||
|
|
||||||
|
if (spotifyLive || steamLive) {
|
||||||
|
statusText!.textContent = "LIVE";
|
||||||
|
statusDot!.classList.remove("bg-[var(--muted)]");
|
||||||
|
statusDot!.classList.add(
|
||||||
|
"bg-[var(--accent)]",
|
||||||
|
"animate-pulse",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
statusText!.textContent = "IDLE";
|
||||||
|
statusDot!.classList.remove(
|
||||||
|
"bg-[var(--accent)]",
|
||||||
|
"animate-pulse",
|
||||||
|
);
|
||||||
|
statusDot!.classList.add("bg-[var(--muted)]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSpotify() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/spotify", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Spotify request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.playing) {
|
||||||
|
spotifyTitle!.textContent = "Nothing right now";
|
||||||
|
spotifyArtist!.textContent = "Spotify";
|
||||||
|
spotifyLink?.removeAttribute("href");
|
||||||
|
|
||||||
|
spotifyIndicator?.classList.remove("inline");
|
||||||
|
spotifyIndicator?.classList.add("hidden");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyTitle!.textContent = data.title;
|
||||||
|
spotifyArtist!.textContent = data.artist;
|
||||||
|
|
||||||
|
if (spotifyLink && data.url) {
|
||||||
|
spotifyLink.href = data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyIndicator?.classList.remove("hidden");
|
||||||
|
spotifyIndicator?.classList.add("inline");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
} catch {
|
||||||
|
spotifyTitle!.textContent = "Unavailable";
|
||||||
|
spotifyArtist!.textContent = "Spotify";
|
||||||
|
spotifyLink?.removeAttribute("href");
|
||||||
|
|
||||||
|
spotifyIndicator?.classList.remove("inline");
|
||||||
|
spotifyIndicator?.classList.add("hidden");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSteam() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/steam", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Steam request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.playing) {
|
||||||
|
steamGame!.textContent = "Not playing";
|
||||||
|
steamLink?.removeAttribute("href");
|
||||||
|
|
||||||
|
steamIndicator?.classList.remove("inline");
|
||||||
|
steamIndicator?.classList.add("hidden");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
steamGame!.textContent = data.game;
|
||||||
|
|
||||||
|
if (steamLink && data.url) {
|
||||||
|
steamLink.href = data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
steamIndicator?.classList.remove("hidden");
|
||||||
|
steamIndicator?.classList.add("inline");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
} catch {
|
||||||
|
steamGame!.textContent = "Unavailable";
|
||||||
|
steamLink?.removeAttribute("href");
|
||||||
|
|
||||||
|
steamIndicator?.classList.remove("inline");
|
||||||
|
steamIndicator?.classList.add("hidden");
|
||||||
|
|
||||||
|
updateOverallStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSpotify();
|
||||||
|
updateSteam();
|
||||||
|
|
||||||
|
setInterval(updateSpotify, 30_000);
|
||||||
|
setInterval(updateSteam, 60_000);
|
||||||
|
</script>
|
||||||
43
src/components/NowPlaying.astro
Normal file
43
src/components/NowPlaying.astro
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<section class="border-y border-[var(--border)]">
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-8 lg:px-8">
|
||||||
|
<div class="grid gap-8 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
Now
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-sm">
|
||||||
|
Current state of the machine.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
♪ Listening
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
Venjent — U R Gonna Move
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 font-mono text-xs text-[var(--muted)]">
|
||||||
|
Spotify
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
🎮 Playing
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
Factorio
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 font-mono text-xs text-[var(--muted)]">
|
||||||
|
Steam
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
62
src/components/ProjectCard.astro
Normal file
62
src/components/ProjectCard.astro
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
stack: string[];
|
||||||
|
href: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
stack,
|
||||||
|
href,
|
||||||
|
status,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
class="group block bg-[var(--surface)] transition-colors hover:bg-[var(--surface-hover)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] px-5 py-4 font-mono text-[10px]">
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
{number}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 lg:p-7">
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-semibold tracking-tight transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-4 max-w-lg text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{stack.map((item) => (
|
||||||
|
<span
|
||||||
|
class="border border-[var(--border)] px-2 py-1 font-mono text-[9px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 font-mono text-[10px] text-[var(--muted)] transition-colors group-hover:text-[var(--text)]">
|
||||||
|
EXPLORE PROJECT →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
163
src/components/Projects.astro
Normal file
163
src/components/Projects.astro
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
|
||||||
|
---
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const projects = await getCollection("projects", ({ data }) => {
|
||||||
|
return data.draft !== true;
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="projects"
|
||||||
|
class="border-t border-[var(--border)]"
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-[10px] tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
02 / PROJECTS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Things I build.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="max-w-md text-sm leading-7 text-[var(--muted)]">
|
||||||
|
Infrastructure, automation, networking and experiments.
|
||||||
|
Some are production systems. Some are glorified
|
||||||
|
learning exercises.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Projects -->
|
||||||
|
|
||||||
|
<div class="mt-16 border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
{
|
||||||
|
projects.map((project, index) => (
|
||||||
|
<a
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
class="group block border-b border-[var(--border)] transition-colors duration-200 hover:bg-[var(--surface)]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid gap-6 px-0 py-8 md:grid-cols-[64px_minmax(0,1fr)_220px_110px] md:items-center">
|
||||||
|
|
||||||
|
<!-- Number -->
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Project -->
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
|
||||||
|
<h3 class="truncate text-2xl font-semibold tracking-tight transition-colors duration-200 group-hover:text-[var(--accent)]">
|
||||||
|
{project.data.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<span class="font-mono text-xs text-[var(--muted)] transition-transform duration-200 group-hover:translate-x-1">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]">
|
||||||
|
{project.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Stack + status -->
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
|
||||||
|
{
|
||||||
|
project.data.stack.map((item) => (
|
||||||
|
<span class="border border-[var(--border)] px-2 py-1 font-mono text-[9px] uppercase tracking-wider text-[var(--muted)]">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 font-mono text-[9px] uppercase tracking-widest">
|
||||||
|
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
"h-1.5 w-1.5 rounded-full",
|
||||||
|
project.data.status === "RUNNING"
|
||||||
|
? "bg-[var(--accent)]"
|
||||||
|
: "bg-[var(--muted)]",
|
||||||
|
]}
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
project.data.status === "RUNNING"
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--muted)]",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{project.data.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- View -->
|
||||||
|
|
||||||
|
<div class="hidden text-right font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] md:block">
|
||||||
|
|
||||||
|
<span class="transition-colors group-hover:text-[var(--text)]">
|
||||||
|
VIEW →
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col gap-4 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{projects.length} PROJECTS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/projects"
|
||||||
|
class="transition-colors hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
VIEW ALL PROJECTS →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
36
src/content.config.ts
Normal file
36
src/content.config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/blog",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/projects",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
stack: z.array(z.string()).default([]),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
blog,
|
||||||
|
projects,
|
||||||
|
};
|
||||||
49
src/content/blog/building-this-site.md
Normal file
49
src/content/blog/building-this-site.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
title: "Building This Site"
|
||||||
|
description: "Why I decided to build my own technical corner of the internet."
|
||||||
|
pubDate: 2026-08-09
|
||||||
|
tags:
|
||||||
|
- meta
|
||||||
|
- astro
|
||||||
|
- infrastructure
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building this site
|
||||||
|
|
||||||
|
I've had a domain sitting around for quite a while.
|
||||||
|
|
||||||
|
It was time to actually do something with it.
|
||||||
|
|
||||||
|
This site is going to be a combination of a portfolio, technical blog and a small window into what I'm currently working on.
|
||||||
|
|
||||||
|
The stack is deliberately simple:
|
||||||
|
|
||||||
|
- Astro
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Markdown / MDX
|
||||||
|
|
||||||
|
No giant frontend framework.
|
||||||
|
|
||||||
|
No unnecessary complexity.
|
||||||
|
|
||||||
|
Just a fast static site with a few dynamic pieces where they actually make sense.
|
||||||
|
|
||||||
|
## What I want to write about
|
||||||
|
|
||||||
|
Mostly things I actually work with:
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- networking
|
||||||
|
- infrastructure
|
||||||
|
- homelab
|
||||||
|
- automation
|
||||||
|
- embedded systems
|
||||||
|
- LoRa
|
||||||
|
- self-hosting
|
||||||
|
- debugging things that shouldn't be broken
|
||||||
|
|
||||||
|
And occasionally something completely unrelated.
|
||||||
|
|
||||||
|
That's part of the point.
|
||||||
43
src/content/projects/automation.md
Normal file
43
src/content/projects/automation.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
title: "Infrastructure Automation"
|
||||||
|
description: "Small tools and automation systems designed to eliminate repetitive operational work."
|
||||||
|
status: "ONGOING"
|
||||||
|
stack:
|
||||||
|
- Python
|
||||||
|
- Go
|
||||||
|
- Bash
|
||||||
|
- Linux
|
||||||
|
- Git
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A collection of tools and scripts created to automate infrastructure operations.
|
||||||
|
|
||||||
|
Some are tiny utilities.
|
||||||
|
|
||||||
|
Others eventually grow into actual services.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
The general rule is simple:
|
||||||
|
|
||||||
|
If I have to perform the same operation repeatedly, it should probably become a script.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
The toolbox includes automation written in:
|
||||||
|
|
||||||
|
- Python
|
||||||
|
- Go
|
||||||
|
- Bash
|
||||||
|
|
||||||
|
Depending on the problem, the simplest appropriate tool wins.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Automation should reduce cognitive load rather than create another system that needs babysitting.
|
||||||
|
|
||||||
|
Readable code and predictable behavior are more important than clever abstractions.
|
||||||
57
src/content/projects/homelab.md
Normal file
57
src/content/projects/homelab.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
title: "Homelab Infrastructure"
|
||||||
|
description: "Self-hosted infrastructure for experimentation, automation, learning and running services."
|
||||||
|
status: "RUNNING"
|
||||||
|
stack:
|
||||||
|
- Proxmox
|
||||||
|
- ZFS
|
||||||
|
- Docker
|
||||||
|
- Zabbix
|
||||||
|
- Linux
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
My homelab is a constantly evolving infrastructure used for experimentation, self-hosting, automation and learning.
|
||||||
|
|
||||||
|
The goal is not to reproduce enterprise infrastructure for the sake of it.
|
||||||
|
|
||||||
|
The goal is to understand how the pieces actually work.
|
||||||
|
|
||||||
|
## Compute
|
||||||
|
|
||||||
|
The main virtualization host runs Proxmox VE.
|
||||||
|
|
||||||
|
Virtual machines and containers are used for isolated services, testing environments and infrastructure experiments.
|
||||||
|
|
||||||
|
The environment currently includes services such as:
|
||||||
|
|
||||||
|
- monitoring
|
||||||
|
- storage
|
||||||
|
- DNS
|
||||||
|
- cloud storage
|
||||||
|
- Git hosting
|
||||||
|
- task management
|
||||||
|
- infrastructure management
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Storage is built around ZFS and dedicated NAS hardware.
|
||||||
|
|
||||||
|
Backups are treated as a separate system rather than simply another directory on the main server.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Infrastructure is monitored with Zabbix.
|
||||||
|
|
||||||
|
The objective is simple:
|
||||||
|
|
||||||
|
> If something breaks, I want to know before somebody else tells me.
|
||||||
|
|
||||||
|
## What I learned
|
||||||
|
|
||||||
|
A homelab is less about having expensive hardware and more about understanding the dependencies between systems.
|
||||||
|
|
||||||
|
The interesting part starts when something inevitably breaks.
|
||||||
50
src/content/projects/meshcore.md
Normal file
50
src/content/projects/meshcore.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
title: "MeshCore / LoRa"
|
||||||
|
description: "Experiments with decentralized radio networking using ESP32 and LoRa."
|
||||||
|
status: "EXPERIMENTAL"
|
||||||
|
stack:
|
||||||
|
- ESP32
|
||||||
|
- LoRa
|
||||||
|
- MeshCore
|
||||||
|
- 868 MHz
|
||||||
|
- Embedded
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An ongoing experiment with low-power decentralized communication using ESP32 and LoRa.
|
||||||
|
|
||||||
|
The idea is to build infrastructure that does not depend entirely on traditional internet connectivity.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
The project uses ESP32-based boards together with 868 MHz LoRa radio hardware.
|
||||||
|
|
||||||
|
Different nodes have different roles within the network.
|
||||||
|
|
||||||
|
## Network
|
||||||
|
|
||||||
|
MeshCore provides the software layer for exchanging messages between nodes.
|
||||||
|
|
||||||
|
The interesting part is not simply sending packets over LoRa.
|
||||||
|
|
||||||
|
It is designing a network that can continue operating when conventional connectivity is unavailable.
|
||||||
|
|
||||||
|
## Experiments
|
||||||
|
|
||||||
|
Current experiments include:
|
||||||
|
|
||||||
|
- repeater placement
|
||||||
|
- antenna selection
|
||||||
|
- range testing
|
||||||
|
- solar-powered nodes
|
||||||
|
- network topology
|
||||||
|
- MQTT integration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This is still an experimental project.
|
||||||
|
|
||||||
|
The hardware and topology are expected to change as more real-world testing is performed.
|
||||||
41
src/content/projects/network.md
Normal file
41
src/content/projects/network.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
title: "Network Infrastructure"
|
||||||
|
description: "Routing, VPN, DNS and network services connecting different environments."
|
||||||
|
status: "RUNNING"
|
||||||
|
stack:
|
||||||
|
- MikroTik
|
||||||
|
- Linux
|
||||||
|
- VPN
|
||||||
|
- DNS
|
||||||
|
- TCP/IP
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Networking is one of the central parts of my infrastructure.
|
||||||
|
|
||||||
|
The environment includes MikroTik routers, managed switches, VPN endpoints and several internal services.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
The network is segmented around different roles and environments.
|
||||||
|
|
||||||
|
MikroTik handles the routing layer while Linux systems provide additional services.
|
||||||
|
|
||||||
|
## VPN
|
||||||
|
|
||||||
|
Remote access is designed around encrypted tunnels rather than exposing internal services directly to the internet.
|
||||||
|
|
||||||
|
The architecture has evolved several times as requirements changed.
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
Internal DNS provides stable names for infrastructure services and avoids relying on hardcoded addresses.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
I prefer boring networking.
|
||||||
|
|
||||||
|
Predictable routing, explicit firewall rules and infrastructure that can be understood from the configuration are more valuable than unnecessary complexity.
|
||||||
28
src/layouts/Layout.astro
Normal file
28
src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
import "../styles/global.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title = "Semyon — Systems Administrator & Infrastructure Engineer",
|
||||||
|
description = "Infrastructure, networking, automation and things worth understanding.",
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
src/pages/api/spotify.ts
Normal file
104
src/pages/api/spotify.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const clientId = import.meta.env.SPOTIFY_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
const refreshToken = import.meta.env.SPOTIFY_REFRESH_TOKEN;
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${clientId}:${clientSecret}`,
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Spotify token request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
if (!clientId || !clientSecret || !refreshToken) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Spotify is not configured",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.spotify.com/v1/me/player/currently-playing",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Spotify playback request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.item || !data.is_playing) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = data.item;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
playing: true,
|
||||||
|
title: item.name,
|
||||||
|
artist: item.artists
|
||||||
|
.map((artist: { name: string }) => artist.name)
|
||||||
|
.join(", "),
|
||||||
|
album: item.album?.name ?? null,
|
||||||
|
progressMs: data.progress_ms ?? 0,
|
||||||
|
durationMs: item.duration_ms ?? 0,
|
||||||
|
url: item.external_urls?.spotify ?? null,
|
||||||
|
image: item.album?.images?.[0]?.url ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Spotify API error:", error);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Spotify unavailable",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
102
src/pages/api/spotify/callback.ts
Normal file
102
src/pages/api/spotify/callback.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const clientId = import.meta.env.SPOTIFY_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
|
const redirectUri =
|
||||||
|
"http://127.0.0.1:4321/api/spotify/callback";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return new Response(
|
||||||
|
`<h1>Spotify authorization failed</h1><p>${error}</p>`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return new Response(
|
||||||
|
"<h1>Missing authorization code</h1>",
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${clientId}:${clientSecret}`,
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
"Content-Type":
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
`<h1>Spotify token exchange failed</h1><pre>${JSON.stringify(data, null, 2)}</pre>`,
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Spotify OAuth</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Spotify authorization successful</h1>
|
||||||
|
<p>Add this value to your .env:</p>
|
||||||
|
<pre>SPOTIFY_REFRESH_TOKEN=${data.refresh_token ?? ""}</pre>
|
||||||
|
<p>You can close this tab.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/pages/api/steam.ts
Normal file
66
src/pages/api/steam.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const apiKey = import.meta.env.STEAM_API_KEY;
|
||||||
|
const steamId = import.meta.env.STEAM_ID;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
if (!apiKey || !steamId) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Steam is not configured",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/",
|
||||||
|
);
|
||||||
|
|
||||||
|
url.searchParams.set("key", apiKey);
|
||||||
|
url.searchParams.set("steamids", steamId);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Steam API request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const player = data.response?.players?.[0];
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.gameid || !player.gameextrainfo) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
playing: true,
|
||||||
|
game: player.gameextrainfo,
|
||||||
|
appId: player.gameid,
|
||||||
|
url: player.profileurl ?? null,
|
||||||
|
avatar: player.avatarfull ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Steam API error:", error);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Steam unavailable",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
123
src/pages/blog/[...slug].astro
Normal file
123
src/pages/blog/[...slug].astro
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection, render } from "astro:content";
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const posts = await getCollection("blog");
|
||||||
|
|
||||||
|
return posts
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.map((post) => ({
|
||||||
|
params: {
|
||||||
|
slug: post.id,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
post,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { post } = Astro.props;
|
||||||
|
const { Content } = await render(post);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title={`${post.data.title} — Semyon`}
|
||||||
|
description={post.data.description}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<article class="mx-auto max-w-4xl px-6 py-24 lg:px-8 lg:py-32">
|
||||||
|
|
||||||
|
<header class="border-b border-[var(--border)] pb-12">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
BLOG
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
{post.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center gap-4 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
|
||||||
|
<time datetime={post.data.pubDate.toISOString()}>
|
||||||
|
{post.data.pubDate.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<span>·</span>
|
||||||
|
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
mt-14
|
||||||
|
max-w-none
|
||||||
|
|
||||||
|
prose
|
||||||
|
prose-invert
|
||||||
|
|
||||||
|
prose-headings:font-semibold
|
||||||
|
prose-headings:tracking-tight
|
||||||
|
|
||||||
|
prose-h1:text-4xl
|
||||||
|
prose-h2:mt-16
|
||||||
|
prose-h2:text-2xl
|
||||||
|
prose-h3:text-xl
|
||||||
|
|
||||||
|
prose-p:text-[var(--muted)]
|
||||||
|
prose-p:leading-8
|
||||||
|
|
||||||
|
prose-a:text-[var(--accent)]
|
||||||
|
prose-a:no-underline
|
||||||
|
|
||||||
|
prose-strong:text-[var(--text)]
|
||||||
|
|
||||||
|
prose-code:font-mono
|
||||||
|
prose-code:text-[var(--accent)]
|
||||||
|
|
||||||
|
prose-pre:border
|
||||||
|
prose-pre:border-[var(--border)]
|
||||||
|
prose-pre:bg-[var(--surface)]
|
||||||
|
|
||||||
|
prose-blockquote:border-[var(--accent)]
|
||||||
|
prose-blockquote:text-[var(--muted)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-20 border-t border-[var(--border)] pt-8">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
← BACK TO BLOG
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
112
src/pages/blog/index.astro
Normal file
112
src/pages/blog/index.astro
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = (await getCollection("blog"))
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.data.pubDate.valueOf() -
|
||||||
|
a.data.pubDate.valueOf(),
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title="Blog — Semyon"
|
||||||
|
description="Technical notes, experiments and things worth documenting."
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
04 / BLOG
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||||
|
|
||||||
|
<h1 class="text-5xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Technical notes.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="max-w-md text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
Things I learned, broke, investigated or decided
|
||||||
|
were worth writing down.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||||
|
|
||||||
|
<div class="space-y-px border border-[var(--border)] bg-[var(--border)]">
|
||||||
|
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.id}/`}
|
||||||
|
class="group block bg-[var(--surface)] p-6 transition-colors hover:bg-[var(--surface-hover)] lg:p-8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[160px_1fr_auto] lg:items-start">
|
||||||
|
|
||||||
|
<time
|
||||||
|
datetime={post.data.pubDate.toISOString()}
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{post.data.pubDate.toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold tracking-tight transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{post.data.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<span class="font-mono text-[9px] text-[var(--muted)]">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)] transition-colors group-hover:text-[var(--text)]">
|
||||||
|
READ →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
|
@ -1,17 +1,328 @@
|
||||||
---
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import Header from "../components/Header.astro";
|
||||||
|
import Hero from "../components/Hero.astro";
|
||||||
|
import Now from "../components/Now.astro";
|
||||||
|
import Projects from "../components/Projects.astro";
|
||||||
|
import LatestPosts from "../components/LatestPosts.astro";
|
||||||
|
---
|
||||||
|
|
||||||
---
|
<Layout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
<html lang="en">
|
<main>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
<Hero />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<Now />
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
<Projects />
|
||||||
<title>Astro</title>
|
|
||||||
</head>
|
<section
|
||||||
<body>
|
id="lab"
|
||||||
<h1>Astro</h1>
|
class="border-t border-[var(--border)]"
|
||||||
</body>
|
>
|
||||||
</html>
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-mono text-[10px] tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
03 / LAB
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
SELF-HOSTED / 24×7
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-10 lg:grid-cols-[1fr_400px] lg:items-end">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="max-w-3xl text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
My infrastructure
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="max-w-md text-sm leading-7 text-[var(--muted)]">
|
||||||
|
A private infrastructure built for experimentation,
|
||||||
|
automation, learning and occasionally breaking things
|
||||||
|
on purpose.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 grid gap-px border border-[var(--border)] bg-[var(--border)] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
|
<div class="bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]">
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
COMPUTE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-2xl font-semibold">
|
||||||
|
Proxmox
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
VM / LXC / KVM
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 font-mono text-[10px] text-[var(--accent)]">
|
||||||
|
01
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]">
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
STORAGE
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-2xl font-semibold">
|
||||||
|
ZFS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
NAS / BACKUPS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 font-mono text-[10px] text-[var(--accent)]">
|
||||||
|
02
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]">
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
NETWORK
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-2xl font-semibold">
|
||||||
|
MikroTik
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
ROUTING / VPN
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 font-mono text-[10px] text-[var(--accent)]">
|
||||||
|
03
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]">
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
MONITORING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-2xl font-semibold">
|
||||||
|
Zabbix
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
METRICS / ALERTING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 font-mono text-[10px] text-[var(--accent)]">
|
||||||
|
04
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 border border-[var(--border)] bg-[var(--surface)]">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 border-b border-[var(--border)] px-6 py-4 font-mono text-[10px] sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
HOMELAB STATUS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-2 text-[var(--accent)]">
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--accent)]"></span>
|
||||||
|
OPERATIONAL
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-3">
|
||||||
|
|
||||||
|
<div class="border-b border-[var(--border)] p-6 sm:border-b-0 sm:border-r">
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
HOSTS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-3xl font-semibold tracking-tight">
|
||||||
|
06
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-b border-[var(--border)] p-6 sm:border-b-0 sm:border-r">
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
SERVICES
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-3xl font-semibold tracking-tight">
|
||||||
|
30+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
UPTIME
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-3xl font-semibold tracking-tight">
|
||||||
|
99.9%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="about"
|
||||||
|
class="border-t border-[var(--border)]"
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
04 / ABOUT
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 grid gap-16 lg:grid-cols-[1fr_420px]">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h2 class="text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Engineer,
|
||||||
|
<br />
|
||||||
|
not just operator.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="mt-10 max-w-2xl space-y-6 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
I work with infrastructure, Linux, networking,
|
||||||
|
automation and systems that need to keep working
|
||||||
|
when nobody is watching them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
My approach is simple: understand how a system
|
||||||
|
actually works, automate what can be automated,
|
||||||
|
and document what eventually needs to be fixed
|
||||||
|
at three in the morning.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Outside of work, I build things in my homelab,
|
||||||
|
experiment with embedded systems and radio,
|
||||||
|
and occasionally spend an unreasonable amount
|
||||||
|
of time debugging something that should have
|
||||||
|
taken ten minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-[var(--border)] bg-[var(--surface)]">
|
||||||
|
|
||||||
|
<div class="border-b border-[var(--border)] px-5 py-4 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
$ whoami
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5 p-6 font-mono text-xs">
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<span class="text-[var(--muted)]">name</span>
|
||||||
|
<span>Semyon</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<span class="text-[var(--muted)]">role</span>
|
||||||
|
<span>sysadmin</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<span class="text-[var(--muted)]">focus</span>
|
||||||
|
<span>infrastructure</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<span class="text-[var(--muted)]">os</span>
|
||||||
|
<span>Linux</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<span class="text-[var(--muted)]">shell</span>
|
||||||
|
<span>zsh</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6 border-t border-[var(--border)] pt-5">
|
||||||
|
<span class="text-[var(--muted)]">status</span>
|
||||||
|
<span class="text-[var(--accent)]">building</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-[10px] tracking-[0.25em] text-[var(--muted)]">
|
||||||
|
05 / WRITING
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||||
|
Latest thoughts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="font-mono text-xs text-[var(--muted)] transition-colors hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
VIEW ALL →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12">
|
||||||
|
<LatestPosts />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="border-t border-[var(--border)]">
|
||||||
|
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-8 font-mono text-[10px] text-[var(--muted)] sm:flex-row sm:items-center sm:justify-between lg:px-8">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
ALASTOR.LOL
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
BUILT WITH ASTRO · RUNNING ON LINUX
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
© 2026 SEMYON
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</Layout>
|
||||||
131
src/pages/projects/[...slug].astro
Normal file
131
src/pages/projects/[...slug].astro
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection, render } from "astro:content";
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const projects = await getCollection("projects");
|
||||||
|
|
||||||
|
return projects
|
||||||
|
.filter((project) => !project.data.draft)
|
||||||
|
.map((project) => ({
|
||||||
|
params: {
|
||||||
|
slug: project.id,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
project,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = Astro.props;
|
||||||
|
const { Content } = await render(project);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title={`${project.data.title} — Semyon`}
|
||||||
|
description={project.data.description}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<article class="mx-auto max-w-5xl px-6 py-24 lg:px-8 lg:py-32">
|
||||||
|
|
||||||
|
<header class="border-b border-[var(--border)] pb-14">
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 font-mono text-[10px]">
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
PROJECT
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--border)]">
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {project.data.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-7 max-w-4xl text-5xl font-bold tracking-tight sm:text-7xl">
|
||||||
|
{project.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-7 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
|
{project.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{project.data.stack.map((item) => (
|
||||||
|
<span
|
||||||
|
class="border border-[var(--border)] px-3 py-2 font-mono text-[9px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
mt-16
|
||||||
|
max-w-none
|
||||||
|
|
||||||
|
prose
|
||||||
|
prose-invert
|
||||||
|
|
||||||
|
prose-headings:font-semibold
|
||||||
|
prose-headings:tracking-tight
|
||||||
|
|
||||||
|
prose-h2:mt-16
|
||||||
|
prose-h2:text-3xl
|
||||||
|
|
||||||
|
prose-h3:text-xl
|
||||||
|
|
||||||
|
prose-p:text-[var(--muted)]
|
||||||
|
prose-p:leading-8
|
||||||
|
|
||||||
|
prose-li:text-[var(--muted)]
|
||||||
|
|
||||||
|
prose-strong:text-[var(--text)]
|
||||||
|
|
||||||
|
prose-a:text-[var(--accent)]
|
||||||
|
prose-a:no-underline
|
||||||
|
|
||||||
|
prose-code:font-mono
|
||||||
|
prose-code:text-[var(--accent)]
|
||||||
|
|
||||||
|
prose-pre:border
|
||||||
|
prose-pre:border-[var(--border)]
|
||||||
|
prose-pre:bg-[var(--surface)]
|
||||||
|
|
||||||
|
prose-blockquote:border-[var(--accent)]
|
||||||
|
prose-blockquote:text-[var(--muted)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-20 border-t border-[var(--border)] pt-8">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/#projects"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
← BACK TO PROJECTS
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
89
src/pages/projects/index.astro
Normal file
89
src/pages/projects/index.astro
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const projects = (await getCollection("projects"))
|
||||||
|
.filter((project) => !project.data.draft);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title="Projects — Semyon"
|
||||||
|
description="Infrastructure, networking, automation and embedded systems projects."
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
PROJECTS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-5 text-5xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Things I've built.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-6 max-w-xl text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
Infrastructure, experiments and tools built to solve
|
||||||
|
problems or understand how things work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||||
|
|
||||||
|
<div class="grid gap-px border border-[var(--border)] bg-[var(--border)] md:grid-cols-2">
|
||||||
|
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<a
|
||||||
|
href={`/projects/${project.id}/`}
|
||||||
|
class="group bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="flex justify-between font-mono text-[10px]">
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {project.data.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-12 text-2xl font-semibold tracking-tight group-hover:text-[var(--accent)]">
|
||||||
|
{project.data.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{project.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{project.data.stack.map((item) => (
|
||||||
|
<span class="font-mono text-[9px] text-[var(--muted)]">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
119
src/styles/global.css
Normal file
119
src/styles/global.css
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #090909;
|
||||||
|
--surface: #0e0e0e;
|
||||||
|
--surface-hover: #131313;
|
||||||
|
|
||||||
|
--text: #e7e7e7;
|
||||||
|
--muted: #777;
|
||||||
|
--border: #242424;
|
||||||
|
|
||||||
|
--accent: #b8ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
rgba(255, 255, 255, 0.012) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.012) 1px,
|
||||||
|
transparent 1px
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
|
||||||
|
background-size: 48px 48px;
|
||||||
|
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family:
|
||||||
|
"JetBrains Mono",
|
||||||
|
"SFMono-Regular",
|
||||||
|
Consolas,
|
||||||
|
"Liberation Mono",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #292929;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #292929 var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Motion */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue