Compare commits
No commits in common. "main" and "master" have entirely different histories.
41 changed files with 8881 additions and 10 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
4
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal 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
22
AGENTS.md
Normal 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
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
AGENTS.md
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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"]
|
||||||
9
LICENSE
9
LICENSE
|
|
@ -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.
|
|
||||||
43
README.md
43
README.md
|
|
@ -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
16
astro.config.mjs
Normal 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
21
compose.yaml
Normal 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
20
content.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/blog",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
blog,
|
||||||
|
};
|
||||||
5122
package-lock.json
generated
Normal file
5122
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal 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
219
scripts/spotify-token.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import http.server
|
||||||
|
import urllib.parse
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ID = "4442e700b9ee46c08b24f351abbe030c"
|
||||||
|
|
||||||
|
REDIRECT_URI = "http://127.0.0.1:8888/callback"
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
"user-read-currently-playing",
|
||||||
|
"user-read-playback-state",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_URL = "https://accounts.spotify.com/authorize"
|
||||||
|
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
code = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
|
||||||
|
CallbackHandler.code = params.get("code", [None])[0]
|
||||||
|
CallbackHandler.error = params.get("error", [None])[0]
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
if CallbackHandler.code:
|
||||||
|
message = """
|
||||||
|
<h1>Spotify authorization received.</h1>
|
||||||
|
<p>You can close this tab.</p>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
message = """
|
||||||
|
<h1>Spotify authorization failed.</h1>
|
||||||
|
<p>Check the terminal for details.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.wfile.write(
|
||||||
|
f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Spotify OAuth</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{message}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_secret():
|
||||||
|
secret = input("SPOTIFY_CLIENT_SECRET: ").strip()
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
raise SystemExit("Client secret cannot be empty.")
|
||||||
|
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorization_url():
|
||||||
|
params = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"scope": " ".join(SCOPES),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{AUTH_URL}?{params}"
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_token(client_secret, code):
|
||||||
|
credentials = f"{CLIENT_ID}:{client_secret}"
|
||||||
|
|
||||||
|
encoded_credentials = base64.b64encode(
|
||||||
|
credentials.encode("utf-8")
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {encoded_credentials}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print()
|
||||||
|
print("Spotify OAuth token generator")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
client_secret = get_client_secret()
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(
|
||||||
|
("127.0.0.1", 8888),
|
||||||
|
CallbackHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
authorization_url = build_authorization_url()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Opening Spotify authorization page...")
|
||||||
|
print()
|
||||||
|
print(authorization_url)
|
||||||
|
print()
|
||||||
|
|
||||||
|
webbrowser.open(authorization_url)
|
||||||
|
|
||||||
|
print("Waiting for Spotify callback...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while CallbackHandler.code is None and CallbackHandler.error is None:
|
||||||
|
server.handle_request()
|
||||||
|
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
if CallbackHandler.error:
|
||||||
|
print("Spotify authorization failed:")
|
||||||
|
print(CallbackHandler.error)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
code = CallbackHandler.code
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise SystemExit("No authorization code received.")
|
||||||
|
|
||||||
|
print("Authorization code received.")
|
||||||
|
print("Exchanging code for tokens...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
response = exchange_code_for_token(client_secret, code)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
print("=" * 60)
|
||||||
|
print("SPOTIFY TOKEN REQUEST FAILED")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"HTTP status: {response.status_code}")
|
||||||
|
print()
|
||||||
|
print("Spotify response:")
|
||||||
|
print(response.text)
|
||||||
|
print()
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
print("Spotify returned invalid JSON:")
|
||||||
|
print(response.text)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
refresh_token = data.get("refresh_token")
|
||||||
|
access_token = data.get("access_token")
|
||||||
|
expires_in = data.get("expires_in")
|
||||||
|
scope = data.get("scope")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("SPOTIFY TOKENS")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
print("SPOTIFY_REFRESH_TOKEN:")
|
||||||
|
print(refresh_token)
|
||||||
|
else:
|
||||||
|
print("WARNING: Spotify did not return a refresh_token.")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
print("Access token received: YES")
|
||||||
|
else:
|
||||||
|
print("Access token received: NO")
|
||||||
|
|
||||||
|
print(f"Expires in: {expires_in} seconds")
|
||||||
|
print(f"Granted scopes: {scope}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("IMPORTANT")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print("Store the refresh token in your .env file.")
|
||||||
|
print("Do not commit it to Git.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
206
src/components/Header.astro
Normal file
206
src/components/Header.astro
Normal 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
245
src/components/Hero.astro
Normal 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>
|
||||||
|
./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>
|
||||||
|
|
||||||
90
src/components/LatestPosts.astro
Normal file
90
src/components/LatestPosts.astro
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
---
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = (await getCollection("blog"))
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.data.pubDate.valueOf() -
|
||||||
|
a.data.pubDate.valueOf(),
|
||||||
|
)
|
||||||
|
.slice(0, 3);
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
04 / NOTES
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||||
|
Latest notes
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
VIEW ALL NOTES →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-16 space-y-px border border-[var(--border)] bg-[var(--border)]">
|
||||||
|
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.id}/`}
|
||||||
|
class="group block bg-[var(--surface)] p-6 transition-colors hover:bg-[var(--surface-hover)] lg:p-8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid gap-5 md:grid-cols-[150px_1fr_auto] md:items-center">
|
||||||
|
|
||||||
|
<time
|
||||||
|
datetime={post.data.pubDate.toISOString()}
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{post.data.pubDate.toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{post.data.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
READ →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
401
src/components/Now.astro
Normal file
401
src/components/Now.astro
Normal 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>
|
||||||
43
src/components/NowPlaying.astro
Normal file
43
src/components/NowPlaying.astro
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<section class="border-y border-[var(--border)]">
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-8 lg:px-8">
|
||||||
|
<div class="grid gap-8 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
Now
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-sm">
|
||||||
|
Current state of the machine.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
♪ Listening
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
Venjent — U R Gonna Move
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 font-mono text-xs text-[var(--muted)]">
|
||||||
|
Spotify
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)]">
|
||||||
|
🎮 Playing
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
Factorio
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 font-mono text-xs text-[var(--muted)]">
|
||||||
|
Steam
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
62
src/components/ProjectCard.astro
Normal file
62
src/components/ProjectCard.astro
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
stack: string[];
|
||||||
|
href: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
stack,
|
||||||
|
href,
|
||||||
|
status,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
class="group block bg-[var(--surface)] transition-colors hover:bg-[var(--surface-hover)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border)] px-5 py-4 font-mono text-[10px]">
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
{number}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 lg:p-7">
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-semibold tracking-tight transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-4 max-w-lg text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{stack.map((item) => (
|
||||||
|
<span
|
||||||
|
class="border border-[var(--border)] px-2 py-1 font-mono text-[9px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 font-mono text-[10px] text-[var(--muted)] transition-colors group-hover:text-[var(--text)]">
|
||||||
|
EXPLORE PROJECT →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
180
src/components/Projects.astro
Normal file
180
src/components/Projects.astro
Normal 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
36
src/content.config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/blog",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: "**/*.{md,mdx}",
|
||||||
|
base: "./src/content/projects",
|
||||||
|
}),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
stack: z.array(z.string()).default([]),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
blog,
|
||||||
|
projects,
|
||||||
|
};
|
||||||
49
src/content/blog/building-this-site.md
Normal file
49
src/content/blog/building-this-site.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
title: "Building This Site"
|
||||||
|
description: "Why I decided to build my own technical corner of the internet."
|
||||||
|
pubDate: 2026-08-09
|
||||||
|
tags:
|
||||||
|
- meta
|
||||||
|
- astro
|
||||||
|
- infrastructure
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building this site
|
||||||
|
|
||||||
|
I've had a domain sitting around for quite a while.
|
||||||
|
|
||||||
|
It was time to actually do something with it.
|
||||||
|
|
||||||
|
This site is going to be a combination of a portfolio, technical blog and a small window into what I'm currently working on.
|
||||||
|
|
||||||
|
The stack is deliberately simple:
|
||||||
|
|
||||||
|
- Astro
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Markdown / MDX
|
||||||
|
|
||||||
|
No giant frontend framework.
|
||||||
|
|
||||||
|
No unnecessary complexity.
|
||||||
|
|
||||||
|
Just a fast static site with a few dynamic pieces where they actually make sense.
|
||||||
|
|
||||||
|
## What I want to write about
|
||||||
|
|
||||||
|
Mostly things I actually work with:
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- networking
|
||||||
|
- infrastructure
|
||||||
|
- homelab
|
||||||
|
- automation
|
||||||
|
- embedded systems
|
||||||
|
- LoRa
|
||||||
|
- self-hosting
|
||||||
|
- debugging things that shouldn't be broken
|
||||||
|
|
||||||
|
And occasionally something completely unrelated.
|
||||||
|
|
||||||
|
That's part of the point.
|
||||||
43
src/content/projects/automation.md
Normal file
43
src/content/projects/automation.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
title: "Infrastructure Automation"
|
||||||
|
description: "Small tools and automation systems designed to eliminate repetitive operational work."
|
||||||
|
status: "ONGOING"
|
||||||
|
stack:
|
||||||
|
- Python
|
||||||
|
- Go
|
||||||
|
- Bash
|
||||||
|
- Linux
|
||||||
|
- Git
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A collection of tools and scripts created to automate infrastructure operations.
|
||||||
|
|
||||||
|
Some are tiny utilities.
|
||||||
|
|
||||||
|
Others eventually grow into actual services.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
The general rule is simple:
|
||||||
|
|
||||||
|
If I have to perform the same operation repeatedly, it should probably become a script.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
The toolbox includes automation written in:
|
||||||
|
|
||||||
|
- Python
|
||||||
|
- Go
|
||||||
|
- Bash
|
||||||
|
|
||||||
|
Depending on the problem, the simplest appropriate tool wins.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Automation should reduce cognitive load rather than create another system that needs babysitting.
|
||||||
|
|
||||||
|
Readable code and predictable behavior are more important than clever abstractions.
|
||||||
57
src/content/projects/homelab.md
Normal file
57
src/content/projects/homelab.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
title: "Homelab Infrastructure"
|
||||||
|
description: "Self-hosted infrastructure for experimentation, automation, learning and running services."
|
||||||
|
status: "RUNNING"
|
||||||
|
stack:
|
||||||
|
- Proxmox
|
||||||
|
- ZFS
|
||||||
|
- Docker
|
||||||
|
- Zabbix
|
||||||
|
- Linux
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
My homelab is a constantly evolving infrastructure used for experimentation, self-hosting, automation and learning.
|
||||||
|
|
||||||
|
The goal is not to reproduce enterprise infrastructure for the sake of it.
|
||||||
|
|
||||||
|
The goal is to understand how the pieces actually work.
|
||||||
|
|
||||||
|
## Compute
|
||||||
|
|
||||||
|
The main virtualization host runs Proxmox VE.
|
||||||
|
|
||||||
|
Virtual machines and containers are used for isolated services, testing environments and infrastructure experiments.
|
||||||
|
|
||||||
|
The environment currently includes services such as:
|
||||||
|
|
||||||
|
- monitoring
|
||||||
|
- storage
|
||||||
|
- DNS
|
||||||
|
- cloud storage
|
||||||
|
- Git hosting
|
||||||
|
- task management
|
||||||
|
- infrastructure management
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Storage is built around ZFS and dedicated NAS hardware.
|
||||||
|
|
||||||
|
Backups are treated as a separate system rather than simply another directory on the main server.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Infrastructure is monitored with Zabbix.
|
||||||
|
|
||||||
|
The objective is simple:
|
||||||
|
|
||||||
|
> If something breaks, I want to know before somebody else tells me.
|
||||||
|
|
||||||
|
## What I learned
|
||||||
|
|
||||||
|
A homelab is less about having expensive hardware and more about understanding the dependencies between systems.
|
||||||
|
|
||||||
|
The interesting part starts when something inevitably breaks.
|
||||||
50
src/content/projects/meshcore.md
Normal file
50
src/content/projects/meshcore.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
title: "MeshCore / LoRa"
|
||||||
|
description: "Experiments with decentralized radio networking using ESP32 and LoRa."
|
||||||
|
status: "EXPERIMENTAL"
|
||||||
|
stack:
|
||||||
|
- ESP32
|
||||||
|
- LoRa
|
||||||
|
- MeshCore
|
||||||
|
- 868 MHz
|
||||||
|
- Embedded
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An ongoing experiment with low-power decentralized communication using ESP32 and LoRa.
|
||||||
|
|
||||||
|
The idea is to build infrastructure that does not depend entirely on traditional internet connectivity.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
The project uses ESP32-based boards together with 868 MHz LoRa radio hardware.
|
||||||
|
|
||||||
|
Different nodes have different roles within the network.
|
||||||
|
|
||||||
|
## Network
|
||||||
|
|
||||||
|
MeshCore provides the software layer for exchanging messages between nodes.
|
||||||
|
|
||||||
|
The interesting part is not simply sending packets over LoRa.
|
||||||
|
|
||||||
|
It is designing a network that can continue operating when conventional connectivity is unavailable.
|
||||||
|
|
||||||
|
## Experiments
|
||||||
|
|
||||||
|
Current experiments include:
|
||||||
|
|
||||||
|
- repeater placement
|
||||||
|
- antenna selection
|
||||||
|
- range testing
|
||||||
|
- solar-powered nodes
|
||||||
|
- network topology
|
||||||
|
- MQTT integration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This is still an experimental project.
|
||||||
|
|
||||||
|
The hardware and topology are expected to change as more real-world testing is performed.
|
||||||
41
src/content/projects/network.md
Normal file
41
src/content/projects/network.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
title: "Network Infrastructure"
|
||||||
|
description: "Routing, VPN, DNS and network services connecting different environments."
|
||||||
|
status: "RUNNING"
|
||||||
|
stack:
|
||||||
|
- MikroTik
|
||||||
|
- Linux
|
||||||
|
- VPN
|
||||||
|
- DNS
|
||||||
|
- TCP/IP
|
||||||
|
featured: true
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Networking is one of the central parts of my infrastructure.
|
||||||
|
|
||||||
|
The environment includes MikroTik routers, managed switches, VPN endpoints and several internal services.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
The network is segmented around different roles and environments.
|
||||||
|
|
||||||
|
MikroTik handles the routing layer while Linux systems provide additional services.
|
||||||
|
|
||||||
|
## VPN
|
||||||
|
|
||||||
|
Remote access is designed around encrypted tunnels rather than exposing internal services directly to the internet.
|
||||||
|
|
||||||
|
The architecture has evolved several times as requirements changed.
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
Internal DNS provides stable names for infrastructure services and avoids relying on hardcoded addresses.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
I prefer boring networking.
|
||||||
|
|
||||||
|
Predictable routing, explicit firewall rules and infrastructure that can be understood from the configuration are more valuable than unnecessary complexity.
|
||||||
28
src/layouts/Layout.astro
Normal file
28
src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
import "../styles/global.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title = "Semyon — Systems Administrator & Infrastructure Engineer",
|
||||||
|
description = "Infrastructure, networking, automation and things worth understanding.",
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
src/pages/api/spotify.ts
Normal file
104
src/pages/api/spotify.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const clientId = import.meta.env.SPOTIFY_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
const refreshToken = import.meta.env.SPOTIFY_REFRESH_TOKEN;
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${clientId}:${clientSecret}`,
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Spotify token request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
if (!clientId || !clientSecret || !refreshToken) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Spotify is not configured",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.spotify.com/v1/me/player/currently-playing",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Spotify playback request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.item || !data.is_playing) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = data.item;
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
playing: true,
|
||||||
|
title: item.name,
|
||||||
|
artist: item.artists
|
||||||
|
.map((artist: { name: string }) => artist.name)
|
||||||
|
.join(", "),
|
||||||
|
album: item.album?.name ?? null,
|
||||||
|
progressMs: data.progress_ms ?? 0,
|
||||||
|
durationMs: item.duration_ms ?? 0,
|
||||||
|
url: item.external_urls?.spotify ?? null,
|
||||||
|
image: item.album?.images?.[0]?.url ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Spotify API error:", error);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Spotify unavailable",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
102
src/pages/api/spotify/callback.ts
Normal file
102
src/pages/api/spotify/callback.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const clientId = import.meta.env.SPOTIFY_CLIENT_ID;
|
||||||
|
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
|
const redirectUri =
|
||||||
|
"http://127.0.0.1:4321/api/spotify/callback";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return new Response(
|
||||||
|
`<h1>Spotify authorization failed</h1><p>${error}</p>`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return new Response(
|
||||||
|
"<h1>Missing authorization code</h1>",
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${clientId}:${clientSecret}`,
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
"Content-Type":
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
`<h1>Spotify token exchange failed</h1><pre>${JSON.stringify(data, null, 2)}</pre>`,
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Spotify OAuth</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Spotify authorization successful</h1>
|
||||||
|
<p>Add this value to your .env:</p>
|
||||||
|
<pre>SPOTIFY_REFRESH_TOKEN=${data.refresh_token ?? ""}</pre>
|
||||||
|
<p>You can close this tab.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/pages/api/steam.ts
Normal file
66
src/pages/api/steam.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const apiKey = import.meta.env.STEAM_API_KEY;
|
||||||
|
const steamId = import.meta.env.STEAM_ID;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
try {
|
||||||
|
if (!apiKey || !steamId) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Steam is not configured",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/",
|
||||||
|
);
|
||||||
|
|
||||||
|
url.searchParams.set("key", apiKey);
|
||||||
|
url.searchParams.set("steamids", steamId);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Steam API request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const player = data.response?.players?.[0];
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.gameid || !player.gameextrainfo) {
|
||||||
|
return Response.json({
|
||||||
|
playing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
playing: true,
|
||||||
|
game: player.gameextrainfo,
|
||||||
|
appId: player.gameid,
|
||||||
|
url: player.profileurl ?? null,
|
||||||
|
avatar: player.avatarfull ?? null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Steam API error:", error);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
playing: false,
|
||||||
|
error: "Steam unavailable",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
123
src/pages/blog/[...slug].astro
Normal file
123
src/pages/blog/[...slug].astro
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection, render } from "astro:content";
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const posts = await getCollection("blog");
|
||||||
|
|
||||||
|
return posts
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.map((post) => ({
|
||||||
|
params: {
|
||||||
|
slug: post.id,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
post,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { post } = Astro.props;
|
||||||
|
const { Content } = await render(post);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title={`${post.data.title} — Semyon`}
|
||||||
|
description={post.data.description}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<article class="mx-auto max-w-4xl px-6 py-24 lg:px-8 lg:py-32">
|
||||||
|
|
||||||
|
<header class="border-b border-[var(--border)] pb-12">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
BLOG
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
{post.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center gap-4 font-mono text-[10px] text-[var(--muted)]">
|
||||||
|
|
||||||
|
<time datetime={post.data.pubDate.toISOString()}>
|
||||||
|
{post.data.pubDate.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<span>·</span>
|
||||||
|
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
mt-14
|
||||||
|
max-w-none
|
||||||
|
|
||||||
|
prose
|
||||||
|
prose-invert
|
||||||
|
|
||||||
|
prose-headings:font-semibold
|
||||||
|
prose-headings:tracking-tight
|
||||||
|
|
||||||
|
prose-h1:text-4xl
|
||||||
|
prose-h2:mt-16
|
||||||
|
prose-h2:text-2xl
|
||||||
|
prose-h3:text-xl
|
||||||
|
|
||||||
|
prose-p:text-[var(--muted)]
|
||||||
|
prose-p:leading-8
|
||||||
|
|
||||||
|
prose-a:text-[var(--accent)]
|
||||||
|
prose-a:no-underline
|
||||||
|
|
||||||
|
prose-strong:text-[var(--text)]
|
||||||
|
|
||||||
|
prose-code:font-mono
|
||||||
|
prose-code:text-[var(--accent)]
|
||||||
|
|
||||||
|
prose-pre:border
|
||||||
|
prose-pre:border-[var(--border)]
|
||||||
|
prose-pre:bg-[var(--surface)]
|
||||||
|
|
||||||
|
prose-blockquote:border-[var(--accent)]
|
||||||
|
prose-blockquote:text-[var(--muted)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-20 border-t border-[var(--border)] pt-8">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/blog"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
← BACK TO BLOG
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
112
src/pages/blog/index.astro
Normal file
112
src/pages/blog/index.astro
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = (await getCollection("blog"))
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.data.pubDate.valueOf() -
|
||||||
|
a.data.pubDate.valueOf(),
|
||||||
|
);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title="Blog — Semyon"
|
||||||
|
description="Technical notes, experiments and things worth documenting."
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
04 / BLOG
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||||
|
|
||||||
|
<h1 class="text-5xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Technical notes.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="max-w-md text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
Things I learned, broke, investigated or decided
|
||||||
|
were worth writing down.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||||
|
|
||||||
|
<div class="space-y-px border border-[var(--border)] bg-[var(--border)]">
|
||||||
|
|
||||||
|
{posts.map((post) => (
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.id}/`}
|
||||||
|
class="group block bg-[var(--surface)] p-6 transition-colors hover:bg-[var(--surface-hover)] lg:p-8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[160px_1fr_auto] lg:items-start">
|
||||||
|
|
||||||
|
<time
|
||||||
|
datetime={post.data.pubDate.toISOString()}
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{post.data.pubDate.toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold tracking-tight transition-colors group-hover:text-[var(--accent)]">
|
||||||
|
{post.data.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{post.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<span class="font-mono text-[9px] text-[var(--muted)]">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] text-[var(--muted)] transition-colors group-hover:text-[var(--text)]">
|
||||||
|
READ →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
328
src/pages/index.astro
Normal file
328
src/pages/index.astro
Normal 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>
|
||||||
131
src/pages/projects/[...slug].astro
Normal file
131
src/pages/projects/[...slug].astro
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection, render } from "astro:content";
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const projects = await getCollection("projects");
|
||||||
|
|
||||||
|
return projects
|
||||||
|
.filter((project) => !project.data.draft)
|
||||||
|
.map((project) => ({
|
||||||
|
params: {
|
||||||
|
slug: project.id,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
project,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project } = Astro.props;
|
||||||
|
const { Content } = await render(project);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title={`${project.data.title} — Semyon`}
|
||||||
|
description={project.data.description}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<article class="mx-auto max-w-5xl px-6 py-24 lg:px-8 lg:py-32">
|
||||||
|
|
||||||
|
<header class="border-b border-[var(--border)] pb-14">
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 font-mono text-[10px]">
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
PROJECT
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--border)]">
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {project.data.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-7 max-w-4xl text-5xl font-bold tracking-tight sm:text-7xl">
|
||||||
|
{project.data.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-7 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
|
||||||
|
{project.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{project.data.stack.map((item) => (
|
||||||
|
<span
|
||||||
|
class="border border-[var(--border)] px-3 py-2 font-mono text-[9px] text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
mt-16
|
||||||
|
max-w-none
|
||||||
|
|
||||||
|
prose
|
||||||
|
prose-invert
|
||||||
|
|
||||||
|
prose-headings:font-semibold
|
||||||
|
prose-headings:tracking-tight
|
||||||
|
|
||||||
|
prose-h2:mt-16
|
||||||
|
prose-h2:text-3xl
|
||||||
|
|
||||||
|
prose-h3:text-xl
|
||||||
|
|
||||||
|
prose-p:text-[var(--muted)]
|
||||||
|
prose-p:leading-8
|
||||||
|
|
||||||
|
prose-li:text-[var(--muted)]
|
||||||
|
|
||||||
|
prose-strong:text-[var(--text)]
|
||||||
|
|
||||||
|
prose-a:text-[var(--accent)]
|
||||||
|
prose-a:no-underline
|
||||||
|
|
||||||
|
prose-code:font-mono
|
||||||
|
prose-code:text-[var(--accent)]
|
||||||
|
|
||||||
|
prose-pre:border
|
||||||
|
prose-pre:border-[var(--border)]
|
||||||
|
prose-pre:bg-[var(--surface)]
|
||||||
|
|
||||||
|
prose-blockquote:border-[var(--accent)]
|
||||||
|
prose-blockquote:text-[var(--muted)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-20 border-t border-[var(--border)] pt-8">
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/#projects"
|
||||||
|
class="font-mono text-[10px] text-[var(--muted)] transition-colors hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
← BACK TO PROJECTS
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
89
src/pages/projects/index.astro
Normal file
89
src/pages/projects/index.astro
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Header from "../../components/Header.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const projects = (await getCollection("projects"))
|
||||||
|
.filter((project) => !project.data.draft);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title="Projects — Semyon"
|
||||||
|
description="Infrastructure, networking, automation and embedded systems projects."
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-28 lg:px-8 lg:py-36">
|
||||||
|
|
||||||
|
<div class="font-mono text-[10px] tracking-widest text-[var(--muted)]">
|
||||||
|
PROJECTS
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-5 text-5xl font-bold tracking-tight sm:text-6xl">
|
||||||
|
Things I've built.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-6 max-w-xl text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
Infrastructure, experiments and tools built to solve
|
||||||
|
problems or understand how things work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-t border-[var(--border)]">
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||||
|
|
||||||
|
<div class="grid gap-px border border-[var(--border)] bg-[var(--border)] md:grid-cols-2">
|
||||||
|
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<a
|
||||||
|
href={`/projects/${project.id}/`}
|
||||||
|
class="group bg-[var(--surface)] p-7 transition-colors hover:bg-[var(--surface-hover)]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="flex justify-between font-mono text-[10px]">
|
||||||
|
|
||||||
|
<span class="text-[var(--muted)]">
|
||||||
|
{String(index + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-[var(--accent)]">
|
||||||
|
● {project.data.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mt-12 text-2xl font-semibold tracking-tight group-hover:text-[var(--accent)]">
|
||||||
|
{project.data.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm leading-relaxed text-[var(--muted)]">
|
||||||
|
{project.data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-2">
|
||||||
|
|
||||||
|
{project.data.stack.map((item) => (
|
||||||
|
<span class="font-mono text-[9px] text-[var(--muted)]">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
88
src/pages/rickroll.astro
Normal file
88
src/pages/rickroll.astro
Normal 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
643
src/styles/global.css
Normal 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
5
tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue