Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

41 changed files with 8881 additions and 10 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

22
AGENTS.md Normal file
View file

@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

View file

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

View file

@ -1,2 +1,43 @@
# main-portfolio
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

16
astro.config.mjs Normal file
View file

@ -0,0 +1,16 @@
// @ts-check
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
vite: {
plugins: [tailwindcss()],
},
});

21
compose.yaml Normal file
View file

@ -0,0 +1,21 @@
services:
portfolio:
build:
context: .
dockerfile: Dockerfile
container_name: semyon-portfolio
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
expose:
- "4321"
networks:
- web
networks:
web:
name: portfolio_web

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

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "semyon-portfolio",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^11.1.0",
"@tailwindcss/vite": "^4.3.3",
"astro": "^7.2.0",
"tailwindcss": "^4.3.3"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

9
public/favicon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

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()

206
src/components/Header.astro Normal file
View file

@ -0,0 +1,206 @@
<header class="relative z-50 border-b border-[var(--border)]">
<div
class="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:px-8"
>
<!-- Logo -->
<a
href="/"
class="group flex items-center gap-3 font-mono text-sm font-bold tracking-tight"
>
<span
class="flex h-7 w-7 items-center justify-center border border-[var(--border)] text-[10px] text-[var(--accent)] transition-colors group-hover:border-[var(--accent)]"
>
>
</span>
```
<span>
ALASTOR<span class="text-[var(--accent)]">.</span>LOL
</span>
</a>
<!-- Desktop navigation -->
<nav class="hidden items-center md:flex">
<div class="flex items-center gap-1">
<a
href="/projects"
class="group relative px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<span>PROJECTS</span>
<span
class="absolute bottom-0 left-3 right-3 h-px origin-left scale-x-0 bg-[var(--accent)] transition-transform duration-200 group-hover:scale-x-100"
></span>
</a>
<a
href="/#lab"
class="group relative px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<span>LAB</span>
<span
class="absolute bottom-0 left-3 right-3 h-px origin-left scale-x-0 bg-[var(--accent)] transition-transform duration-200 group-hover:scale-x-100"
></span>
</a>
<a
href="/blog"
class="group relative px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<span>BLOG</span>
<span
class="absolute bottom-0 left-3 right-3 h-px origin-left scale-x-0 bg-[var(--accent)] transition-transform duration-200 group-hover:scale-x-100"
></span>
</a>
<a
href="/#about"
class="group relative px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<span>ABOUT</span>
<span
class="absolute bottom-0 left-3 right-3 h-px origin-left scale-x-0 bg-[var(--accent)] transition-transform duration-200 group-hover:scale-x-100"
></span>
</a>
</div>
<div class="mx-4 h-5 w-px bg-[var(--border)]"></div>
<div class="flex items-center gap-2">
<a
href="https://www.donationalerts.com/r/alastor3301"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center gap-2 border border-[var(--border)] px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--accent)] transition-all duration-200 hover:border-[var(--accent)] hover:bg-[var(--accent)] hover:text-black"
>
<span>SUPPORT</span>
<span
class="transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
>
↗
</span>
</a>
<a
href="https://github.com/Trytonottry"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
class="group flex h-8 w-8 items-center justify-center border border-[var(--border)] font-mono text-[10px] text-[var(--muted)] transition-colors hover:border-[var(--text)] hover:text-[var(--text)]"
> <span>GITHUB</span>
<span class="transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5">
↗
</span>
</a>
</div>
</nav>
<!-- Mobile menu button -->
<button
id="mobile-menu-button"
type="button"
class="flex items-center gap-3 border border-[var(--border)] px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] transition-colors hover:border-[var(--text)] hover:text-[var(--text)] md:hidden"
aria-expanded="false"
aria-controls="mobile-menu"
>
<span id="mobile-menu-label">MENU</span>
<span class="flex flex-col gap-1">
<span
id="menu-line-1"
class="block h-px w-3 bg-current transition-transform duration-200"
></span>
<span
id="menu-line-2"
class="block h-px w-3 bg-current transition-transform duration-200"
></span>
</span>
</button>
</div>
<!-- Mobile navigation -->
<div
id="mobile-menu"
class="hidden border-t border-[var(--border)] bg-[var(--bg)] md:hidden"
>
<nav class="mx-auto max-w-7xl px-6 py-4 lg:px-8">
<div class="grid border border-[var(--border)]">
<a
href="/#projects"
class="border-b border-[var(--border)] px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
01 / PROJECTS
</a>
<a
href="/#lab"
class="border-b border-[var(--border)] px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
02 / LAB
</a>
<a
href="/blog"
class="border-b border-[var(--border)] px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
03 / BLOG
</a>
<a
href="/#about"
class="border-b border-[var(--border)] px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
04 / ABOUT
</a>
<div class="grid grid-cols-2">
<a
href="https://www.donationalerts.com/r/alastor3301"
target="_blank"
rel="noopener noreferrer"
class="border-r border-[var(--border)] px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--accent)] transition-colors hover:bg-[var(--accent)] hover:text-black"
>
SUPPORT ↗
</a>
<a
href="https://github.com/Trytonottry"
target="_blank"
rel="noopener noreferrer"
class="px-4 py-4 font-mono text-xs uppercase tracking-widest text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
GITHUB ↗
</a>
</div>
</div>
</nav>
</div>
```
</header>
<script>
const button = document.querySelector("#mobile-menu-button");
const menu = document.querySelector("#mobile-menu");
const label = document.querySelector("#mobile-menu-label");
const line1 = document.querySelector("#menu-line-1");
const line2 = document.querySelector("#menu-line-2");
button?.addEventListener("click", () => {
const isOpen = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!isOpen));
menu?.classList.toggle("hidden", isOpen);
if (label) {
label.textContent = isOpen ? "MENU" : "CLOSE";
}
if (!isOpen) {
line1?.classList.add("translate-y-[3px]", "rotate-45");
line2?.classList.add("-translate-y-[3px]", "-rotate-45");
} else {
line1?.classList.remove("translate-y-[3px]", "rotate-45");
line2?.classList.remove("-translate-y-[3px]", "-rotate-45");
}
});
</script>

245
src/components/Hero.astro Normal file
View file

@ -0,0 +1,245 @@
<div
class="relative overflow-hidden border-b border-[var(--border)]"
>
<!-- Background grid -->
<div
class="pointer-events-none absolute inset-0 opacity-40"
style="
background-image:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(to bottom, black, transparent);
"
></div>
<!-- Accent glow -->
<div
class="pointer-events-none absolute -right-40 -top-40 h-[500px] w-[500px] rounded-full bg-[var(--accent)] opacity-[0.035] blur-3xl"
></div>
<div
class="relative mx-auto grid max-w-7xl gap-16 px-6 py-24 lg:grid-cols-[minmax(0,1fr)_360px] lg:px-8 lg:py-36"
>
<!-- LEFT -->
<div class="flex flex-col justify-center">
<!-- Status -->
<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-50"
></span>
<span
class="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent)]"
></span>
</span>
SYSTEM ONLINE
<span class="text-[var(--border)]">/</span>
<span>ALASTOR.LOL</span>
</div>
<!-- Main title -->
<h1
class="max-w-5xl text-5xl font-black uppercase leading-[0.88] tracking-[-0.065em] sm:text-7xl lg:text-[6.8rem]"
>
<span class="block">BUILD.</span>
<span class="block text-[var(--muted)]">
BREAK.
</span>
<span class="block">
<span class="text-[var(--accent)]">
UNDERSTAND.
</span>
</span>
<span class="block">REPEAT.</span>
</h1>
<!-- Description -->
<p
class="mt-10 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>
<!-- Actions -->
<div class="mt-10 flex flex-wrap gap-3">
<a
href="#projects"
class="group relative overflow-hidden border border-[var(--text)] px-5 py-3 font-mono text-[11px] uppercase tracking-wider transition-colors duration-200 hover:bg-[var(--text)] hover:text-black"
>
<span class="relative z-10">
VIEW PROJECTS
</span>
<span
class="ml-2 inline-block transition-transform duration-200 group-hover:translate-x-1"
>
→
</span>
</a>
<a
href="/blog"
class="group border border-[var(--border)] px-5 py-3 font-mono text-[11px] uppercase tracking-wider text-[var(--muted)] transition-colors duration-200 hover:border-[var(--text)] hover:text-[var(--text)]"
>
READ BLOG
<span
class="ml-2 inline-block transition-transform duration-200 group-hover:translate-x-1"
>
→
</span>
</a>
</div>
<!-- Small metadata -->
<div
class="mt-14 flex flex-wrap gap-x-6 gap-y-2 font-mono text-[9px] uppercase tracking-widest text-[var(--muted)]"
>
<span>LINUX</span>
<span>NETWORKING</span>
<span>AUTOMATION</span>
<span>HOMELAB</span>
</div>
</div>
<!-- RIGHT -->
<div class="flex items-end">
<div
class="w-full border border-[var(--border)] bg-[var(--surface)] font-mono text-xs shadow-2xl shadow-black/20"
>
<!-- Terminal header -->
<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-2 w-2 rounded-full bg-[var(--accent)]"></span>
<span class="text-[var(--muted)]">
SYSTEM STATUS
</span>
</div>
<a
href="/rickroll"
class="text-[var(--accent)] transition-opacity hover:opacity-60"
aria-label="System status"
>
● ONLINE
</a>
</div>
<div class="p-5">
<!-- Terminal prompt -->
<div class="mb-7 text-[var(--muted)]">
<span class="text-[var(--accent)]">$</span>
&nbsp; ./check-system.sh
</div>
<!-- Systems -->
<div class="space-y-5">
<div class="flex items-center justify-between">
<span class="text-[var(--muted)]">
Linux
</span>
<span class="text-[var(--accent)]">
● OK
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[var(--muted)]">
Infrastructure
</span>
<span class="text-[var(--accent)]">
● OK
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[var(--muted)]">
Networking
</span>
<span class="text-[var(--accent)]">
● OK
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[var(--muted)]">
Automation
</span>
<span class="text-[var(--accent)]">
● OK
</span>
</div>
</div>
<!-- Homelab -->
<div class="mt-8 border-t border-[var(--border)] pt-5">
<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 overflow-hidden bg-[var(--border)]">
<div
class="h-full w-[87%] bg-[var(--accent)]"
></div>
</div>
</div>
<!-- Footer -->
<div
class="mt-7 border-t border-[var(--border)] pt-5 text-[10px] text-[var(--muted)]"
>
<div class="flex justify-between">
<span>HOSTS</span>
<span>06</span>
</div>
<div class="mt-2 flex justify-between">
<span>SERVICES</span>
<span>30+</span>
</div>
<div class="mt-2 flex justify-between">
<span>UPTIME</span>
<span class="text-[var(--accent)]">
99.9%
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom coordinate -->
<div
class="pointer-events-none absolute bottom-5 left-1/2 hidden -translate-x-1/2 font-mono text-[9px] uppercase tracking-[0.3em] text-[var(--muted)] opacity-50 md:block"
>
SCROLL TO EXPLORE
</div>
</div>

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>

401
src/components/Now.astro Normal file
View file

@ -0,0 +1,401 @@
<div class="border-y border-[var(--border)]">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
```
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border)] py-4">
<div class="flex items-center gap-3">
<span class="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--muted)]">
01 / NOW
</span>
<span class="hidden h-px w-8 bg-[var(--border)] sm:block"></span>
<span class="hidden font-mono text-[9px] uppercase tracking-widest text-[var(--muted)] sm:block">
LIVE ACTIVITY
</span>
</div>
<div
id="now-status"
class="flex items-center gap-2 font-mono text-[9px] 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>
<!-- Activity grid -->
<div class="grid md:grid-cols-4">
<!-- Current state -->
<div class="border-b border-[var(--border)] py-7 md:border-b-0 md:pr-8">
<div class="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--muted)]">
CURRENT STATE
</div>
<div class="mt-4 flex items-start gap-3">
<span class="mt-2 h-1 w-1 shrink-0 bg-[var(--accent)]"></span>
<div>
<div class="text-sm leading-6">
Building things,
<br />
breaking things,
<br />
understanding why.
</div>
<div class="mt-4 font-mono text-[9px] uppercase tracking-widest text-[var(--muted)]">
ALASTOR.LOL
</div>
</div>
</div>
</div>
<!-- Spotify -->
<div class="border-b border-[var(--border)] py-7 md:border-b-0 md:border-l md:px-8">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--muted)]">
♪ LISTENING
</span>
<span
id="spotify-indicator"
class="hidden font-mono text-[8px] uppercase tracking-widest text-[var(--accent)]"
>
LIVE
</span>
</div>
<span class="font-mono text-[8px] text-[var(--muted)]">
01
</span>
</div>
<a
id="spotify-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="group mt-5 block"
>
<div class="flex items-start gap-3">
<div
class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center border border-[var(--border)] text-[11px] text-[var(--accent)] transition-colors group-hover:border-[var(--accent)]"
>
♪
</div>
<div class="min-w-0">
<div
id="spotify-title"
class="truncate text-sm font-medium transition-colors group-hover:text-[var(--accent)]"
>
Loading...
</div>
<div
id="spotify-artist"
class="mt-1 truncate text-xs text-[var(--muted)]"
>
Spotify
</div>
</div>
</div>
</a>
<div class="mt-5 flex items-center justify-between border-t border-[var(--border)] pt-3">
<span class="font-mono text-[8px] uppercase tracking-widest text-[var(--muted)]">
SPOTIFY
</span>
<span class="font-mono text-[8px] text-[var(--muted)]">
30s
</span>
</div>
</div>
<!-- Steam -->
<div class="border-b border-[var(--border)] py-7 md:border-b-0 md:border-l md:px-8">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--muted)]">
PLAYING
</span>
<span
id="steam-indicator"
class="hidden font-mono text-[8px] uppercase tracking-widest text-[var(--accent)]"
>
LIVE
</span>
</div>
<span class="font-mono text-[8px] text-[var(--muted)]">
02
</span>
</div>
<a
id="steam-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="group mt-5 block"
>
<div class="flex items-start gap-3">
<div
class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center border border-[var(--border)] text-[10px] text-[var(--accent)] transition-colors group-hover:border-[var(--accent)]"
>
▶
</div>
<div class="min-w-0">
<div
id="steam-game"
class="truncate text-sm font-medium transition-colors group-hover:text-[var(--accent)]"
>
Loading...
</div>
<div class="mt-1 truncate text-xs text-[var(--muted)]">
Steam
</div>
</div>
</div>
</a>
<div class="mt-5 flex items-center justify-between border-t border-[var(--border)] pt-3">
<span class="font-mono text-[8px] uppercase tracking-widest text-[var(--muted)]">
STEAM
</span>
<span class="font-mono text-[8px] text-[var(--muted)]">
60s
</span>
</div>
</div>
<!-- Building -->
<div class="py-7 md:border-l md:pl-8">
<div class="flex items-center justify-between">
<div class="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--muted)]">
BUILDING
</div>
<span class="font-mono text-[8px] text-[var(--muted)]">
03
</span>
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<div class="relative flex h-8 w-8 items-center justify-center border border-[var(--border)]">
<span class="h-1.5 w-1.5 bg-[var(--accent)]"></span>
<span class="absolute inset-1 border border-[var(--border)]"></span>
</div>
<div>
<div class="text-sm font-medium">
alastor.lol
</div>
<div class="mt-1 text-xs text-[var(--muted)]">
This website
</div>
</div>
</div>
</div>
<div class="mt-5 flex items-center justify-between border-t border-[var(--border)] pt-3">
<span class="font-mono text-[8px] uppercase tracking-widest text-[var(--muted)]">
ASTRO · TS
</span>
<span class="font-mono text-[8px] text-[var(--accent)]">
ACTIVE
</span>
</div>
</div>
</div>
<!-- Bottom telemetry -->
<div class="flex flex-col gap-2 border-t border-[var(--border)] py-3 font-mono text-[8px] uppercase tracking-widest text-[var(--muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<span>ACTIVITY FEED</span>
<span class="h-px w-6 bg-[var(--border)]"></span>
<span id="last-update">WAITING FOR SYNC</span>
</div>
<div>
SPOTIFY / STEAM / LOCAL
</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");
const lastUpdate = document.querySelector("#last-update");
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)]");
}
}
function updateTimestamp() {
const now = new Date();
lastUpdate!.textContent =
"LAST SYNC " +
now.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
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();
updateTimestamp();
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();
updateTimestamp();
} catch {
spotifyTitle!.textContent = "Unavailable";
spotifyArtist!.textContent = "Spotify";
spotifyLink?.removeAttribute("href");
spotifyIndicator?.classList.remove("inline");
spotifyIndicator?.classList.add("hidden");
updateOverallStatus();
updateTimestamp();
}
}
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();
updateTimestamp();
return;
}
steamGame!.textContent = data.game;
if (steamLink && data.url) {
steamLink.href = data.url;
}
steamIndicator?.classList.remove("hidden");
steamIndicator?.classList.add("inline");
updateOverallStatus();
updateTimestamp();
} catch {
steamGame!.textContent = "Unavailable";
steamLink?.removeAttribute("href");
steamIndicator?.classList.remove("inline");
steamIndicator?.classList.add("hidden");
updateOverallStatus();
updateTimestamp();
}
}
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,180 @@
---
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 lg:py-36">
<!-- Header -->
<div class="grid gap-10 lg:grid-cols-[1fr_360px] lg:items-end">
<div>
<div class="flex items-center gap-3 font-mono text-[10px] tracking-[0.25em] text-[var(--muted)]">
<span class="text-[var(--accent)]">02</span>
<span>/</span>
<span>PROJECTS</span>
</div>
<h2 class="mt-6 max-w-3xl text-5xl font-bold leading-[0.95] tracking-[-0.045em] sm:text-6xl lg:text-7xl">
Things I
<span class="text-[var(--muted)]"> build.</span>
</h2>
</div>
<div class="lg:pb-1">
<p class="text-sm leading-7 text-[var(--muted)]">
Infrastructure, automation, networking and experiments.
Some are production systems. Some are glorified learning
exercises.
</p>
</div>
</div>
<!-- Project grid -->
<div class="mt-20 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 relative min-h-[320px] overflow-hidden bg-[var(--bg)] transition-colors duration-300 hover:bg-[var(--surface)]"
>
<!-- Background number -->
<div
class="pointer-events-none absolute -right-3 -top-8 select-none font-mono text-[9rem] font-bold leading-none tracking-[-0.08em] text-[var(--surface-hover)] transition-colors duration-300 group-hover:text-[var(--border)]"
>
{String(index + 1).padStart(2, "0")}
</div>
<div class="relative flex h-full flex-col justify-between p-7 sm:p-9">
<!-- Top -->
<div>
<div class="flex items-center justify-between">
<div class="font-mono text-[10px] tracking-[0.2em] text-[var(--muted)]">
PROJECT {String(index + 1).padStart(2, "0")}
</div>
<div class="flex items-center gap-2 font-mono text-[9px] uppercase tracking-[0.15em]">
<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>
<!-- Title -->
<div class="mt-12 max-w-xl">
<h3 class="text-3xl font-semibold leading-tight tracking-[-0.025em] transition-colors duration-200 group-hover:text-[var(--accent)] sm:text-4xl">
{project.data.title}
</h3>
<p class="mt-5 max-w-lg text-sm leading-7 text-[var(--muted)]">
{project.data.description}
</p>
</div>
</div>
<!-- Bottom -->
<div class="mt-12">
<div class="flex flex-wrap gap-1.5">
{
project.data.stack.map((item) => (
<span
class="border border-[var(--border)] px-2.5 py-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--muted)] transition-colors duration-200 group-hover:border-[var(--muted)] group-hover:text-[var(--text)]"
>
{item}
</span>
))
}
</div>
<div class="mt-7 flex items-center justify-between border-t border-[var(--border)] pt-4">
<span class="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--muted)]">
EXPLORE PROJECT
</span>
<span class="font-mono text-sm text-[var(--muted)] transition-all duration-200 group-hover:translate-x-1 group-hover:text-[var(--accent)]">
→
</span>
</div>
</div>
</div>
</a>
))
}
</div>
<!-- Footer -->
<div class="mt-7 flex flex-col gap-4 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--muted)] sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--accent)]"></span>
<span>{projects.length} PROJECTS</span>
</div>
<a
href="/projects"
class="group flex items-center gap-2 transition-colors hover:text-[var(--accent)]"
>
VIEW ALL PROJECTS
<span class="transition-transform duration-200 group-hover:translate-x-1">
→
</span>
</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>

328
src/pages/index.astro Normal file
View file

@ -0,0 +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 />
<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>

88
src/pages/rickroll.astro Normal file
View file

@ -0,0 +1,88 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout>
<main
class="relative flex min-h-screen items-center justify-center overflow-hidden bg-black px-6"
>
<div
class="absolute inset-0 opacity-20"
style="
background-image:
linear-gradient(rgba(184,255,0,.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(184,255,0,.08) 1px, transparent 1px);
background-size: 40px 40px;
"
></div>
<div class="relative z-10 w-full max-w-5xl">
<div
class="overflow-hidden border border-[var(--border)] bg-[var(--surface)]"
>
<div
class="flex items-center justify-between border-b border-[var(--border)] px-4 py-3 font-mono text-[10px] uppercase tracking-widest"
>
<span class="text-[var(--muted)]">
SECURITY INCIDENT
</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>
CRITICAL
</span>
</div>
<div class="p-6 sm:p-10 lg:p-14">
<div
class="mb-8 font-mono text-[10px] uppercase tracking-[0.3em] text-[var(--muted)]"
>
ERR / 0x1337 / ALASTOR.LOL
</div>
<h1
class="max-w-4xl text-5xl font-black uppercase leading-[0.9] tracking-[-0.06em] sm:text-7xl lg:text-8xl"
>
Внимание!
<br />
<span class="text-[var(--accent)]">
Вас зарикроллили!
</span>
</h1>
<div class="mt-10 aspect-video w-full overflow-hidden border border-[var(--border)] bg-black">
<iframe
class="h-full w-full"
src="https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0"
title="Never Gonna Give You Up"
frameborder="0"
allow="autoplay; encrypted-media"
allowfullscreen
></iframe>
</div>
<div
class="mt-8 flex flex-col gap-4 border-t border-[var(--border)] pt-6 font-mono text-xs text-[var(--muted)] sm:flex-row sm:items-center sm:justify-between"
>
<span>
STATUS: YOU SHOULD HAVE KNOWN BETTER
</span>
<a
href="/"
class="text-[var(--text)] transition-colors hover:text-[var(--accent)]"
>
← RETURN TO SAFETY
</a>
</div>
</div>
</div>
<div
class="mt-4 text-center font-mono text-[9px] uppercase tracking-[0.25em] text-[var(--muted)]"
>
No systems were harmed during this incident.
</div>
</div>
</main>
</Layout>

643
src/styles/global.css Normal file
View file

@ -0,0 +1,643 @@
@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: var(--bg);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
::selection {
background: var(--accent);
color: #000;
}
a {
color: inherit;
text-decoration: none;
}
.font-mono {
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
}
/* ─────────────────────────────────────────
Markdown / Article typography
───────────────────────────────────────── */
.prose {
max-width: 760px;
color: var(--text);
font-size: 0.95rem;
line-height: 1.85;
}
.prose > *:first-child {
margin-top: 0;
}
.prose > *:last-child {
margin-bottom: 0;
}
/* Headings */
.prose h1 {
margin-top: 3rem;
margin-bottom: 1.25rem;
font-size: 2.25rem;
line-height: 1.15;
font-weight: 700;
letter-spacing: -0.035em;
}
.prose h2 {
margin-top: 3rem;
margin-bottom: 1rem;
font-size: 1.65rem;
line-height: 1.25;
font-weight: 650;
letter-spacing: -0.025em;
}
.prose h3 {
margin-top: 2.25rem;
margin-bottom: 0.75rem;
font-size: 1.2rem;
line-height: 1.35;
font-weight: 600;
}
.prose h4 {
margin-top: 1.75rem;
margin-bottom: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
/* Paragraphs */
.prose p {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
/* Links */
.prose a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(
in srgb,
var(--accent) 40%,
transparent
);
text-underline-offset: 3px;
transition:
color 150ms ease,
text-decoration-color 150ms ease;
}
.prose a:hover {
color: var(--text);
text-decoration-color: var(--text);
}
/* Lists */
.prose ul,
.prose ol {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-top: 0.45rem;
padding-left: 0.25rem;
}
.prose li::marker {
color: var(--accent);
}
.prose li > ul,
.prose li > ol {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
/* Blockquotes */
.prose blockquote {
margin: 2rem 0;
padding: 0.75rem 0 0.75rem 1.25rem;
border-left: 2px solid var(--accent);
color: var(--muted);
font-style: italic;
}
.prose blockquote p {
margin: 0;
}
/* Inline code */
.prose :not(pre) > code {
padding: 0.15rem 0.4rem;
border: 1px solid var(--border);
background: var(--surface);
color: var(--accent);
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
monospace;
font-size: 0.82em;
}
/* Code blocks */
.prose pre {
position: relative;
margin: 2rem 0;
padding: 1.25rem 1.5rem;
overflow-x: auto;
border: 1px solid var(--border);
background: #070707;
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
monospace;
font-size: 0.8rem;
line-height: 1.7;
scrollbar-width: thin;
}
.prose pre code {
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font-size: inherit;
}
/* Horizontal rule */
.prose hr {
margin: 3rem 0;
border: 0;
border-top: 1px solid var(--border);
}
/* Images */
.prose img {
display: block;
width: 100%;
height: auto;
margin: 2rem 0;
border: 1px solid var(--border);
}
/* Tables */
.prose table {
width: 100%;
margin: 2rem 0;
border-collapse: collapse;
font-size: 0.85rem;
}
.prose th,
.prose td {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
text-align: left;
}
.prose th {
background: var(--surface);
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
monospace;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.prose td {
color: var(--muted);
}
/* Strong / emphasis */
.prose strong {
color: var(--text);
font-weight: 650;
}
.prose em {
color: var(--muted);
}
/* Definition-style spacing */
.prose dl {
margin: 1.5rem 0;
}
.prose dt {
margin-top: 1rem;
font-weight: 600;
}
.prose dd {
margin-left: 1.5rem;
color: var(--muted);
}
/* Mobile */
@media (max-width: 640px) {
.prose {
font-size: 0.9rem;
line-height: 1.8;
}
.prose h1 {
font-size: 1.9rem;
}
.prose h2 {
font-size: 1.4rem;
}
.prose h3 {
font-size: 1.1rem;
}
.prose pre {
margin-left: -0.5rem;
margin-right: -0.5rem;
padding: 1rem;
}
.prose table {
display: block;
overflow-x: auto;
}
}
/* ─────────────────────────────────────────────
Markdown / Article typography
───────────────────────────────────────────── */
.markdown {
max-width: none;
color: var(--text);
font-size: 0.95rem;
line-height: 1.8;
}
.markdown > *:first-child {
margin-top: 0;
}
.markdown > *:last-child {
margin-bottom: 0;
}
/* Headings */
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4 {
color: var(--text);
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
}
.markdown h1 {
margin-top: 3rem;
margin-bottom: 1.25rem;
font-size: 2.25rem;
}
.markdown h2 {
margin-top: 3rem;
margin-bottom: 1rem;
padding-bottom: 0.65rem;
border-bottom: 1px solid var(--border);
font-size: 1.65rem;
}
.markdown h3 {
margin-top: 2.25rem;
margin-bottom: 0.75rem;
font-size: 1.25rem;
}
.markdown h4 {
margin-top: 1.75rem;
margin-bottom: 0.65rem;
font-size: 1rem;
}
/* Paragraphs */
.markdown p {
margin: 1.15rem 0;
color: #c4c4c4;
}
/* Strong / emphasis */
.markdown strong {
color: var(--text);
font-weight: 650;
}
.markdown em {
color: #d0d0d0;
}
/* Links */
.markdown a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(
in srgb,
var(--accent) 35%,
transparent
);
text-underline-offset: 3px;
transition:
text-decoration-color 150ms ease,
opacity 150ms ease;
}
.markdown a:hover {
text-decoration-color: var(--accent);
}
/* Lists */
.markdown ul,
.markdown ol {
margin: 1.25rem 0;
padding-left: 1.5rem;
color: #c4c4c4;
}
.markdown ul {
list-style-type: disc;
}
.markdown ol {
list-style-type: decimal;
}
.markdown li {
margin: 0.45rem 0;
padding-left: 0.25rem;
}
.markdown li::marker {
color: var(--muted);
}
.markdown li > ul,
.markdown li > ol {
margin-top: 0.45rem;
margin-bottom: 0.45rem;
}
/* Inline code */
.markdown :not(pre) > code {
border: 1px solid var(--border);
background: var(--surface);
padding: 0.15rem 0.4rem;
color: var(--accent);
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
font-size: 0.82em;
}
/* Code blocks */
.markdown pre {
position: relative;
overflow-x: auto;
margin: 1.75rem 0;
border: 1px solid var(--border);
background: #050505;
padding: 1.25rem 1.35rem;
color: #d8d8d8;
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
font-size: 0.8rem;
line-height: 1.7;
}
.markdown pre code {
font-family: inherit;
font-size: inherit;
}
/* Blockquotes */
.markdown blockquote {
margin: 1.75rem 0;
border-left: 2px solid var(--accent);
background: var(--surface);
padding: 1rem 1.25rem;
color: #bdbdbd;
}
.markdown blockquote p {
margin: 0;
}
/* Horizontal rule */
.markdown hr {
margin: 3rem 0;
border: 0;
border-top: 1px solid var(--border);
}
/* Tables */
.markdown table {
width: 100%;
margin: 1.75rem 0;
border-collapse: collapse;
font-size: 0.85rem;
}
.markdown thead {
background: var(--surface);
}
.markdown th,
.markdown td {
border: 1px solid var(--border);
padding: 0.7rem 0.8rem;
text-align: left;
}
.markdown th {
color: var(--text);
font-family:
"JetBrains Mono",
"SFMono-Regular",
Consolas,
monospace;
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.markdown td {
color: #bdbdbd;
}
/* Images */
.markdown img {
display: block;
max-width: 100%;
height: auto;
margin: 2rem auto;
border: 1px solid var(--border);
}
/* Small screens */
@media (max-width: 640px) {
.markdown {
font-size: 0.9rem;
}
.markdown h1 {
font-size: 1.8rem;
}
.markdown h2 {
font-size: 1.4rem;
}
.markdown pre {
margin-left: -1rem;
margin-right: -1rem;
border-left: 0;
border-right: 0;
padding: 1rem;
border-radius: 0;
}
.markdown table {
display: block;
overflow-x: auto;
}
}

5
tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}