restore portfolio

This commit is contained in:
Semyon 2026-08-10 15:09:14 +03:00
parent 82c21e9479
commit 13a67bbf8d
28 changed files with 7747 additions and 19 deletions

View file

@ -1,5 +1,16 @@
// @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
View 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

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,9 @@
"astro": "astro"
},
"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
View 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()

View 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
View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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,
};

View 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.

View 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.

View 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.

View 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.

View 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
View 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
View 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 },
);
}
};

View 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
View 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 },
);
}
};

View 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
View 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>

View file

@ -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">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
</html>
<main>
<Hero />
<Now />
<Projects />
<section
id="lab"
class="border-t border-[var(--border)]"
>
<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>

View 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>

View 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
View 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;
}
}