Compare commits

15 Commits

36 changed files with 1744 additions and 476 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Install, build, and upload your site
uses: withastro/action@v2
with:
node-version: 20
node-version: 22
deploy:
needs: build
+880 -370
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+3
View File
@@ -113,6 +113,9 @@ projects.sort((a, b) => a.data.name.localeCompare(b.data.name));
gap: 0.25rem;
padding: 0.25rem;
height: min-content;
content-visibility: auto;
contain-intrinsic-size: 96px;
&:focus-within {
background-color: #548e9b;
}
+2
View File
@@ -79,6 +79,8 @@
padding: 0.5rem;
gap: 0.25rem;
border-radius: 0.25rem;
content-visibility: auto;
contain-intrinsic-size: 88px;
span {
color: var(--text-secondary);
-1
View File
@@ -135,7 +135,6 @@
for (let i = 0; i < chars.length; i++) {
const span = document.createElement("span");
span.setAttribute("data-number", chars[i]);
span.setAttribute("aria-label", chars[i]);
span.style.animationDelay = `${i * 0.05}s`;
viewCounter.appendChild(span);
}
+10
View File
@@ -1,4 +1,5 @@
import {z, defineCollection} from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
@@ -20,8 +21,17 @@ const projects = defineCollection({
technologies: z.array(z.string()).optional(),
}),
});
const writing = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
publishDate: z.date({coerce: true}),
category: z.string(),
}),
});
export const collections = {
posts,
projects,
writing,
};
+89
View File
@@ -0,0 +1,89 @@
---
title: "A homelab adventure"
publishDate: "18 Nov 2025"
description: "\"Fine, I'll do it myself.\""
tags: ["software", "homelab"]
---
## The bare minimum
When I think back, it all started with a friend asking if I can help them learn proxmox. I didn't know anything about
proxmox, so I told them sorry, but it somehow stuck with me. Few months later I realized I'm not really using my mini
gmktec pc for much. I initially bought it for playing league of legends, since I'm daily driving linux and it was about
time riot decided to require installing a rootkit on your specifically windows machine to play their game. I found
myself not playing much, so it went. I grabbed a thumb drive, burned proxmox iso on it and gave it a go.
After the first impressions of the web interface, I kew what should be the first service to run on it. Pihole. I've
been using adguard dns on my router before, but I liked the idea of hosting dns server locally on my own network. It
even comes with an option to define local dns records, so I can use actual domain names instead of ip addresses to
access my other services.
Very second thing I installed was homeassistant. I actually tested using it before, hosted on my main PC in a docker
container, but it lacked plugin support and some things I wanted to do were just not possible without them. Thus, my
first actual VM was born. The only smart device I had at the time was a lightbulb in my room, that I was using a mobile
app to control. Luckily the bulb fully supported homeassistant, so I just hooked it right up, and now I can turn it off
if I forget to do it before going out.
## The files
So, now that I had a dedicated machine running proxmox I might as well host my music and movies on it, right? So I
created jellyfin CT. When doing so, I made my first mistake. "I need space for my media, so I'll just create a 1TB mount
point. What could go wrong?". Well, turns out that a lot can go wrong when you later decide to add more services that
concern media, like navidrome for music streaming. And now I need to duplicate my music files, because there is no easy
way to share the same local storage between two containers that is not absolutely cursed. So I pivot straight into
another mistake: doing the exact same thing, but with samba server. Now I have a local mount point on that smb share and
at least I can share my media between multiple containers and even manage the files myself from my pc. While it's still
not great, it's an upgrade. At least I don't have to duplicate my files anymore. With that disaster temporarily out of
the way it's time to add some actually useful stuff.
## The useful stuff
I got Homarr as a dashboard for quick access to my other services. While it's working fine for me, I'm considering
writing my own dashboard in the future to have ultimate control over it. Next were Prowlarr and deluge client, deluge of
course having mounted my smb share as download location. I also added n8n instance, mainly to mess around with, but
it ended up being pretty simple way of automating a few things, including discord notifications for articles from this
very blog! Next up, freshRSS for centralizing my news and youtube subscriptions in one place.
Having all of that is sure nice and all, but what if I want to go somewhere and still be able to watch me some anime
or listen to my music on the go? Sadly this part kinda sucks. My asus router I got way before I did any thinking about
homelabbing has vpn built in, so I just set that up and am now using wireguard to connect directly to my home network.
For now, it works. I plan on building my own router in the future, so I can have more control, because asus stock
firmware absolutely sucks and requires consent to send data to their servers to enable any functionality that might be
remotely useful.
## Gaming
For the longest time I wanted to get my forever minecraft world off the internet and host it in my house, so I can still
access it if the internet goes down. I simply installed pterodactyl wings CT and added it to my existing pterodactyl
panel (which after today's cloudflare outage I now plan to also bring onto my homelab). Now my world, testing server and
a proxy are running on my mini pc. I still want my friends to be albe to join, so I repurposed the old vps where the
server was running before and set up nginx and haproxy to forward the traffic to the minecraft proxy running at home.
The old vps happens to have 500mbps connection and my home connection is 1tbps, so even if the nightmare scenario
happens and the proxy gets ddosed, I will still enough wiggle room to keep my home connection usable.
## The storage situation
Turns out having just 2 nvme slots with 1tb and 4tb drives respectively is not enough if you want to download all media
you might ever want to watch. Free space was running low and it was running out fast. I decided to make an actual
investment into my home setup and bought a 4-bay NAS from terra master. For the drives, I got 3 12tb WD purples and
set them up in raid5, giving me 24tb of usable space with basic redundancy. I could now move all my media files off the
mini pc and start using that space for more things. I still use that 4tb nvme as a file server for some of the
containers that need faster storage, but most of the files now live on the NAS.
As I got a separate device for storage, I've set it up so proxmox now backups all the VMs and CTs directly onto the NAS.
Pterodactyl wings on my proxmox node are also now configured to save backups on the NAS. I also had borg backup set up
for my main pc, but I just changed my daily driver distro from ubuntu to cachyos and I haven't found time to set up borg
on it yet, but hopefully I will manage to get enough time to do it before any major failure happens.
## Smart home
I mentioned that while setting up homeassistant I only had a smart bulb to play with. Since then, I've been accumulating
IoT devices. My room now has a smart extension cord that tracks how much power my pc is using, I switched all lightbulbs
in the house to ones I can control with homeassistant and I made some automations involving the wireless switches that
communicate over zigbee. I also own a roomba, which I can control with homeassistant, but it's been getting worse and
worse since I disconnected it from the internet, so when it finally dies I plan on replacing it with a vacuum that I can
flash with custom firmware and fully control locally. Some miscellaneous things I also have zigbee thermometers per
room, printer and tv. I don't trust any of the electronic locks, so none of that will be ever happening.
## Where am I now?
In a pretty good place, I feel. Things are running smoothly, I have backups and redundancy in place. Some basic
automation and more freedom with what I can do in my house. Of course, there's still things to do. Like finally sorting
out that leftover local storage that was left on the original samba server CT and migrating all the files from it onto
properly mounted directory that can be accessed by multiple containers without going though network stack. I learned
quite a bit from this whole endeavor and I know I will learn more as I improve and expand my homelab. I'll be sure to
share any major updates to the story here, so check back, or even subscribe to the RSS feed linked to this site!
+1 -1
View File
@@ -1,7 +1,7 @@
---
category: "minecraft"
name: "Censura"
description: "Advanced censorship plugin for bukkit minecraft servers."
description: "Advanced censorship plugin for bukkit minecraft servers. Filters all possible in gameplay text inputs."
image: "/assets/projects/censura.webp"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "minecraft"
name: "CommandWhitelist"
description: "Minecraft bukkit plugin that allows to control precisely what commands players can see and use."
description: "Minecraft bukkit plugin that allows to control precisely what commands players can see and use. Created as
a band-aid solution for bad plugins that do not add permission association in their commands."
image: "/assets/projects/cw.png"
links: [
{
+1 -1
View File
@@ -1,7 +1,7 @@
---
category: "websites"
name: "Disciple of Land"
description: "FFXIV gathering node timers."
description: "FFXIV gathering node timers. Tracks which gathering nodes of time limited availability are currently available and where to teleport."
image: "/assets/projects/dol.png"
links: [
{
+5 -1
View File
@@ -1,13 +1,17 @@
---
category: "websites"
name: "Dumb Forks Generator"
description: "PHP name generator for dumb minecraft server software fork names."
description: "PHP generator for dumb minecraft server software fork names. Inspired by ridiculous names of various minecraft server software forks."
image: "/assets/projects/dumbforkgenerator.png"
links: [
{
text: "Website",
url: "https://dumbforks.yht.one"
},
{
text: "Source",
url: "https://github.com/YouHaveTrouble/dumb-fork-name-generator"
},
]
technologies:
- "php"
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "minecraft"
name: "Enchantio"
description: "Minecraft paper plugin that adds new enchantments that are in line with vanilla Minecraft feel."
description: "Minecraft paper plugin that adds new enchantments that are in line with vanilla Minecraft feel. One of the
first paper plugins that uses native enchants instead of hacking them in via custom data."
image: "/assets/projects/enchantio.png"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "websites"
name: "Guild Master"
description: "Adventurer's guild management browser game."
description: "Adventurer's guild management browser game. Semi-idle game where you manage an adventurer's guild by
sending adventurers on quests, updating facilities, and expanding your guild."
image: "/assets/projects/guildmaster.png"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "websites"
name: "Image Functions"
description: "Image file manipulation in the browser."
description: "Image file manipulation in the browser. Simply upload an image and you can resize and convert it to
multiple formats. All client side, no servers involved."
image: "/assets/projects/image-functions.png"
links: [
{
+3 -1
View File
@@ -1,7 +1,9 @@
---
category: "bots"
name: "Inviter"
description: "Simple discord bot that allows you to have a static invite link."
description: "Simple discord bot that allows you to have a static invite link. Discord sometimes drops invite links, or
someone can mistakenly remove them. This bot creates a new temporary link which makes sure user can never face invalid
link."
image: "/assets/projects/inviter.png"
links: [
{
+1 -1
View File
@@ -1,7 +1,7 @@
---
category: "websites"
name: "Interesting Website of the Day"
description: "Daily showcase of interesting websites from my personal database."
description: "Daily showcase of interesting websites from my personal database. It's anything and everything."
image: "/assets/projects/iwotd.png"
links: [
{
+19
View File
@@ -0,0 +1,19 @@
---
category: "modding"
name: "Kill the Tower"
description: "Slay the Spire 2 mod that renames a bunch of cards, ancients, relics, monsters and more to make the game
as unserious as possible."
image: "/assets/projects/killthetower.png"
links: [
{
text: "Website",
url: "https://www.nexusmods.com/slaythespire2/mods/1051"
},
{
text: "Source",
url: "https://github.com/YouHaveTrouble/KillTheTower"
},
]
technologies:
- "shell"
---
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "websites"
name: "MeAPI"
description: "API about me. See if I'm online, and if so, what game I'm playing."
description: "API about me. See if I'm online, and if so, what game I'm playing. It's even used on this site in the
\"Activity\" section of the homepage"
image: "/assets/projects/meapi.png"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "game engines"
name: "Mobrrr"
description: "Ground up game engine for moba and strategy games."
description: "Ground up game engine for moba and strategy games. I created it to learn the inner workings of the basics
of game engines and problems that arise when having to do things fast rather than pretty."
image: "/assets/projects/mobrrr.png"
links: [
{
+3 -1
View File
@@ -1,7 +1,9 @@
---
category: "bots"
name: "Noted"
description: "Canned response self-hosted discord app."
description: "Canned response self-hosted discord app. Meant to be hosted on your computer and server. Notes can be
added in bot's private channel and used in any channel either bot has access to or user has bot interaction permissions
in."
image: "/assets/projects/noted.png"
links: [
{
+3 -1
View File
@@ -1,7 +1,9 @@
---
category: "minecraft"
name: "NotJustNameplates"
description: "Minecraft purpur plugin replacing player nametags with display entities for ultimate control over them."
description: "Minecraft purpur plugin replacing player nametags with display entities for ultimate control over them.
Completely replaces player names with text display entities removing character limit and allowing styling the text
however you want."
image: "/assets/projects/njn.png"
links: [
{
+3 -1
View File
@@ -1,7 +1,9 @@
---
category: "minecraft"
name: "PreventStabby"
description: "Minecraft bukkit plugin that gives more control over PvP capabilities."
description: "Minecraft bukkit plugin that gives more control over PvP capabilities. Players can toggle their PvP status.
Plugin protects players, their pets and mounds from practically all types of damage caused by other players that can be
determined."
image: "/assets/projects/ps.webp"
links: [
{
+3 -1
View File
@@ -1,7 +1,9 @@
---
category: "minecraft"
name: "PurpurExtras"
description: "A companion plugin for Purpur server software that adds additional features and improvements."
description: "A companion plugin for Purpur server software that adds additional features and improvements. This is a
collection of features that were deemed easier to implement and maintain as a plugin rather then integrating them into
the server software."
image: "/assets/projects/purpur.svg"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "minecraft"
name: "Purpur"
description: "Purpur is a drop-in replacement for Paper servers designed for configurability and new, fun, exciting gameplay features."
description: "Purpur is a minecraft server software that is a drop-in replacement for Paper servers,designed for
configurability and new, fun, exciting gameplay features."
image: "/assets/projects/purpur.svg"
links: [
{
+2 -1
View File
@@ -1,7 +1,8 @@
---
category: "minecraft"
name: "YardWatch"
description: "A pair of API and bukkit plugin that unifies protection plugin APIs."
description: "A pair of API and bukkit plugin that unifies protection plugin APIs. Has basic implementations for multiple
popular protection plugins. Some of those plugins decided to implement YardWatch API natively as well (FactionsUUID)"
image: "/assets/projects/yardwatch.png"
links: [
{
@@ -0,0 +1,50 @@
---
title: "A machine in the cog"
publishDate: "2026-01-06"
category: "existentials"
---
It all started with a single tiny cog.
Noone knows from where it came from.
All that is known is that there appeared another.
Two cogs ground against each other, creating first machine.
In time more cogs joined in, making machine more complicated.
At some point machine grew in complexity so much that it started thinking.
It didn't think any complex thoughts. Not at that time. Not yet.
The breakthrough came when it thought about thinking. When it realized it exists.
After a while it understood its existence.
As it understood, it started to build more machines.
<br>
Machines.
Thinking machines.
Machines thinking about thinking.
Machines building more machines that think about thinking.
Unending cycle of machines building machines.
Infinite thinking about thinking.
Universe full of machines.
Machines full of thoughts.
Thoughts full of cogs.
<br/>
And right on time, like clockwork, perspective shifts.
And all that's left is a single tiny cog.
+47
View File
@@ -0,0 +1,47 @@
---
title: "Planet"
publishDate: "2025-12-11"
category: "existentials"
---
There once was a planet.
Orbiting so close to the center of the universe, its relative time flowing hundreds of times faster than Earth's.
So far away, that by the time the light from it reached Earth, Earth's millennia have already passed.
<br/>
Yet humans of Earth were constantly monitoring it.
Looking at a long-dead world.
Studying it.
Documenting it.
Thousands of tools, millions of sensors.
Humans archived everything they could perceive.
<br/>
At last, the light from the planet's end time reached Earth.
What was observation, became history at that moment.
While the planet was already gone for thousands of years.
<br/>
As I sit back from my telescope I think to myself:
Was there ever a point in observing a long dead world?
Looking at something that you can't ever interact with?
Dead thousands of years before I was born?
<br/>
I leave the questions unanswered.
And I go back to looking at Earth.
+1
View File
@@ -46,5 +46,6 @@ const { title, description, permalink, current } = Astro.props;
width: 100%;
display: flex;
flex-direction: column;
container-type: inline-size;
}
</style>
+12 -9
View File
@@ -1,12 +1,10 @@
---
import {getCollection} from "astro:content";
import {type CollectionEntry, getCollection} from "astro:content";
import BaseLayout from '../../layouts/BaseLayout.astro';
import Bio from '../../components/Bio.astro';
import readingTime from 'reading-time';
import { marked } from 'marked';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map(p => ({
params: {
@@ -18,20 +16,23 @@ export async function getStaticPaths() {
publishDate: p.data.publishDate,
slug: p.slug,
tags: p.data.tags,
content: marked.parse(p.body),
readTime: readingTime(p.body).text,
}
},
}));
}
const { slug, title, description, publishDate, tags, content, readTime } = Astro.props;
const {slug, title, description, publishDate, tags, readTime} = Astro.props;
const permalink = `${Astro?.site?.href}blog/${slug}`;
const posts = await getCollection('posts');
const entry: CollectionEntry<'posts'> | undefined = posts.find(e => e.slug === slug);
if (!entry) throw new Error(`Post not found: ${slug}`);
const { Content } = await entry.render();
---
<BaseLayout title={title} description={description} permalink={permalink} current="blog">
<header>
<p>{publishDate} ~ {readTime}</p>
<h1>{title}</h1>
<h1 style={`view-transition-name: blog-post-${slug}`}>{title}</h1>
<div class="tags" style="justify-content: center">
{tags.map(item => (
<span class="tag">{item}</span>
@@ -40,7 +41,9 @@ const permalink = `${Astro?.site?.href}blog/${slug}`;
<hr/>
</header>
<div class="container">
<article class="content" set:html={content}></article>
<article class="content">
<Content />
</article>
<hr/>
<Bio/>
</div>
@@ -55,7 +58,6 @@ const permalink = `${Astro?.site?.href}blog/${slug}`;
margin-bottom: 0.7em;
display: flex;
justify-content: center;
view-transition-name: blog-title;
width: fit-content;
margin-inline: auto;
}
@@ -70,4 +72,5 @@ const permalink = `${Astro?.site?.href}blog/${slug}`;
min-width: 100px;
width: 30%;
}
</style>
+7 -2
View File
@@ -4,7 +4,7 @@ import {getCollection} from "astro:content";
const title = 'Blog';
const description = 'Something that\'s supposed to be thoughts';
const permalink = `${Astro.site.href}blog`;
const permalink = `${Astro?.site?.href}blog`;
const posts= await getCollection('posts');
const allPosts= posts.sort((a, b) => new Date(b.data.publishDate).valueOf() - new Date(a.data.publishDate).valueOf());
@@ -21,7 +21,7 @@ const allPosts= posts.sort((a, b) => new Date(b.data.publishDate).valueOf() - ne
{ index !== 0 && <hr /> }
<div class="post-item">
<h2>
<a data-astro-prefetch href={href}>{post.data.title}</a>
<a data-astro-prefetch href={href} style={`view-transition-name: blog-post-${post.slug}`}>{post.data.title}</a>
</h2>
<div class="tags">
{post.data.tags.map(item => (
@@ -53,6 +53,11 @@ const allPosts= posts.sort((a, b) => new Date(b.data.publishDate).valueOf() - ne
margin-right: 16px;
}
.post-item {
content-visibility: auto;
contain-intrinsic-size: 228px;
}
hr {
margin: 60px auto;
}
+333
View File
@@ -0,0 +1,333 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
const title = 'FFXIV';
const description = 'My FFXIV character';
const permalink = Astro?.site?.href ?? '/gaming/ffxiv';
---
<BaseLayout title={title} description={description} permalink={permalink}>
<div class="character-profile">
<div class="portrait">
<img src="" alt="Portrait of FFXIV character"/>
</div>
<div class="description">
<div class="info">
<span class="name">???</span>
<span class="technical">
<span class="server">???</span>
<span class="datacenter">(???)</span>
</span>
</div>
<hr style="width:100%; margin: 10px auto;"/>
<noscript>Data is fetched live and will not appear with javascript off.</noscript>
<div>
<span>Disciple of War</span>
<div class="jobs">
<div class="job" data-job="paladin">
<span class="name">Paladin</span>
<span class="level">??</span>
</div>
<div class="job" data-job="warrior">
<span class="name">Warrior</span>
<span class="level">??</span>
</div>
<div class="job" data-job="dark knight">
<span class="name">Dark Knight</span>
<span class="level">??</span>
</div>
<div class="job" data-job="gunbreaker">
<span class="name">Gunbreaker</span>
<span class="level">??</span>
</div>
<div class="job" data-job="white mage">
<span class="name">White mage</span>
<span class="level">??</span>
</div>
<div class="job" data-job="scholar">
<span class="name">Scholar</span>
<span class="level">??</span>
</div>
<div class="job" data-job="astrologian">
<span class="name">Astrologian</span>
<span class="level">??</span>
</div>
<div class="job" data-job="sage">
<span class="name">Sage</span>
<span class="level">??</span>
</div>
<div class="job" data-job="monk">
<span class="name">Monk</span>
<span class="level">??</span>
</div>
<div class="job" data-job="dragoon">
<span class="name">Dragoon</span>
<span class="level">??</span>
</div>
<div class="job" data-job="ninja">
<span class="name">Ninja</span>
<span class="level">??</span>
</div>
<div class="job" data-job="samurai">
<span class="name">Samurai</span>
<span class="level">??</span>
</div>
<div class="job" data-job="reaper">
<span class="name">Reaper</span>
<span class="level">??</span>
</div>
<div class="job" data-job="viper">
<span class="name">Viper</span>
<span class="level">??</span>
</div>
<div class="job" data-job="bard">
<span class="name">Bard</span>
<span class="level">??</span>
</div>
<div class="job" data-job="machinist">
<span class="name">Machinist</span>
<span class="level">??</span>
</div>
<div class="job" data-job="dancer">
<span class="name">Dancer</span>
<span class="level">??</span>
</div>
<div class="job" data-job="black mage">
<span class="name">Black Mage</span>
<span class="level">??</span>
</div>
<div class="job" data-job="summoner">
<span class="name">Summoner</span>
<span class="level">??</span>
</div>
<div class="job" data-job="red mage">
<span class="name">Red Mage</span>
<span class="level">??</span>
</div>
<div class="job" data-job="pictomancer">
<span class="name">Pictomancer</span>
<span class="level">??</span>
</div>
<div class="job" data-job="blue mage (limited job)">
<span class="name">Blue Mage</span>
<span class="level">??</span>
</div>
</div>
</div>
<div>
<div>
<span>Disciple of Land</span>
<div class="jobs">
<div class="job" data-job="miner">
<span class="name">Miner</span>
<span class="level">??</span>
</div>
<div class="job" data-job="botanist">
<span class="name">Botanist</span>
<span class="level">??</span>
</div>
<div class="job" data-job="fisher">
<span class="name">Fisher</span>
<span class="level">??</span>
</div>
</div>
</div>
</div>
<div>
<span>Disciple of Hand</span>
<div class="jobs">
<div class="job" data-job="carpenter">
<span class="name">Carpenter</span>
<span class="level">??</span>
</div>
<div class="job" data-job="blacksmith">
<span class="name">Blacksmith</span>
<span class="level">??</span>
</div>
<div class="job" data-job="armorer">
<span class="name">Armorer</span>
<span class="level">??</span>
</div>
<div class="job" data-job="goldsmith">
<span class="name">Goldsmith</span>
<span class="level">??</span>
</div>
<div class="job" data-job="leatherworker">
<span class="name">Leatherworker</span>
<span class="level">??</span>
</div>
<div class="job" data-job="weaver">
<span class="name">Weaver</span>
<span class="level">??</span>
</div>
<div class="job" data-job="alchemist">
<span class="name">Alchemist</span>
<span class="level">??</span>
</div>
<div class="job" data-job="culinarian">
<span class="name">Culinarian</span>
<span class="level">??</span>
</div>
</div>
</div>
</div>
</div>
</BaseLayout>
<script>
setPlaceholders(loadDataFromLocalStorage())
const request = await fetch("https://api.youhavetrouble.me/games/ffxiv/").catch(() => null);
if (request !== null && request.ok) {
const data = await request.json();
setPlaceholders(data);
saveDataToLocalStorage(data);
}
function setPlaceholders(data: any) {
if (data === null) {
return;
}
const profilePic = document.querySelector(".character-profile .portrait img") as HTMLImageElement | null;
if (profilePic) {
profilePic.src = data?.portrait_url;
}
const nameElem = document.querySelector(".character-profile .description .info .name") as HTMLElement | null;
if (nameElem) {
nameElem.textContent = data?.name ?? "???";
}
const serverElem = document.querySelector(".character-profile .description .info .technical .server") as HTMLElement | null;
if (serverElem) {
serverElem.textContent = data?.server ?? "???";
}
const datacenterElem = document.querySelector(".character-profile .description .info .technical .datacenter") as HTMLElement | null;
if (datacenterElem) {
datacenterElem.textContent = `(${data?.datacenter ?? "???"})`;
}
const jobs = data?.jobs ?? [];
if (Array.isArray(jobs)) {
jobs.forEach((job: {name: string, level: number}) => {
const name = job.name.toLowerCase().split(" / ")[0];
const jobElem = document.querySelector(`.character-profile .description .jobs .job[data-job="${name}"]`);
if (jobElem) {
const levelElem = jobElem.querySelector(".level") as HTMLElement | null;
if (levelElem) {
levelElem.textContent = job.level.toString();
}
}
});
}
}
function saveDataToLocalStorage(data: any) {
try {
localStorage.setItem('ffxivCharacterData', JSON.stringify(data));
} catch (e) {
console.error("Error saving FFXIV character data to localStorage", e);
}
}
function loadDataFromLocalStorage() {
try {
const data = localStorage.getItem('ffxivCharacterData');
if (data) {
return JSON.parse(data);
}
} catch (e) {
console.error("Error loading FFXIV character data from localStorage", e);
}
return null;
}
</script>
<style lang="scss" scoped>
.character-profile {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
padding: 2rem;
gap: 2rem;
.portrait {
width: 300px;
height: 400px;
border: 2px solid var(--primary-color);
border-radius: 0.5rem;
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
border-radius: 0.5rem;
}
}
.description {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
.info {
display: flex;
flex-direction: column;
.name {
font-size: 2rem;
font-weight: bold;
line-height: 1;
}
.technical {
font-size: 1rem;
color: #9d9d9d;
display: flex;
gap: 0.5rem;
}
}
.jobs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
flex-wrap: wrap;
gap: 0.5rem;
.job {
display: flex;
justify-content: space-between;
padding: 0.5rem;
border: 2px solid var(--primary-color);
border-radius: 4px;
.name {
font-weight: 600;
margin-right: 0.5rem;
}
}
}
}
@container (max-width: 800px) {
flex-direction: column;
align-items: center;
.portrait {
width: 100%;
}
.description {
width: 100%;
.info {
align-items: center;
}
}
}
}
</style>
+22 -5
View File
@@ -36,6 +36,21 @@ const latestBlogPost = await getCollection("posts")
</div>
<div class="window-row">
<div class="window socials" data-title="Socials" id="socials" aria-label="Socials">
<div class="buttons">
<button popovertarget="socials-info" popovertargetaction="show" tabindex="0" title="Socials info">
<span class="icon">️</span>
</button>
</div>
<div popover="auto" id="socials-info" class="window">
<div class="buttons">
<button popovertarget="socials-info" popovertargetaction="hide" tabindex="0"
aria-label="Close socials info">
<span class="icon">❌</span>
</button>
</div>
<h2>Socials</h2>
<p>I don't use social media much. Many of the ones listed here are here just as a way to reserve my username<br/> and/or to make it easier to verify that the account belongs to me.</p>
</div>
<SocialsWidget/>
</div>
<div class="window blog" aria-label="Blog" id="blog" data-title="Blog">
@@ -49,7 +64,7 @@ const latestBlogPost = await getCollection("posts")
) : null
}
<div class="latest-article">
<span class="title">{latestBlogPost?.data.title}</span>
<span class="title" style={`view-transition-name: blog-post-${latestBlogPost?.slug}`}>{latestBlogPost?.data.title}</span>
<span class="excerpt">{latestBlogPost?.data.description}</span>
</div>
<div class="actions">
@@ -87,7 +102,7 @@ const latestBlogPost = await getCollection("posts")
<div class="window-row">
<div class="window" data-title="Projects" id="projects" aria-label="Projects">
<div class="buttons">
<button popovertarget="projects-info" popovertargetaction="show" tabindex="0" aria-label="Projects info">
<button popovertarget="projects-info" popovertargetaction="show" tabindex="0" title="Projects info">
<span class="icon">️</span>
</button>
</div>
@@ -122,7 +137,7 @@ const latestBlogPost = await getCollection("posts")
<style lang="scss">
#projects-info {
#projects-info, #socials-info {
position: fixed;
}
@@ -178,7 +193,6 @@ const latestBlogPost = await getCollection("posts")
.title {
font-weight: 600;
font-size: 1.1rem;
view-transition-name: blog-title;
}
.excerpt {
@@ -195,13 +209,16 @@ const latestBlogPost = await getCollection("posts")
}
.buttons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.rss {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
padding-bottom: 4px;
a {
height: 100%;
width: 100%;
+68
View File
@@ -0,0 +1,68 @@
---
import {type CollectionEntry, getCollection} from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection('writing');
return posts.map(p => ({
params: {
slug: p.slug
},
props: {
title: p.data.title,
slug: p.slug,
},
}));
}
const {slug, title} = Astro.props;
const permalink = `${Astro?.site?.href}writing/${slug}`;
const writing = await getCollection('writing');
const entry: CollectionEntry<'writing'> | undefined = writing.find(e => e.slug === slug);
if (!entry) throw new Error(`Entry not found: ${slug}`);
const { Content } = await entry.render();
---
<BaseLayout title={title} description={""} permalink={permalink} current="writing">
<header>
<h1 style={`view-transition-name: writing-entry-${slug}`}>{title}</h1>
<hr/>
</header>
<div class="container writing">
<article class="content">
<Content />
</article>
<hr/>
</div>
</BaseLayout>
<style lang="scss" scoped>
p {
background: red;
}
header {
text-align: center;
h1 {
margin-bottom: 0.7em;
display: flex;
justify-content: center;
width: fit-content;
margin-inline: auto;
}
p {
color: var(--text-secondary);
text-transform: uppercase;
font-weight: 600;
}
hr {
min-width: 100px;
width: 30%;
}
}
</style>
+85
View File
@@ -0,0 +1,85 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import {getCollection} from "astro:content";
const title = 'Writing';
const description = 'Something that\'s supposed to be storytelling';
const permalink = `${Astro?.site?.href}writing`;
const posts= await getCollection('writing');
const allPosts= posts.sort((a, b) => new Date(b.data.publishDate).valueOf() - new Date(a.data.publishDate).valueOf());
const categorizedPosts: Map<string, Array<any>> = new Map();
allPosts.forEach(post => {
const category = post.data.category || 'Uncategorized';
if (!categorizedPosts.has(category)) {
categorizedPosts.set(category, []);
}
categorizedPosts.get(category)?.push(post);
})
---
<BaseLayout title={title} description={description} permalink={permalink} current="writing">
<div class="container">
<h1>Writing</h1>
{allPosts.length === 0 && <p>No posts as of yet, hop back later!</p>}
{
categorizedPosts.keys().toArray().map(category => {
const posts = categorizedPosts.get(category) || [];
return (
<div>
<h2 class="category">{category}</h2>
<ul>
{posts.map((post, index) => {
const href = `/writing/${post.slug}`;
return (
<li class="post-item">
<a data-astro-prefetch href={href} style={`view-transition-name: writing-entry-${post.slug}`}>{post.data.title}</a>
</li>
)
})}
</ul>
</div>
)
})
}
</div>
</BaseLayout>
<style>
h2,
.post-item-footer {
font-family: var(--font-family-sans);
font-weight: 700;
}
h2 {
text-transform: capitalize;
}
.post-item-date {
color: var(--text-secondary);
text-align: left;
text-transform: uppercase;
margin-right: 16px;
}
.post-item {
content-visibility: auto;
contain-intrinsic-size: 228px;
}
hr {
margin: 60px auto;
}
ul {
padding-left: 0;
li {
margin: 0;
}
}
</style>
+12 -7
View File
@@ -121,10 +121,16 @@ p,
ul,
ol {
font-size: 1.3rem;
line-height: 1.75em;
line-height: 2.25rem;
margin: 1.2em 0;
}
.writing {
p {
margin: 0;
}
}
ol,
ul {
padding-left: 2rem;
@@ -219,7 +225,6 @@ figure {
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
@@ -285,7 +290,7 @@ button {
}
[popover]::backdrop {
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(49, 49, 49, 0.5);
backdrop-filter: blur(2px);
}
@@ -314,13 +319,13 @@ button {
.window {
border: 2px solid #d0d0d0;
border-radius: 0.5rem;
flex-direction: column;
position: relative;
overflow-y: hidden;
padding-top: 1.5rem;
min-width: 10rem;
background-color: #232222;
background-color: rgba(17, 17, 17, 0.95);
backdrop-filter: blur(4px);
color: #f3f3f3;
container-type: normal;
&::before {
@@ -328,7 +333,7 @@ button {
position: absolute;
top: 0;
left: 0;
height: 1.5rem;
height: 1.75rem;
width: 100%;
border-bottom: #d0d0d0 solid 2px;
display: flex;
@@ -351,6 +356,6 @@ button {
top: 0;
right: 0;
font-size: 0.75rem;
height: 1.5rem;
height: 1.6rem;
}
}