feat(projects): add architecture diagrams, align project texts

Add diagrams to three projects. A tap opens them full size.
Align en/de texts: no versions, Gitea not GitHub, Portainer, home
server + VPS. Make To Be Played the featured project.
Remove the Hostinger logo image.
This commit is contained in:
lobo committed 2026-10-14 08:18:18 +02:00
1 parent 449e2d430a
commit 93e9c59436
11 files changed
+247 -50

No files matched your search

+3 -4
View File
@@ -41,12 +41,11 @@ export class AssetsConstants {
'/assets/projects/diploma/6.webp'
];
static readonly CLAUDE_BOX_IMAGES = [
'/assets/projects/claude-in-a-box/1.svg'
];
static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
static readonly TBP_DIAGRAM = '/assets/projects/to-be-played/architecture.svg';
static readonly TRIBBLE_DIAGRAM = '/assets/projects/tribble-the-homeserver/architecture.svg';
static readonly TRIBBLE_IMAGES = [
'/assets/projects/tribble-the-homeserver/1.webp',
'/assets/projects/tribble-the-homeserver/2.webp',
'/assets/projects/tribble-the-homeserver/3.webp',
'/assets/projects/tribble-the-homeserver/4.webp'
@@ -12,6 +12,14 @@
</ul>
</div>
@if (project.diagram) {
<div class="mb-4 rounded-xl overflow-hidden bg-[#1e2530]">
<a [href]="project.diagram" target="_blank" rel="noopener" [attr.aria-label]="'PROJECTS.DIAGRAM_ALT' | translate">
<img class="w-full h-auto block" [src]="project.diagram" [alt]="'PROJECTS.DIAGRAM_ALT' | translate">
</a>
</div>
}
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
@@ -27,27 +27,46 @@ describe('ProjectDialogComponent', () => {
expect(() => register()).not.toThrow();
});
it('renders an upgraded swiper-container for project images', async () => {
async function createDialog(data: object): Promise<ComponentFixture<ProjectDialogComponent>> {
await TestBed.configureTestingModule({
imports: [ProjectDialogComponent],
providers: [
provideZonelessChangeDetection(),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
},
},
{ provide: MAT_DIALOG_DATA, useValue: data },
],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectDialogComponent);
fixture.detectChanges();
return fixture;
}
it('renders an upgraded swiper-container for project images', async () => {
const fixture = await createDialog({
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
});
const el: HTMLElement = fixture.nativeElement.querySelector('swiper-container');
expect(el).not.toBeNull();
expect(el instanceof customElements.get('swiper-container')!).toBe(true);
});
it('renders the diagram above the sections when the project has one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [], diagram: 'd.svg' });
const img: HTMLImageElement | null = fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]');
expect(img?.getAttribute('src')).toBe('d.svg');
const link: HTMLAnchorElement | null = img?.closest('a') ?? null;
expect(link?.getAttribute('href')).toBe('d.svg');
expect(link?.getAttribute('target')).toBe('_blank');
expect(fixture.nativeElement.querySelector('swiper-container')).toBeNull();
});
it('renders no diagram without one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [] });
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
});
});
@@ -5,8 +5,8 @@
<mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -31,8 +31,8 @@
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -36,6 +36,14 @@ describe('ProjectsComponent', () => {
expect(titles).toContain('PROJECTS.TBP.TITLE');
});
it('features To Be Played', () => {
expect(fixture.componentInstance.featuredProject()?.identifier).toBe('to-be-played');
});
it('card falls back to the diagram when a project has no images', () => {
expect(fixture.nativeElement.querySelector('img[src="/assets/projects/claude-in-a-box/1.svg"]')).not.toBeNull();
});
it('every project translation key exists in en and de', () => {
const keys = fixture.componentInstance.allProjects.flatMap(project => [
project.title,
+17 -10
View File
@@ -19,6 +19,7 @@ export interface Projects {
url: string,
source: string
}[],
diagram?: string,
icon: string,
assets: string,
links: {
@@ -59,6 +60,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TBP.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TBP.INTRODUCTION',
images: AssetsConstants.TBP_IMAGES.map(url => ({ url, source: '' })),
diagram: AssetsConstants.TBP_DIAGRAM,
icon: 'playlist_add_check',
assets: '',
links: [
@@ -82,7 +84,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TBP.LEARNING_1',
'PROJECTS.TBP.LEARNING_2',
],
isFeatured: false,
isFeatured: true,
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
},
{
@@ -90,7 +92,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
title: 'PROJECTS.CLAUDE_BOX.TITLE',
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
images: [],
diagram: AssetsConstants.CLAUDE_BOX_DIAGRAM,
icon: 'smart_toy',
assets: '',
links: [],
@@ -136,7 +139,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.LEARNING_2',
],
isFeatured: false,
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker']
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'Gitea Actions', 'Docker']
},
{
identifier: "elmucho",
@@ -156,13 +159,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
challenges: [
'PROJECTS.EL_MUCHO.CHALLENGE_1',
'PROJECTS.EL_MUCHO.CHALLENGE_2',
'PROJECTS.EL_MUCHO.CHALLENGE_3',
],
learnings: [
'PROJECTS.EL_MUCHO.LEARNING_1',
'PROJECTS.EL_MUCHO.LEARNING_2',
],
isFeatured: true,
isFeatured: false,
technologies: ['Unity', 'C#', 'Steamworks', 'Git']
},
{
@@ -223,16 +225,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
images: [
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://dashboardicons.com/icons/traefik' }
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/traefik' }
],
diagram: AssetsConstants.TRIBBLE_DIAGRAM,
icon: 'dns',
assets: '',
links: [
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
{ name: 'Docker', url: 'https://www.docker.com/' },
{ name: 'Portainer', url: 'https://www.portainer.io/' },
{ name: 'Traefik', url: 'https://traefik.io/' },
{ name: 'Gitea', url: 'https://gitea.io/' },
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
@@ -255,13 +258,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TRIBBLE.LEARNING_2',
],
isFeatured: false,
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
technologies: ['Ubuntu Server', 'Docker', 'Portainer', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
}
]
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
otherProjects = computed(() => this.allProjects.filter(p => !p.isFeatured));
cardImage(project: Projects): string | undefined {
return project.images[0]?.url ?? project.diagram;
}
ngOnInit(): void {
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => { this.dialogOpenFunction(); }, 10);
+11 -11
View File
@@ -212,9 +212,9 @@
"LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Einsatz moderner Technologien und CI/CD-Pipelines (Angular 20+, Spring Boot 4, GitHub).",
"P1": "Moderner Frontend-Stack und eine CI/CD-Pipeline mit Gitea Actions (Angular, Docker).",
"P2": "Präsentation persönlicher Projekte und kontinuierliche Verbesserung algorithmischer Fähigkeiten.",
"P3": "Vertiefung von JavaScript/TypeScript-, Angular- und Spring-Boot-Kenntnissen durch praktisches Arbeiten."
"P3": "Vertiefung von JavaScript/TypeScript- und Angular-Kenntnissen durch praktisches Arbeiten."
}
},
"P1": {
@@ -301,6 +301,7 @@
"PROJECTS": {
"DOWNLOAD": "Herunterladen",
"READ_MORE": "Mehr erfahren",
"DIAGRAM_ALT": "Architekturdiagramm",
"LINK_TO_PROJECT": "Zum Projekt",
"CLOSE": "Schließen",
"SECTION": {
@@ -309,11 +310,11 @@
},
"PLAYGROUND": {
"TITLE": "Playground Portfolio",
"SHORT_DESCRIPTION": "Full-Stack Portfolio mit interaktiven Algorithmus-Visualisierungen.",
"SHORT_DESCRIPTION": "Frontend-Portfolio mit interaktiven Algorithmus-Visualisierungen.",
"INTRODUCTION": "Diese Website dient als lebendiges Portfolio und Testumgebung für moderne Webtechnologien. Ziel ist es, komplexe Algorithmen und mathematische Konzepte (wie WebGPU-Simulationen oder Raymarching) anschaulich im Browser darzustellen.",
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
"BULLET_1": "Entwicklung mit Angular und Material Design.",
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.",
"BULLET_3": "Automatisierte CI/CD mit Gitea Actions und Containerisierung mit Docker.",
"BULLET_4": "Internationalization (i18n) für globale Reichweite.",
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.",
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
@@ -322,11 +323,11 @@
},
"TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home-Infrastruktur mit Docker, Traefik und sicherer VPN-Anbindung.",
"INTRODUCTION": "Dokumentation und Aufbau einer privaten Cloud-Infrastruktur. Fokus liegt auf Datensouveränität, Automatisierung und Sicherheit.",
"SHORT_DESCRIPTION": "Home-Server für CI und private Dienste, dazu ein VPS für die öffentlichen Seiten.",
"INTRODUCTION": "Zwei Maschinen: Der Home-Server „Tribble“ betreibt Gitea mit Actions-Runner und die privaten Dienste, ein Hostinger-VPS liefert die öffentlichen Seiten aus. Die CI baut die Images zu Hause, der VPS zieht neue Images mit Watchtower über Tailscale, und Traefik leitet die öffentlichen Seiten weiter.",
"BULLET_1": "Zentrale Verwaltung via Docker-Compose und Portainer.",
"BULLET_2": "Automatisches SSL-Management und Reverse-Proxy mit Traefik.",
"BULLET_3": "Private Versionskontrolle (Gitea) und Medien-Streaming (Jellyfin).",
"BULLET_3": "Private Versionskontrolle und CI mit Gitea, Medien-Streaming mit Jellyfin.",
"BULLET_4": "Netzwerkweite Ad-Blocking und DNS-Kontrolle via AdGuard Home.",
"CHALLENGE_1": "Konfiguration sicherer Netzwerkschichten und Firewall-Regeln für Remote-Zugriff.",
"CHALLENGE_2": "Automatisierung von Backups und Recovery-Strategien für containerisierte Daten.",
@@ -336,14 +337,13 @@
"EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Rundenbasiertes Taktik-RPG, veröffentlicht auf Steam.",
"INTRODUCTION": "Ein kommerzielles Spieleprojekt, das von der ersten Idee bis zum weltweiten Release auf Steam eigenverantwortlich umgesetzt wurde. Ein taktisches RPG, das klassische Gameplay-Elemente mit modernen Systemen verbindet.",
"BULLET_1": "Komplette Engine-Entwicklung in Unity (C#).",
"INTRODUCTION": "Ein kommerzielles Spiel, das mein Bruder und ich von der ersten Idee bis zum Release auf Steam gebracht haben: geplant, gebaut, verkauft. Ein taktisches RPG, das klassische Gameplay-Elemente mit modernen Systemen verbindet.",
"BULLET_1": "Spielentwicklung in Unity (C#).",
"BULLET_2": "Integration von Steamworks-Funktionen (Achievements, Cloud Saves).",
"BULLET_3": "Entwicklung einer eigenen taktischen KI und Pfadfindungs-Logik.",
"BULLET_4": "Management des gesamten Asset-Pipelines und Sound-Designs.",
"CHALLENGE_1": "Implementierung eines robusten rundenbasierten Systems mit komplexen Abhängigkeiten.",
"CHALLENGE_2": "Performance-Optimierung für eine flüssige Darstellung auf verschiedenen Hardware-Profilen.",
"CHALLENGE_3": "Umgang mit den strengen Zertifizierungs-Anforderungen von Steam.",
"LEARNING_1": "Durchhaltevermögen und Fokus über einen mehrjährigen Entwicklungszyklus.",
"LEARNING_2": "Vermarktung und Community-Management für ein digitales Produkt."
},
+11 -11
View File
@@ -212,9 +212,9 @@
"LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Using modern technologies and CI/CD pipelines (Angular 20+, Spring Boot 4, GitHub).",
"P1": "Modern frontend stack and a CI/CD pipeline on Gitea Actions (Angular, Docker).",
"P2": "Showcasing personal projects and improving algorithmic skills over time.",
"P3": "Deepening knowledge in JavaScript/TypeScript, Angular, Spring Boot and related technologies through hands-on practice."
"P3": "Deepening knowledge in JavaScript/TypeScript, Angular and related technologies through hands-on practice."
}
},
"P1": {
@@ -301,6 +301,7 @@
"PROJECTS": {
"DOWNLOAD": "Download",
"READ_MORE": "Read More",
"DIAGRAM_ALT": "Architecture diagram",
"LINK_TO_PROJECT": "To the project",
"CLOSE": "Close",
"SECTION": {
@@ -309,11 +310,11 @@
},
"PLAYGROUND": {
"TITLE": "Playground Portfolio",
"SHORT_DESCRIPTION": "Full-stack portfolio with interactive algorithm visualizations.",
"SHORT_DESCRIPTION": "Frontend portfolio with interactive algorithm visualizations.",
"INTRODUCTION": "This website serves as a living portfolio and testing ground for modern web technologies. The goal is to clearly represent complex algorithms and mathematical concepts (such as WebGPU simulations or Raymarching) directly in the browser.",
"BULLET_1": "Development with Angular 19+ and Material Design.",
"BULLET_1": "Development with Angular and Material Design.",
"BULLET_2": "Implementation of performant visualizations (WebGPU, Shader, Canvas).",
"BULLET_3": "Automated CI/CD pipelines and containerization with Docker.",
"BULLET_3": "Automated CI/CD with Gitea Actions and containerization with Docker.",
"BULLET_4": "Internationalization (i18n) for global reach.",
"CHALLENGE_1": "Optimizing render performance for complex 3D fractals in real-time.",
"CHALLENGE_2": "Architecting a scalable and maintainable frontend structure for diverse sub-projects.",
@@ -322,11 +323,11 @@
},
"TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home infrastructure with Docker, Traefik, and secure VPN connectivity.",
"INTRODUCTION": "Documentation and construction of a private cloud infrastructure. Focus is on data sovereignty, automation, and security.",
"SHORT_DESCRIPTION": "Home server for CI and private services, plus a VPS for the public sites.",
"INTRODUCTION": "Two machines: the home server \"Tribble\" runs Gitea with its Actions runner and the private services, and a Hostinger VPS serves the public sites. CI builds the images at home, the VPS pulls new images over Tailscale with Watchtower, and Traefik routes the public sites.",
"BULLET_1": "Central management via Docker-Compose and Portainer.",
"BULLET_2": "Automatic SSL management and reverse proxy with Traefik.",
"BULLET_3": "Private version control (Gitea) and media streaming (Jellyfin).",
"BULLET_3": "Private version control and CI with Gitea, media streaming with Jellyfin.",
"BULLET_4": "Network-wide ad-blocking and DNS control via AdGuard Home.",
"CHALLENGE_1": "Configuring secure network layers and firewall rules for remote access.",
"CHALLENGE_2": "Automating backups and recovery strategies for containerized data.",
@@ -336,14 +337,13 @@
"EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Turn-based tactical RPG, published on Steam.",
"INTRODUCTION": "A commercial game project that was independently implemented from the initial idea to the worldwide release on Steam. A tactical RPG that combines classic gameplay elements with modern systems.",
"BULLET_1": "Complete engine development in Unity (C#).",
"INTRODUCTION": "A commercial game that my brother and I took from the first idea to the release on Steam: planned, built and sold. A tactical RPG that combines classic gameplay elements with modern systems.",
"BULLET_1": "Game development in Unity (C#).",
"BULLET_2": "Integration of Steamworks features (Achievements, Cloud Saves).",
"BULLET_3": "Development of a custom tactical AI and pathfinding logic.",
"BULLET_4": "Management of the entire asset pipeline and sound design.",
"CHALLENGE_1": "Implementing a robust turn-based system with complex dependencies.",
"CHALLENGE_2": "Performance optimization for a smooth experience across various hardware profiles.",
"CHALLENGE_3": "Handling Steam's strict certification requirements.",
"LEARNING_1": "Perseverance and focus over a multi-year development cycle.",
"LEARNING_2": "Marketing and community management for a digital product."
},
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
<title id="title">To Be Played</title>
<desc id="desc">Architecture of To Be Played: a React frontend talks to an Auth service that signs tokens and a Core service that verifies them. Both use PostgreSQL, Core also calls the Steam API and IGDB.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2530"/>
<stop offset="100%" stop-color="#15181d"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#26c6da"/>
<stop offset="100%" stop-color="#ffa726"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
</marker>
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
</marker>
</defs>
<style>
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
.sans { font-family: Roboto, Arial, sans-serif; }
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
.chip { fill: #cdd6e0; font-size: 19px; }
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
.name { fill: #cdd6e0; font-size: 22px; font-weight: 600; }
.sub { fill: #8b97a6; font-size: 16px; }
.arr { fill: #8b97a6; font-size: 15px; }
</style>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">to-be-played</text>
<text x="64" y="102" class="sans lbl">three services, one login</text>
<rect x="48" y="240" width="196" height="110" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="68" y="274" class="sans name">React Frontend</text>
<text x="68" y="300" class="sans sub">browser</text>
<rect x="350" y="150" width="310" height="120" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="370" y="184" class="sans name">Auth service</text>
<text x="370" y="210" class="sans sub">signs tokens, Ed25519 private key</text>
<text x="370" y="232" class="sans sub">HttpOnly cookies</text>
<rect x="350" y="290" width="310" height="220" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="370" y="324" class="sans name">Core service</text>
<text x="370" y="350" class="sans sub">library, stats, suggestions</text>
<text x="370" y="372" class="sans sub">verifies tokens, public key only</text>
<rect x="740" y="150" width="180" height="230" rx="14" fill="#1a222c" stroke="#2f3a47"/>
<text x="760" y="178" class="sans cap" text-anchor="start">POSTGRESQL</text>
<rect x="760" y="192" width="140" height="56" rx="10" fill="#243040" stroke="#33475c"/>
<text x="830" y="227" class="mono chip" text-anchor="middle">auth db</text>
<rect x="760" y="302" width="140" height="56" rx="10" fill="#243040" stroke="#33475c"/>
<text x="830" y="337" class="mono chip" text-anchor="middle">core db</text>
<text x="1000" y="372" class="sans cap" text-anchor="start">EXTERNAL</text>
<rect x="1000" y="395" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="427" class="sans chip" text-anchor="middle">Steam API</text>
<rect x="1000" y="460" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="492" class="sans chip" text-anchor="middle">IGDB</text>
<path d="M 252 258 H 342" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="297" y="248" class="sans arr" text-anchor="middle">login</text>
<path d="M 252 330 H 342" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="297" y="320" class="sans arr" text-anchor="middle">API, cookie</text>
<path d="M 668 220 H 752" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<path d="M 668 330 H 752" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<path d="M 668 420 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="410" class="sans arr" text-anchor="middle">sync</text>
<path d="M 668 485 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="475" class="sans arr" text-anchor="middle">time to beat</text>
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">react · spring boot · postgresql · docker</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,84 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
<title id="title">Self-hosted infrastructure</title>
<desc id="desc">Architecture of the self-hosted infrastructure: a push to Gitea on the home server builds an image at home, Watchtower on the Hostinger VPS pulls it over Tailscale, and Traefik routes the public sites.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2530"/>
<stop offset="100%" stop-color="#15181d"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#26c6da"/>
<stop offset="100%" stop-color="#ffa726"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
</marker>
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
</marker>
</defs>
<style>
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
.sans { font-family: Roboto, Arial, sans-serif; }
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
.chip { fill: #cdd6e0; font-size: 19px; }
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
.name { fill: #cdd6e0; font-size: 22px; font-weight: 600; }
.sub { fill: #8b97a6; font-size: 16px; }
.arr { fill: #8b97a6; font-size: 15px; }
</style>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">Self-hosted infrastructure</text>
<text x="64" y="102" class="sans lbl">build at home, serve from the VPS</text>
<rect x="130" y="140" width="460" height="390" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
<text x="154" y="168" class="sans cap" text-anchor="start">HOME SERVER "TRIBBLE"</text>
<rect x="154" y="186" width="412" height="130" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="174" y="220" class="sans name">Gitea</text>
<text x="174" y="246" class="sans sub">git, Actions runner, container registry</text>
<rect x="154" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="218" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Traefik</text>
<rect x="296" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="360" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Portainer</text>
<rect x="438" y="346" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="502" y="382" class="sans chip" style="font-size:16px" text-anchor="middle">Jellyfin</text>
<rect x="154" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="218" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">AdGuard Home</text>
<rect x="296" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="360" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">Paperless-ngx</text>
<rect x="438" y="418" width="128" height="58" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="502" y="454" class="sans chip" style="font-size:16px" text-anchor="middle">Tailscale</text>
<rect x="700" y="140" width="452" height="390" rx="18" fill="#1a222c" stroke="#ffa726" stroke-width="2"/>
<text x="724" y="168" class="sans cap" text-anchor="start">HOSTINGER VPS</text>
<rect x="724" y="206" width="404" height="90" rx="10" fill="#243040" stroke="#33475c"/>
<text x="744" y="240" class="sans name">Watchtower</text>
<text x="744" y="266" class="sans sub">pulls new images automatically</text>
<path d="M 736 296 V 484" stroke="#4a5563" stroke-width="2"/>
<text x="750" y="322" class="sans arr" text-anchor="start">deploy</text>
<rect x="752" y="330" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="362" class="sans chip" style="font-size:17px" text-anchor="start">Playground (this site)</text>
<path d="M 736 356 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 356 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="752" y="394" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="426" class="sans chip" style="font-size:17px" text-anchor="start">Pepper Pepperoni</text>
<path d="M 736 420 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 420 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="752" y="458" width="216" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="770" y="490" class="sans chip" style="font-size:17px" text-anchor="start">To Be Played</text>
<path d="M 736 484 H 750" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<path d="M 1000 484 H 976" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<rect x="1000" y="330" width="128" height="180" rx="10" fill="#2a2118" stroke="#5c4326"/>
<text x="1016" y="368" class="sans name" text-anchor="start">Traefik</text>
<text x="1016" y="394" class="sans sub" text-anchor="start">TLS, routing</text>
<path d="M 40 251 H 150" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<text x="44" y="240" class="mono arr" text-anchor="start">git push</text>
<path d="M 574 251 H 716" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<text x="645" y="214" class="sans arr" text-anchor="middle">new image</text>
<text x="645" y="234" class="sans arr" text-anchor="middle">via Tailscale</text>
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">gitea · docker · traefik · tailscale · watchtower</text>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB