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